Reactで、画像をドラッグして切り抜く方法

​​React​Canvas API,CORS,Playwright,Pointer Events,TypeScript

画像の上に切り抜く範囲の枠を重ね、ドラッグで動かしたり角で大きさを変えたりする仕組みは、Pointer Events と canvas だけで作れます。範囲は画面上の px ではなく、元の画像のピクセル単位で持ちます。こうすると、画面の幅で画像の表示が変わっても範囲はずれず、切り抜くときはその値を canvas の drawImage() にそのまま渡せます。

ここでは、範囲を選ぶ ImageCropper と、選んだ範囲を PNG にする cropImage を作りました。あわせて、setPointerCapture() を呼ばない場合や、画像の draggable="false" を外した場合に何が起きるかも確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 で、Playwright でマウスとキーボードを操作して確かめました。画像は、写真ではなく Pillow で描いた 1200×800 の PNG です。タッチ操作、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。縦横比を固定する切り抜き(正方形のアイコンなど)も、この記事では扱いません。

範囲の中をドラッグして枠を動かし、右下の角をドラッグして縮め、枠の外から家のまわりを新しく囲み、「切り抜く」を押すと、家のまわりを切り抜いた画像が下に出る(切り抜いた画像は GIF では幅 200px で表示)
4回の操作で、範囲と切り抜いた画像が変わる様子。変わった表示だけピンクにしている(カーソルと操作の表示は GIF 用に重ねたもの)

範囲を選ぶコンポーネント

ImageCropper は、切り抜く範囲を crop として親から受け取り、変わるたびに onChange で新しい範囲を渡します。範囲の x・y・幅・高さは、元の画像のピクセル単位で表します(基準は naturalWidth と naturalHeight の大きさです)。

tsx
import { useRef, useState } from 'react';
import type { KeyboardEvent, PointerEvent } from 'react';

// 切り抜く範囲。画像の元の大きさ(naturalWidth / naturalHeight)での px で持つ
export type Crop = { x: number; y: number; width: number; height: number };
type Point = { x: number; y: number };
type Handle = 'nw' | 'ne' | 'sw' | 'se';

type Drag =
  | { mode: 'move'; start: Point; crop: Crop }
  // 角のドラッグと、範囲の外から新しく囲むドラッグ。anchor は動かない側の角
  | { mode: 'resize'; anchor: Point; offset: Point; before: Crop };

type Props = {
  src: string;
  crop: Crop | null;
  onChange: (crop: Crop) => void;
  minSize?: number;
};

const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max);

// 2つの点を対角にする長方形(どちら向きにドラッグしても幅と高さが正になる)
function rectFrom(a: Point, b: Point): Crop {
  return {
    x: Math.round(Math.min(a.x, b.x)),
    y: Math.round(Math.min(a.y, b.y)),
    width: Math.round(Math.abs(a.x - b.x)),
    height: Math.round(Math.abs(a.y - b.y)),
  };
}

