Reactで、マス目に色を塗るドット絵エディターを作る方法

​​React​Canvas,Pointer Events,TypeScript

ドット絵エディターは、マス目の色を1つの配列で持ち、ポインターの位置から「何列目・何行目か」を計算してその要素を書き換えれば作れます。ドラッグで塗るときは、1つ前に塗ったマスから今のマスまでを直線で埋めると、ポインターを速く動かしても線が途切れません。

ここでは 16×16 のマス目に、ペン、消しゴム、塗りつぶし、「元に戻す」と「PNGで保存」のボタンを付けました。マス目の変化は useReducer の reducer にまとめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright の page.mouse でマウスを操作しました。保存した PNG の色は sharp 0.33.5 で読み取りました。タッチ操作は、Chrome DevTools Protocol で指の操作を送り、なぞったときに塗られるマスを比べることだけを試しました。ペン、Firefox と Safari、スマートフォンのブラウザ、キーボードだけでの操作は試していません。

16×16 のマス目の上で、ペンを選んだ状態で (3,3) から右・下・左・上へドラッグし、黒い四角の枠を描く。次に黄色と「塗りつぶし」を選んで枠の内側をクリックすると、内側が黄色になる。最後に「元に戻す」を押すと黄色が消え、黒い枠だけに戻る。下の行には、塗ったマスの数が 0、36、100、36 と変わっていく
ペンで枠を描き、内側を塗りつぶして、元に戻すまでの様子。塗ったマスの数が変わったコマだけ、その数字がピンクになる(カーソル、押している間の輪、操作の説明と回数、塗ったマスの数の表示は GIF 用に重ねたもの)

マス目の色を配列で持ち、reducer で書き換える

マス目の色は、長さ 256(16×16)の配列で持ちます。マスの位置は、左上を (0,0) とし、0 から数えた (列, 行) で表します。x 列・y 行のマスは、配列の y * 16 + x 番目の要素です。塗っていないマスは null にしておき、保存するときに透明にします。

マス目を書き換える操作は、次の reducer にまとめました。past は元に戻すための履歴、strokeStart はドラッグを始めたときの絵です。

ts
import { cellsOnLine, floodFill, type Pixels, type Point } from './pixels';

export const SIZE = 16;

export type State = {
  pixels: Pixels;
  past: Pixels[]; // 元に戻すための履歴
  strokeStart: Pixels | null; // ドラッグを始めたときの絵
};

export type Action =
  | { type: 'begin' }
  | { type: 'paint'; from: Point; to: Point; color: string | null }
  | { type: 'end' }
  | { type: 'fill'; at: Point; color: string }
  | { type: 'undo' };

const inside = ({ x, y }: Point) => x >= 0 && y >= 0 && x < SIZE && y < SIZE;

export const initialState: State = {
  pixels: Array(SIZE * SIZE).fill(null),
  past: [],
  strokeStart: null,
};

export function editorReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'begin':
      return { ...state, strokeStart: state.pixels };
    case 'paint': {
      let next = state.pixels;
      for (const p of cellsOnLine(action.from, action.to)) {
        const i = p.y * SIZE + p.x;
        if (!inside(p) || next[i] === action.color) continue;
        if (next === state.pixels) next = [...state.pixels];
        (next as (string | null)[])[i] = action.color;
      }
      return next === state.pixels ? state : { ...state, pixels: next };
    }
    case 'end': {
      const start = state.strokeStart;
      if (start === null) return state;
      // 1回のドラッグで何か変わったときだけ、始めたときの絵を履歴に積む
      const past = start === state.pixels ? state.past : [...state.past, start];
      return { ...state, past, strokeStart: null };
    }
    case 'fill': {
      if (!inside(action.at)) return state;
      const next = floodFill(state.pixels, SIZE, action.at, action.color);
      if (next === state.pixels) return state;
      return { ...state, pixels: next, past: [...state.past, state.pixels] };
    }
    case 'undo': {
      if (state.past.length === 0) return state;
      return { ...state, pixels: state.past[state.past.length - 1], past: state.past.slice(0, -1) };
    }
  }
}

