
react-konvaを利用して、以下機能を持ったお絵かきアプリを作っていく。
- ペンツール(色の選択が可能)で線が描ける
- 進む戻る(UndoRedo)機能が使える
- 円ツールで円が描ける
- 背景画像付き
gitにソースコード全量あげたのでこちらも参考に。
react-konvaデモ(GitHub)
今回はReact19・TypeScriptを利用している
"react": "19.1.0",
"typescript": "^4.9.5",
"konva": "9.3.20",
"react-konva": "^19.0.4",
Konva・react-konvaのインストール
npm install react-konva konva --save
■ react-konvaとは
KonvaをReactで使いやすくしたKonva公式のライブラリ。
HTML5canvasをReactコンポーネントとして扱えるツールで、図形を操作したり、アニメーションをつけたりできる
■ 基本構造(Stage、Layer、Line)
simple.tsx
      Stage
        // 大きさ定義ないと動かない
        width={300}
        height={300}
      >
        Layer>
          Line points={[1, 1, 100, 100, 200, 200, 300, 50]} stroke="red" />
        Layer>
      Stage>
- Stage キャンバスの土台みたいなもの。このStage上でないとkonvaは動かない
- 
Layer レイヤー層。レイヤーを分けると不要なレンダリングを防げたりする(背景画像とペンツール描画部分を分けたり)ただやみくもに増やせばいいものではなく、最大3-5個程度らしい
 参考 https://konvajs.org/docs/performance/Layer_Management.html
- Line [x1,y1,x2,y2,x3,y3….]といった1つの配列の値を繋いで線を描いている。x,yのセットで1つの配列の中に順に詰めていくようになっており、実際は大量の値を繋いで滑らかにみえるようになる
■ 基本イベント(Mouse系)
onMouseMove (onTouchMove)
マウスの移動をキャッチするイベント。
これでマウスに沿って線を書ける。
ipad対応する場合はonTouchMoveを利用することで対応できる。
onMouseMove.tsx
  // 例えば左上(1,1)からスタート
  const [line, setLine] = useStatenumber[]>([1, 1]);
  // マウスを動きに沿って現在地をセット
  const handleMouseMove = (e) => {
    const point = e.target.getStage().getPointerPosition();
    setLine([...line, point.x, point.y]);
  };
  return (
      Stage width={500} height={500} onMouseMove={handleMouseMove} onTouchMove={handleMouseMove}>
        Layer>
          Line points={line} stroke="black" />
        Layer>
      Stage>
  );
onMouseDown (onTouchStart)
マウスのクリックした瞬間をキャッチするイベント。
これでクリックしたところから線を引き始められる。
onMouseDown.tsx
  const [line, setLine] = useStatenumber[]>([]);
  const handleMouseDown = (e) => {
    const point = e.target.getStage().getPointerPosition();
    setLine([point.x, point.y]);
  };
  return (
      Stage width={500} height={500} onMouseDown={handleMouseDown} onTouchStart={handleMouseDown}>
        Layer>
          Line points={line} stroke="red"/>
        Layer>
      Stage>
  );
onMouseUp (onTouchEnd)
マウスを離した瞬間をキャッチするイベント。
これでマウスを離したタイミングでペイントを終了できる。
■ 1本だけ線が書けるお絵かきアプリ
今の基本イベントを組み合わせて、まずは1本だけ線がかけるお絵描きアプリを作ってみる
ドラッグ中以外でマウスを動かしても反応しないよう、useRefでドラッグ状態を管理。
でドラッグ中かどうかを管理。
 1. MouseDown → isDrawingをtrueにし、スタート位置を記録
 2. MouseMove → isDrawingがtrueの時のみポイント追加
 3. MouseUp  → isDrawingをfalseに、描画終了
  const isDrawing = React.useRef(false);
  const [line, setLine] = useStatenumber[]>([]);
  // クリックした位置から描画スタート
  const handleMouseDown = (e) => {
    const point = e.target.getStage()?.getPointerPosition();
    isDrawing.current = true;
    // スタート位置をセット
    setLine([point.x, point.y]);
  };
  const handleMouseMove = (e) => {
    // ドラッグ中のみ描画
    if (isDrawing.current === false) return;
    const point = e.target.getStage()?.getPointerPosition();
    setLine((prevLine) => [...prevLine, point.x, point.y]);
  };
  // 離したタイミングで描画終了
  const handleMouseUp = () => {
    isDrawing.current = false;
  };