export function ImageCropper({ src, crop, onChange, minSize = 20 }: Props) {
  const imgRef = useRef<HTMLImageElement>(null);
  const drag = useRef<Drag | null>(null);
  const [size, setSize] = useState<{ width: number; height: number } | null>(null);

  // 画面上の座標を、画像の元の大きさでの座標に直す
  function toImagePoint(e: PointerEvent, img: HTMLImageElement): Point {
    const r = img.getBoundingClientRect();
    return {
      x: ((e.clientX - r.left) * img.naturalWidth) / r.width,
      y: ((e.clientY - r.top) * img.naturalHeight) / r.height,
    };
  }

  function handleLoad() {
    const img = imgRef.current!;
    const { naturalWidth: w, naturalHeight: h } = img;
    setSize({ width: w, height: h });
    // 最初は、中央の 80% を囲んでおく
    onChange(rectFrom({ x: w * 0.1, y: h * 0.1 }, { x: w * 0.9, y: h * 0.9 }));
  }

  function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
    const img = imgRef.current;
    if (!img || !crop || e.button !== 0) return;
    const p = toImagePoint(e, img);
    const target = (e.target as HTMLElement).dataset.handle as Handle | 'move' | undefined;
    if (target === 'move') {
      drag.current = { mode: 'move', start: p, crop };
    } else if (target) {
      const left = crop.x, top = crop.y, right = crop.x + crop.width, bottom = crop.y + crop.height;
      const corner = { x: target.endsWith('w') ? left : right, y: target.startsWith('n') ? top : bottom };
      const anchor = { x: target.endsWith('w') ? right : left, y: target.startsWith('n') ? bottom : top };
      // 角の中心からずれた所をつかんでも、角が指の位置へ飛ばないようにずれを覚えておく
      drag.current = { mode: 'resize', anchor, offset: { x: corner.x - p.x, y: corner.y - p.y }, before: crop };
    } else {
      drag.current = { mode: 'resize', anchor: p, offset: { x: 0, y: 0 }, before: crop };
    }
    // ポインターが枠の外へ出ても、離すまでこの要素にイベントが届くようにする
    e.currentTarget.setPointerCapture(e.pointerId);
  }

  function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
    const d = drag.current;
    const img = imgRef.current;
    if (!d || !img) return;
    const p = toImagePoint(e, img);
    const w = img.naturalWidth, h = img.naturalHeight;
    if (d.mode === 'move') {
      onChange({
        ...d.crop,
        x: Math.round(clamp(d.crop.x + p.x - d.start.x, 0, w - d.crop.width)),
        y: Math.round(clamp(d.crop.y + p.y - d.start.y, 0, h - d.crop.height)),
      });
    } else {
      const q = { x: clamp(p.x + d.offset.x, 0, w), y: clamp(p.y + d.offset.y, 0, h) };
      onChange(rectFrom(d.anchor, q));
    }
  }

  function handlePointerUp() {
    const d = drag.current;
    drag.current = null;
    // クリックしただけのときなど、小さすぎる範囲になったら元に戻す
    if (d?.mode === 'resize' && crop && (crop.width < minSize || crop.height < minSize)) {
      onChange(d.before);
    }
  }

  // 矢印キーで範囲を 10px ずつ動かす
  function handleKeyDown(e: KeyboardEvent) {
    if (!crop || !size) return;
    const step = { ArrowLeft: [-10, 0], ArrowRight: [10, 0], ArrowUp: [0, -10], ArrowDown: [0, 10] }[e.key];
    if (!step) return;
    e.preventDefault();
    onChange({
      ...crop,
      x: clamp(crop.x + step[0], 0, size.width - crop.width),
      y: clamp(crop.y + step[1], 0, size.height - crop.height),
    });
  }

  const pct = (n: number, total: number) => `${(n / total) * 100}%`;

  return (
    <div
      className="cropper"
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerUp}
    >
      <img ref={imgRef} src={src} alt="切り抜く画像" draggable={false} onLoad={handleLoad} />
      {size && crop && (
        <div
          className="crop-box"
          data-handle="move"
          tabIndex={0}
          role="group"
          aria-label={`切り抜く範囲 ${crop.width}×${crop.height}px(矢印キーで移動)`}
          onKeyDown={handleKeyDown}
          style={{
            left: pct(crop.x, size.width),
            top: pct(crop.y, size.height),
            width: pct(crop.width, size.width),
            height: pct(crop.height, size.height),
          }}
        >
          {(['nw', 'ne', 'sw', 'se'] as const).map((h) => (
            <span key={h} className={`crop-handle ${h}`} data-handle={h} />
          ))}
        </div>
      )}
    </div>
  );
}

ドラッグを始めたときの情報(どの操作か、押した位置、そのときの範囲)は useRef に入れています。ref を書き換えても再レンダリングは起きないので、描画に使わない値を置くのに向いています。画面に出す範囲そのものは、親の state です。

