Reactで、マス目に色を塗るドット絵エディターを作る方法
ドット絵エディターは、マス目の色を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、スマートフォンのブラウザ、キーボードだけでの操作は試していません。

マス目の色を配列で持ち、reducer で書き換える
マス目の色は、長さ 256(16×16)の配列で持ちます。マスの位置は、左上を (0,0) とし、0 から数えた (列, 行) で表します。x 列・y 行のマスは、配列の y * 16 + x 番目の要素です。塗っていないマスは null にしておき、保存するときに透明にします。
マス目を書き換える操作は、次の reducer にまとめました。past は元に戻すための履歴、strokeStart はドラッグを始めたときの絵です。
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つではなくマス目の全体に付けています。
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)にしています。
.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 までを、ブレゼンハムのアルゴリズムで直線にして、通るマスをすべて塗っています。
// 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 で呼んだ結果です。斜めの線でも、縦の線でも、両端を含めて途切れずにマスが並びます。
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 マスで、塗ったマスの一覧も同じでした。
塗りつぶしは、同じ色でつながったマスをたどる
塗りつぶしは、クリックしたマスと同じ色で、上下左右につながったマスを順にたどって塗ります。
// 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マスには届きません。
塗りつぶしの前: .#.. / .#.. / ##.. / ....
(0,0) を塗りつぶし: o#.. / o#.. / ##.. / ....
(3,0) を塗りつぶし: .#oo / .#oo / ##oo / oooo
上下左右だけをたどるので、斜めにだけ接しているマスには広がりません。3×3 の絵で、黒いマスに囲まれた左上の (0,0) は、(1,1) と斜めに接していますが、塗られずに残りました。
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 の画像になります。
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 で読みました。
保存したファイル名: 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 の正方形の端まで同じ色で描かれています。