ソースコード全量
ここから、先ほどの基本編を踏まえてもう少し実用的なお絵描きアプリを作っていく
参考:https://konvajs.org/docs/sandbox/Free_Drawing.html
■ 複数の線が描けるようにする
先ほど1本線がかけるお絵描きアプリをまずは複数線かける形に進化させよう。
ついでにクリアボタもつけておこう
データ構造の修正
まずは複数線管理できるようデータ構造を修正
// Before: 1本の線  
const [line, setLine] = useStatenumber[]>([]);
// After: 複数の線の配列
const [lines, setLines] = useStatenumber[][]>([]);
lines = [
  [10, 20, 30, 40],  // 線1
  [50, 60, 70, 80],  // 線2
  [90, 100, 110, 120] // 線3
]
みたいなイメージで管理することにする。
クリック時:新しい線を開始
今までの配列に加え、新しい配列を追加してスタート位置をセット。
const handleMouseDown = (e) => {
    setLines((prev) => [...prev, [point.x, point.y]]);
}
ドラッグ時:現在の線に座標を追加
マウスを動かす際は、最後の現在ペイント中の線( =配列の最後(線3) )に現在地を追加していく感じとなる。
const handleMouseMove = (e) => {
 // 最後に書いた線(配列の最後尾)のインデックス取得
  const lastIdx = lines.length - 1;
  setLines((prev) => [
    // 完成済の線のみまずはセット
    ...prev.slice(0, lastIdx),
    // 現在ペイント中の線に現在地を追加して更新
    [...prev[lastIdx], point.x, point.y] 
  ]);
};
描画時:全ての線をレンダリング
実際の描画はmapでそれぞれの線を描画してあげよう
{lines.map((line, i) => (
  Line key={i} points={line} stroke="black" />
))}
ソースコード全量
■ 色が選べる機能を追加する
pointだけ管理していた部分にカラーも追加で管理できる型に変更して色を選べるようにする。
データ構造の修正
複数色のペンで描画できるよう、色情報も管理するデータ構造に変更。
// Before: 座標のみ
const [lines, setLines] = useStatenumber[][]>([]);
// After: 座標 + 色情報
const [lines, setLines] = useStateArray{ point: number[]; color: string }>>([]);
データイメージ
[
  { point: [10, 20], color: "red" },
  { point: [30, 40], color: "blue" },
  { point: [50, 60], color: "green" }
];
各イベントで色情報を追加
各イベントでも現在選択中の色情報もセットで更新してあげれば良い
const handleMouseDown = (e: any) => {
 setLines([...lines, { point: [point.x, point.y], color }]); 
};
const handleMouseMove = (e) => {
  setLines((prev) => [
      ...prev.slice(0, lastIdx),
      {
        point: [...prev[lastIdx].point, point.x, point.y],
        // 既存の線の色を保持
        color: prev[lastIdx].color, 
      },
    ]);
};
色情報を適用してレンダリング処理
実際の描画で色情報を取りつつ、描画してあげよう
 {lines.map((line, i) => (
    Line key={i} points={line.point} stroke={line.color} />
  ))}
ソースコード全量
全量はこちら
色が選べるお絵かきアプリ(GitHub)
■ 進む戻る(UndoRedo)機能を追加する
以下のような仕様をイメージして機能追加をしていく
- Undo後に新しく描画した場合、Redo履歴は削除する
- 線を1本描くごとに履歴が保存する
今回は公式ドキュメントを参考にRefで履歴を管理しながら書いてみる
参考 https://konvajs.org/docs/react/Undo-Redo.html
履歴管理のデータ構造
refを以下のように用意して履歴管理をしていく
  const history = useRefArray{ point: number[]; color: string }>[]>([[]]);
  const historyStep = useRef(0);
履歴の中身はこんなイメージ
history.current = [
  [], // 初期状態
  [{ point: [10, 10, 50, 50], color: "red" }], // 1本目
  [
    { point: [10, 10, 50, 50], color: "red" },
    { point: [100, 100, 200, 200], color: "blue" }
  ], // 2本目
  [
    { point: [10, 10, 50, 50], color: "red" },
    { point: [100, 100, 200, 200], color: "blue" },
    { point: [100, 200, 300, 300], color: "green" }
  ] // 3本目
];
UndoRedoの実装
Undoの動作例:
例えば3本線が描かれた状態(historyStep.current = 3)でundoを押すと:
- historyStepを1つ戻す(3 → 2)
- history.current[2]の状態(2本線の状態)を取得
- setLinesで画面を更新
history.current[2]で以下が取得できる
[
    { point: [10, 10, 50, 50], color: "red" },
    { point: [100, 100, 200, 200], color: "blue" }
] 
  const handleUndo = () => {
    // 初期状態なら何もしない
    if (historyStep.current === 0) {
      return;
    }
    historyStep.current -= 1;
    const previous = history.current[historyStep.current];
    setLines(previous);
  };
  const handleRedo = () => {
    // 最新なら何もしない
    if (historyStep.current === history.current.length - 1) {
      return;
    }
    historyStep.current += 1;
    const next = history.current[historyStep.current];
    setLines(next);
  };
履歴の追加
線がかき終わった段階(mouseUp時点)で履歴追加を行う
  const handleMouseUp = () => {
    isDrawing.current = false;
    // 現在表示中の線をnewHistoryに取り出す
    const newHistory = history.current.slice(0, historyStep.current + 1);
    // 最新の線を追加
    newHistory.push([...lines]);
    // currentを最新状態に更新
    history.current = newHistory;
    // historyStepも最新状態に更新
    historyStep.current = newHistory.length - 1;
    // Linesの方も一貫性確保のためこのタイミングで更新 ← これが無いと1本前の線が短くなる!
    setLines([...lines]);
  };
 注意点