どこを押したかは、押された要素の data-handle で見分けます。枠の中なら move、角のつまみなら nw・ne・sw・se で、どちらでもなければ枠の外です。枠の外を押したときは、押した点を anchor(動かない角)にして、新しく範囲を囲みます。角をドラッグするときも、反対側の角を anchor にするので、同じ rectFrom で長方形を作れます。

見た目は CSS で付けています。枠の位置と大きさは画像に対する % で指定しているので、画面の幅を変えても、元の画像の同じ部分を囲んだままです(幅 640px から 360px に変えても、枠は画像の幅に対して同じ位置にありました)。範囲の外側は、枠の box-shadow を大きく広げて暗くしています。

css
.cropper {
  position: relative;
  max-width: 100%;
  overflow: hidden;
  /* タッチでドラッグしたときに、ページがスクロールしないようにする */
  touch-action: none;
  user-select: none;
}
.cropper img { display: block; max-width: 100%; }

.crop-box {
  position: absolute;
  box-sizing: border-box;
  border: 2px solid #fff;
  /* 範囲の外側を暗くする */
  box-shadow: 0 0 0 9999px rgb(0 0 0 / 0.5);
  cursor: move;
}
.crop-box:focus-visible { outline: 3px solid #00c8e8; }

.crop-handle {
  position: absolute;
  width: 16px;
  height: 16px;
  background: #fff;
  border: 2px solid #067a98;
  box-sizing: border-box;
}
.crop-handle.nw { left: -2px; top: -2px; cursor: nwse-resize; }
.crop-handle.ne { right: -2px; top: -2px; cursor: nesw-resize; }
.crop-handle.sw { left: -2px; bottom: -2px; cursor: nesw-resize; }
.crop-handle.se { right: -2px; bottom: -2px; cursor: nwse-resize; }

ドラッグでの動き

検証用の画像を画面に 600×400 で表示し(元の画像の 1px が画面の 0.5px)、ドラッグしたときの範囲を確かめました。出力の座標は元の画像での px です。範囲を動かす操作、角の操作、新しく囲む操作は、それぞれ最初の範囲(中央の 80%)から始め、同じ種類の2つ目以降は前の結果から続けています。

text
画面上の画像の大きさ: 600×400
範囲:x 120, y 80, 960×640px
範囲の中を、元の画像で右へ100px・下へ60px分ドラッグ: 範囲:x 220, y 140, 960×640px
範囲の中を、右へ500px分(画像の外まで)ドラッグ: 範囲:x 240, y 140, 960×640px
右下の角を、画像の右下の外までドラッグ: 範囲:x 120, y 80, 1080×720px
左上の角を、元の画像の (400, 300) あたりへドラッグ: 範囲:x 384, y 284, 816×516px
左上の角を、右下の角を越えて (1150, 780) あたりへドラッグ: 範囲:x 1134, y 764, 66×36px
範囲の外の (60, 760) から、左上の (20, 20) へドラッグ: 範囲:x 20, y 20, 40×740px
範囲の外の (1180, 20) をクリックしただけ: 範囲:x 20, y 20, 40×740px
範囲の外の (1100, 700) から 10px×5px だけドラッグ: 範囲:x 20, y 20, 40×740px

枠は画像の外へは出ません。右へ大きくドラッグしても、x は画像の幅 1200 から範囲の幅 960 を引いた 240 で止まります。角を画像の外まで引いたときも、角は画像の端で止まります。

左上の角をつかんで (400, 300) へドラッグすると、角は (384, 284) になりました。ポインターは (400, 300) へ動かしましたが、そこに来るのはつかんだつまみの中心で、角はそれより少し左上に残ります。押したときの角とポインターのずれを offset に覚えておき、そのずれを保ったまま動かしているためです。左上の角が右下の角を越えると、rectFrom で2つの角が入れ替わり、幅と高さは正のまま 66×36px になります。枠の外からのドラッグも、同じく押した点と離した点を対角にした範囲になります。

押した点からほとんど動かさずに離すと、とても小さな範囲になります。そこで、幅か高さが minSize(20px)より小さいときは、離した時点で元の範囲に戻しています。10px×5px だけドラッグしたときは、範囲は変わりませんでした。

範囲の枠にはキーボードでもフォーカスでき、矢印キーで 10px ずつ動きます(→ を2回と ↑ を1回で x 120, y 80 から x 140, y 70)。

setPointerCapture を呼ばないと、画像の外へ出たところで範囲が止まる

ポインターのイベントは、ふつうはポインターの下にある要素に届きます(MDN の Pointer Events の説明)。setPointerCapture() を呼ぶと、そのポインターのイベントは、ボタンを離すまで呼んだ要素に届くようになります。

setPointerCapture を何もしない関数に置き換えたページと比べました。右下の角をつかみ、画像の右の外を通って右下の外までドラッグして離し、そのあとボタンを押さずに画像の中央へ戻しています。

text
あり: 右下の角を、画像の右の外を通って右下の外までドラッグして離す: 範囲:x 120, y 80, 1080×720px / 押さずに画像の中央へ戻る: 範囲:x 120, y 80, 1080×720px
なし: 右下の角を、画像の右の外を通って右下の外までドラッグして離す: 範囲:x 120, y 80, 1080×557px / 押さずに画像の中央へ戻る: 範囲:x 120, y 80, 496×336px

呼ばない場合は、画像の外へ出たところで範囲が止まり、高さが 557px のままでした。さらに、ボタンを押さずに画像の上へ戻ると、範囲がまた動きました。画像の外で離したことが伝わらず、ドラッグが終わっていないためです。

画像の draggable を false にしておく

ブラウザは、画像を押したまま動かすと、画像そのもののドラッグ(ドラッグ&ドロップ)を始めます。draggable を指定しない場合の既定の動きで、画像・リンク・選んだ文字がドラッグできます。そこで、<img> に draggable={false} を付けています。

この属性を外したページで、枠の外の画像を押して新しく囲もうとすると、次のようになりました。

text
draggable="false" あり: 範囲の外の (60, 760) から (400, 400) へドラッグ中: 範囲:x 60, y 400, 340×360px / 起きたイベント: なし
draggable を外した: 範囲の外の (60, 760) から (400, 400) へドラッグ中: 範囲:x 60, y 724, 34×36px / 起きたイベント: dragstart, pointercancel

外した場合は、dragstart のあとに pointercancel が起き、範囲は 34×36px で止まりました。pointercancel は、そのポインターのイベントがもう続かないとブラウザが判断したときに送られるイベントです。pointercancel が届くと、onPointerCancel に渡した handlePointerUp が呼ばれてドラッグが終わるので、範囲はそこで止まります(34×36px は minSize より大きいので、元には戻りません)。

タッチでは、ブラウザがスクロールや拡大を始めると pointercancel が送られます(touch-action の説明)。そのため .cropper に touch-action: none を付けていますが、筆者はタッチ操作では試していません。

選んだ範囲を PNG にする

切り抜きは、範囲と同じ大きさの canvas に、元の画像のその部分だけを描いて書き出します。drawImage() は、引数を9つ渡すと、元の画像の (sx, sy, sWidth, sHeight) の範囲を、canvas の (dx, dy, dWidth, dHeight) に描きます。範囲を元の画像の大きさで持っているので、crop の値をそのまま渡せます。

ts
import type { Crop } from './ImageCropper';

// src の画像から crop の範囲を切り出し、PNG の Blob にする
export async function cropImage(src: string, crop: Crop): Promise<Blob> {
  const img = new Image();
  // 別のオリジンの画像でも canvas から書き出せるように、CORS で読み込む
  img.crossOrigin = 'anonymous';
  img.src = src;
  await img.decode();

  const canvas = document.createElement('canvas');
  canvas.width = crop.width;
  canvas.height = crop.height;
  const ctx = canvas.getContext('2d')!;
  // 元の画像の (x, y, width, height) の範囲を、canvas の (0, 0) から同じ大きさで描く
  ctx.drawImage(img, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);

  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('画像を書き出せませんでした'))), 'image/png');
  });
}