paint では、色が変わるマスが見つかったときに初めて配列をコピーし、そのコピーを書き換えます。React の公式ドキュメントにある配列の state の更新方法のとおり、state の配列は読み取り専用として扱い、変えるときは新しい配列を作ります。useReducer のリファレンスでは、reducer は純粋な関数にするよう求められています。上の reducer は、受け取った state.pixels を書き換えません。

何も変わらなかったときは、受け取った state をそのまま返します。同じ色で塗られたマスをなぞっただけなら、配列は作り直されません。

ポインターの位置から、何列目・何行目かを計算する

画面のコンポーネントは次のとおりです。マス目は CSS グリッドで 256 個の div を並べたもので、ポインターのイベントは、マスの1つ1つではなくマス目の全体に付けています。

tsx
import { useReducer, useRef, useState } from 'react';
import { editorReducer, initialState, SIZE } from './editorReducer';
import { download, exportPng } from './exportPng';
import type { Point } from './pixels';

type Tool = 'pen' | 'eraser' | 'fill';

const PALETTE = ['#1f2a30', '#ffffff', '#d85878', '#f2b632', '#3aa655', '#00c8e8', '#6a4bc4', '#8a5a3c'];
const TOOLS: { id: Tool; label: string }[] = [
  { id: 'pen', label: 'ペン' },
  { id: 'eraser', label: '消しゴム' },
  { id: 'fill', label: '塗りつぶし' },
];

export function PixelEditor() {
  const [state, dispatch] = useReducer(editorReducer, initialState);
  const [color, setColor] = useState(PALETTE[0]);
  const [tool, setTool] = useState<Tool>('pen');
  const last = useRef<Point | null>(null); // ドラッグ中、最後に塗ったマス

  // ポインターの位置から、何列目・何行目のマスかを求める
  function cellAt(e: React.PointerEvent<HTMLDivElement>): Point {
    const r = e.currentTarget.getBoundingClientRect();
    return {
      x: Math.floor(((e.clientX - r.left) / r.width) * SIZE),
      y: Math.floor(((e.clientY - r.top) / r.height) * SIZE),
    };
  }

  function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
    if (e.button !== 0) return;
    const p = cellAt(e);
    if (tool === 'fill') {
      dispatch({ type: 'fill', at: p, color });
      return;
    }
    e.currentTarget.setPointerCapture(e.pointerId);
    last.current = p;
    dispatch({ type: 'begin' });
    dispatch({ type: 'paint', from: p, to: p, color: tool === 'eraser' ? null : color });
  }

  function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
    const from = last.current;
    if (from === null) return;
    const to = cellAt(e);
    if (to.x === from.x && to.y === from.y) return;
    dispatch({ type: 'paint', from, to, color: tool === 'eraser' ? null : color });
    last.current = to;
  }

  function endStroke() {
    if (last.current === null) return;
    last.current = null;
    dispatch({ type: 'end' });
  }

  return (
    <div className="pixel-editor">
      <div className="toolbar">
        {TOOLS.map((t) => (
          <button key={t.id} type="button" aria-pressed={tool === t.id} onClick={() => setTool(t.id)}>
            {t.label}
          </button>
        ))}
        <button type="button" onClick={() => dispatch({ type: 'undo' })} disabled={state.past.length === 0}>
          元に戻す
        </button>
        <button type="button" onClick={async () => download(await exportPng(state.pixels, SIZE, 16), 'pixel-art.png')}>
          PNGで保存
        </button>
      </div>
      <div className="palette">
        {PALETTE.map((c) => (
          <button
            key={c}
            type="button"
            aria-label={c}
            aria-pressed={color === c}
            style={{ background: c }}
            onClick={() => setColor(c)}
          />
        ))}
      </div>
      <div
        className="grid"
        style={{ gridTemplateColumns: `repeat(${SIZE}, 1fr)` }}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endStroke}
        onPointerCancel={endStroke}
      >
        {state.pixels.map((c, i) => (
          <div key={i} className="cell" style={{ background: c ?? undefined }} />
        ))}
      </div>
    </div>
  );
}