注意点
Linesの方も一貫性確保のためこのタイミングで更新している。
このタイミングで更新しないと、素早く2本目の線を描いた場合に1本前の線の最後だけ少し短くなる現象が発生する。
ソースコード全量
全量はこちら
UndoRedo機能付きお絵かきアプリ(GitHub)
■ 背景をつけてみる
背景画像を入れる基本の形
Layerを分けて実装。
そうすることでLineを書いてる際にImage側が再レンダリングされなくなるらしい。
https://konvajs.org/docs/performance/Layer_Management.html
Stage>
    Layer>
        Image 
            image={backgroundImage}
            width={400}
            height={400} 
        />
    Layer>
    Layer>
        Line points={line.point} stroke={line.color} />
    Layer>
Stage>
背景画像のロード
konva提供のカスタムフック「useImage」を利用してロード
konvaが提供する画像読み込みカスタムフック「useImage」というのがあったのでそれを使ってみる
use-imageのinstall
use-Img.tsx
const URLImage = ({ src, ...rest }: { src: string }) => {
  const [image] = useImage(src, "anonymous");
  return (
    Image
      image={image}
      width={400}
      height={400}
      {...rest}
    />
  );
};
URLImage src="/img/sampledeno.jpeg" />
参考 https://konvajs.org/docs/react/Images.html
useEffectでロード
use-image使わずでも
simple-Img.tsx
    const [backgroundImage, setBackgroundImage] = useStateHTMLImageElement>();
    useEffect(() => {
    const image = new window.Image();
    image.src = "/img/sampledeno.jpeg";
    image.onload = () => {
      setBackgroundImage(image);
    };
    }, []);
    
    Image
      image={backgroundImage}
      width={400}
      height={400}
    />            
おまけ:縦横比を維持する
縦横比を維持して画像を入れたい時はこんなふうに横幅セットしてあげるといい感じに入る。
width = {(image.width / image.height) * canvasHeight}
height = {canvasHeight}
ソースコード全量
全量はこちら
背景画像付きお絵かきアプリ(GitHub)
■ 円を描けるようにする
マウスに合わせて大きさが変わる円をかける機能を用意していく
円を描画する仕組み
円の中心地点、半径、色などを指定してあげると円がかける。
参考)https://konvajs.org/api/Konva.Circle.html
     Circle
        // 円の中心地点
        x={200}
        y={200}
        // 半径
        radius={50}
        // 線の色
        stroke="pink"
    />
円を管理するデータとしては以下のような形で考える。
{ x: number; y: number; radius: number;}
クリック&ドラッグでサイズ決定する
操作の流れ
- クリック地点:円の中心として固定
- ドラッグ:マウスを動かした距離が円の半径になる
線描画とは異なり、円では「中心地点(Mouse Down地点)から現在のマウス位置までの距離」を半径として更新し続ける
半径の計算
半径の大きさはhypotで計算できる。
circle.x , circle.y:最初にクリックした中心地点(Mouse Down地点)
point.x , point.y:マウスの現在地点
const radius = Math.hypot(point.x - circle.x, point.y - circle.y);
// 計算した半径だけを更新
setCircle({
    ...circle,
    radius,
});
hypotの距離計算についてMath.hypot(dx, dy)は三平方の定理(ピタゴラスの定理)で距離を計算します
r = √(dx² + dy²)
ペン/円ツールの切り替え
circleを選択中は半径を更新していけるよう、切り替えボタンなどで制御できるようにしてく
  const [tool, setTool] = useState"pen" | "circle">("pen");
複数円への対応
線を複数本管理するときの考え方と基本同じ、配列でそれぞれの円を管理する。
const [circles, setCircles] = useState
    Array{
      x: number;
      y: number;
      radius: number;
    }>
  >([]);
  
[
  { x: 100, y: 150, radius: 50 },
  { x: 250, y: 200, radius: 30 },
  { x: 180, y: 80, radius: 40 }
]
ドラッグ時の更新も、現在描画中(配列最後の要素の円)の半径だけを更新できるようにする必要がある。
  setCircles((prev) => [
    ...prev.slice(0, lastIdx),
    {
      ...prev[lastIdx],
      radius,
    },
  ]);
ソースコード全量
全量はこちら
円がかけるお絵かきアプリ
■ その他入れておくと良さげな処理
滑らかな線にするための設定
以下のような設定を入れておくと、マウスを早く動かしても補正が入るため、線が滑らかに見えるようになる。
Line
  ~~~省略~~~
  tension={0.5}
  lineCap="round"
  lineJoin="round"
/>
canvasをはみ出た場合の処理
ドラッグ中、canvasサイズ(Stageで指定したサイズ)をはみ出してから、ドラッグ終了(MouseUp)が検知されない。canvas外でドラッグ終了した場合も終了できるよう以下のように入れておいてもいいかもしれない
useEffect(() => {
  const handleGlobalMouseUp = () => {
    isDrawing.current = false;
  };
  document.addEventListener("mouseup", handleGlobalMouseUp);
  return () => {
    document.removeEventListener("mouseup", handleGlobalMouseUp);
  };
}, []);
Views: 0
 
                                    