使う側の App では、選んだファイルを URL.createObjectURL() で URL にして ImageCropper に渡し、「切り抜く」を押したら cropImage で PNG を作って表示します。

tsx
import { useEffect, useState } from 'react';
import type { ChangeEvent } from 'react';
import { ImageCropper } from './ImageCropper';
import type { Crop } from './ImageCropper';
import { cropImage } from './cropImage';

export function App() {
  const [src, setSrc] = useState<string | null>(null);
  const [crop, setCrop] = useState<Crop | null>(null);
  const [result, setResult] = useState<{ url: string; width: number; height: number } | null>(null);

  // 選んだファイルの URL は、画像を選び直したときと、画面を閉じるときに解放する
  useEffect(() => () => { if (src) URL.revokeObjectURL(src); }, [src]);
  useEffect(() => () => { if (result) URL.revokeObjectURL(result.url); }, [result]);

  function handleFile(e: ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    setCrop(null);
    setResult(null);
    setSrc(URL.createObjectURL(file));
  }

  async function handleCrop() {
    if (!src || !crop) return;
    const blob = await cropImage(src, crop);
    setResult({ url: URL.createObjectURL(blob), width: crop.width, height: crop.height });
  }

  return (
    <main>
      <input type="file" accept="image/*" aria-label="画像を選ぶ" onChange={handleFile} />
      {src && (
        <>
          <ImageCropper src={src} crop={crop} onChange={setCrop} />
          <p className="crop-info">
            {crop ? `範囲:x ${crop.x}, y ${crop.y}, ${crop.width}×${crop.height}px` : '読み込み中'}
          </p>
          <button type="button" onClick={handleCrop} disabled={!crop}>切り抜く</button>
        </>
      )}
      {result && (
        <figure>
          <img className="result" src={result.url} alt="切り抜いた画像" />
          <figcaption>切り抜いた画像:{result.width}×{result.height}px</figcaption>
        </figure>
      )}
    </main>
  );
}