cellAt は、ポインターの位置から、getBoundingClientRect() で得たマス目の左上の位置を引きます。その距離をマス目の幅に対する割合に直し、16 を掛けて切り捨てます。幅に対する割合で計算しているので、マス目の幅を CSS で変えても、cellAt を直す必要はありません。ただし、後で書くとおり、マス目に border を付けたときは、getBoundingClientRect() の幅に枠の幅が入ります。

マスごとに onPointerEnter を付ける作り方もありますが、ここでは使っていません。MDN の setPointerCapture() のページによると、ポインターキャプチャを設定すると pointerenter や pointerleave は発生しなくなります。また、タッチ画面のように画面を直接触って操作する場合は、pointerdown のときにポインターキャプチャが自動で設定されると書かれています。

これを確かめるため、マスごとに onPointerDown と onPointerEnter を付けて塗る版も作り、この記事の作りと比べました。Playwright でタッチを有効にしたページに、Chrome DevTools Protocol の Input.dispatchTouchEvent で指の操作を送り、(1,5) から (14,5) まで右へなぞりました。マウスでも同じ線をドラッグしました。実機の指では試していません。

作り 指でなぞる マウスでドラッグ
マスごとに onPointerEnter 1 マス((1,5) だけ) 14 マス
この記事の作り 14 マス 14 マス

マスごとに付けた版を指でなぞると、触れたマスの要素にポインターキャプチャが自動で設定され、そのあとは、ほかのマスに pointerenter が1回も届きませんでした。マウスでは、キャプチャは設定されず、pointerenter が 13 回届いて 14 マスが塗られました。この記事の作りでは、pointerenter は届かなくても、マス目の要素で受け取った pointermove の位置からマスを計算するので、指でもマウスでも同じ 14 マスが塗られました。

マス目の要素でポインターをキャプチャしておくと、ポインターがマス目の外に出てもイベントが届き続けます。検証環境で (2,2) から右の外へ出て (2,13) に戻ったところ、外にいる間の位置はマス目の範囲の外になるので塗られず、右端の列では (15,6) と (15,9) だけが塗られました。外へ出る線と戻る線が、右端の列を通ったところです。

マス目の CSS は次のとおりです。マス目の外枠を border にすると、getBoundingClientRect() の大きさに枠の幅が入り、検証環境では 322px になりました。このままだと、cellAt で割る幅が枠の分だけ大きくなり、マスの位置が少しずれます。そこで outline で枠を描き、マス目をちょうど 320px(1マス 20px)にしています。

css
.pixel-editor .grid {
  display: grid;
  width: 320px;
  aspect-ratio: 1;
  touch-action: none;
  user-select: none;
  background: #fff;
  outline: 1px solid #9aa5ab;
}
.pixel-editor .cell { box-shadow: inset 0 0 0 0.5px #d5dde0; }

MDN によると、touch-action: none を指定すると、ブラウザーはパンやズームのジェスチャーを扱わなくなります。指で塗るときにページがスクロールしないように付けています。同じページには、この指定がブラウザーのズーム機能の操作を妨げることがある、という注意もあります。

速く動かしたときは、前のマスとの間を直線で埋める

ポインターを速く動かすと、pointermove が通ったマスごとに届くとは限りません。MDN の getCoalescedEvents() のページにも、ブラウザーは複数の pointermove を1つにまとめることがあり、速く大きく動かしたときは位置の細かさが下がる、とあります。

そこで reducer の paint では、前に塗ったマス from から今のマス to までを、ブレゼンハムのアルゴリズムで直線にして、通るマスをすべて塗っています。

ts
// a から b までの直線が通るマス(ブレゼンハムのアルゴリズム)
export function cellsOnLine(a: Point, b: Point): Point[] {
  const cells: Point[] = [];
  const dx = Math.abs(b.x - a.x);
  const dy = -Math.abs(b.y - a.y);
  const sx = a.x < b.x ? 1 : -1;
  const sy = a.y < b.y ? 1 : -1;
  let err = dx + dy;
  let { x, y } = a;
  while (true) {
    cells.push({ x, y });
    if (x === b.x && y === b.y) break;
    const e2 = 2 * err;
    if (e2 >= dy) { err += dy; x += sx; }
    if (e2 <= dx) { err += dx; y += sy; }
  }
  return cells;
}

Node.js で呼んだ結果です。斜めの線でも、縦の線でも、両端を含めて途切れずにマスが並びます。

text
cellsOnLine (1,1)→(6,3): (1,1) (2,1) (3,2) (4,2) (5,3) (6,3)
cellsOnLine (2,5)→(2,1): (2,5) (2,4) (2,3) (2,2) (2,1)

直線で埋めるものと、cellsOnLine(action.from, action.to) を [action.to] に替えて今のマスだけを塗るものを、ブラウザーで比べました。どちらも (1,1) から (14,10) までドラッグし、途中の pointermove の数は Playwright の mouse.move の steps で変えています。

途中の動き 直線で埋める 今のマスだけ塗る
steps 1(一気に動かす) 14 マス 2 マス
steps 4 14 マス 5 マス
steps 30(ゆっくり動かす) 17 マス 17 マス

steps 4 のとき、ボタンを押している間に届いた pointermove は4回でした。今のマスだけを塗るものでは、押したときの (1,1) と、4回の pointermove の位置の (4,3)、(8,6)、(11,8)、(14,10) だけが塗られ、飛び飛びの点になりました。ゆっくり動かしたときは、どちらも 17 マスで、塗ったマスの一覧も同じでした。

塗りつぶしは、同じ色でつながったマスをたどる

塗りつぶしは、クリックしたマスと同じ色で、上下左右につながったマスを順にたどって塗ります。

ts
// start と同じ色でつながったマス(上下左右)を color で塗る
export function floodFill(pixels: Pixels, size: number, start: Point, color: string): Pixels {
  const target = pixels[start.y * size + start.x];
  if (target === color) return pixels;
  const next = [...pixels];
  const stack: Point[] = [start];
  while (stack.length > 0) {
    const { x, y } = stack.pop()!;
    if (x < 0 || y < 0 || x >= size || y >= size) continue;
    const i = y * size + x;
    if (next[i] !== target) continue;
    next[i] = color;
    stack.push({ x: x + 1, y }, { x: x - 1, y }, { x, y: y + 1 }, { x, y: y - 1 });
  }
  return next;
}

最初に、クリックしたマスがすでに塗る色と同じかを確かめています。同じ色のまま進めると、塗ったマスがまた「同じ色のマス」として見つかり、たどるのが終わらなくなるからです。このときは配列をそのまま返すので、reducer の fill は履歴を積みません。

4×4 の絵で試した結果です(# は黒、. は空、o は塗りつぶした色、/ は行の区切り)。(0,0) を塗りつぶすと、黒い線で囲まれた左上の2マスだけが塗られます。右側の (3,0) を塗りつぶすと、下の行を回って左下まで広がりますが、左上の2マスには届きません。

text
塗りつぶしの前: .#.. / .#.. / ##.. / ....
(0,0) を塗りつぶし: o#.. / o#.. / ##.. / ....
(3,0) を塗りつぶし: .#oo / .#oo / ##oo / oooo

上下左右だけをたどるので、斜めにだけ接しているマスには広がりません。3×3 の絵で、黒いマスに囲まれた左上の (0,0) は、(1,1) と斜めに接していますが、塗られずに残りました。

text
3×3 の前: .#. / #.. / ...
3×3 の (2,2) を塗りつぶし: .#o / #oo / ooo

ブラウザーでも、(3,3) から (12,12) までの黒い枠(36 マス)の内側をピンクで塗りつぶすと、ピンクは 64 マスで、枠の外には広がりませんでした。枠の外側をクリックすると、外の 156 マスが塗られ、全部で 256 マスになりました。

1回のドラッグを、元に戻す1回分にする

ペンで塗るときは、ドラッグのあいだに paint が何回も届きます。そのたびに履歴を積むと、「元に戻す」を1回押しても、最後の数マスしか戻りません。

そこで、押したときに begin で今の絵を strokeStart に残し、離したときの end で、絵が変わっていれば strokeStart を1つだけ履歴に積んでいます。塗りつぶしは1回のクリックで終わるので、fill の中で履歴を積みます。

検証環境のブラウザーで、続けて操作した結果です。

操作 塗ったマス ピンクのマス
ペンで枠を1回のドラッグで描く 36 0
内側をピンクで塗りつぶす 100 64
外側をピンクで塗りつぶす 256 220
「元に戻す」を押す 100 64
消しゴムで 7 行の (3,7) から (12,7) へ 90 56
「元に戻す」を押す 100 64
「元に戻す」を押す 36 0

枠を描いたドラッグは、「元に戻す」1回で消えるまとまりになっています。最後に、残った黒い枠の上辺を黒のペンでなぞってから「元に戻す」を押すと、塗ったマスは 0 になり、「元に戻す」は押せない状態になりました。上辺をなぞっても絵が変わらないので、そのドラッグは履歴に積まれず、「元に戻す」で枠を描く前まで戻ったためです。

canvas に描き直して、PNG で保存する

保存するときは、配列から canvas に描き直して PNG にします。1マスを 16px の正方形にしたので、16×16 のマス目は 256×256 の画像になります。

ts
import type { Pixels } from './pixels';

// 1マスを scale px の正方形にして PNG にする。塗っていないマスは透明のまま
export function exportPng(pixels: Pixels, size: number, scale: number): Promise<Blob> {
  const canvas = document.createElement('canvas');
  canvas.width = size * scale;
  canvas.height = size * scale;
  const ctx = canvas.getContext('2d')!;
  pixels.forEach((color, i) => {
    if (color === null) return;
    ctx.fillStyle = color;
    ctx.fillRect((i % size) * scale, Math.floor(i / size) * scale, scale, scale);
  });
  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/png');
  });
}

export function download(blob: Blob, fileName: string) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = fileName;
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 0);
}

toBlob() のコールバックには、画像を作れなかったときに null が渡されることがあるので、そのときは Promise を reject にしています。URL.createObjectURL() で作った URL を <a> の download 属性と一緒に使うと、download の値がファイル名として提案されます。検証環境の Chromium では、pixel-art.png という名前でダウンロードされました。使い終わった URL は URL.revokeObjectURL() で解放します。

黒い枠の内側をピンクで塗った絵を保存し、ダウンロードされたファイルを sharp で読みました。

text
保存したファイル名: pixel-art.png
PNG: 256×256、チャンネル 4
  左上(塗っていないマス) (0,0) の RGBA: 0,0,0,0
  枠の角 (3,3) の左上の画素 (48,48) の RGBA: 31,42,48,255
  枠の角 (3,3) の右下の画素 (63,63) の RGBA: 31,42,48,255
  内側 (7,7) の中ほど (120,120) の RGBA: 216,88,120,255

塗っていないマスは透明(アルファ 0)のままです。枠の角のマスは、16px の正方形の左上と右下の画素がどちらもパレットの黒(#1f2a30)でした。1マスが、16px の正方形の端まで同じ色で描かれています。

​Posted by 86