左上を (484, 234) に、大きさを 556×466px にして切り抜き、書き出した PNG と元の画像の同じ範囲を、1画素ずつ比べました。

text
範囲:x 484, y 234, 556×466px
切り抜いた画像:556×466px
書き出した画像: image/png 556×466。元の画像の同じ範囲と色が違う画素: 0 / 259096

色が違う画素はありませんでした。画面には縮小して表示していても、書き出した画像は元の画像の解像度のままです。

別のオリジンの画像は、CORS で読み込まないと書き出せない

選んだファイルではなく、別のオリジンにある画像を切り抜くときは注意が要ります。CORS の許可なしに読み込んだ別のオリジンの画像を canvas に描くと、canvas は「汚染された(tainted)」状態になり、toBlob() などで中身を取り出せなくなります(MDN の説明)。

ページとは別のポートで、Access-Control-Allow-Origin を返さないサーバーと、Access-Control-Allow-Origin: * を返すサーバーを立て、cropImage と同じ処理(crossOrigin の指定のあり・なしだけを変えたもの)で書き出せるかを確かめました。

画像のサーバー crossOrigin 結果
CORS のヘッダーなし 指定なし toBlob で SecurityError
CORS のヘッダーなし "anonymous" 画像の読み込みで失敗
Access-Control-Allow-Origin: * 指定なし toBlob で SecurityError
Access-Control-Allow-Origin: * "anonymous" 書き出せた

書き出すには、サーバーの許可と crossOrigin = "anonymous" の両方が要ります。crossOrigin を指定しないと、CORS のリクエストにならないためです。cropImage で指定しているのはこのためで、選んだファイルの blob: の URL も、指定したまま切り抜けました。

​Posted by 86