🎨

Next.js(App router) + react-konva + react-color-paletteでお絵描きアプリを実装

2024/06/14に公開

はじめに

こんにちは、株式会社コズムの穴澤です。

以前「node-canvasのビルド時に遭遇したエラー」というタイトルで記事を投稿したのですが、せっかくなのでnode-canvasを使用してどのように実装できるのかというものをお見せしようと思い、今回こちらの記事を執筆させていただいております。

実装したもの

さて、何を実装したのかというとズバリ、「お絵描きアプリ」です。実際には以下のようの動作ができます。

他にも、手順を戻したり、描画をリセットしたり・・・

もちろん消しゴムや太さの変更、HEX値での色の変更が行えます。
あとは描画したcanvasをpngでダウンロードできる機能(一番右のボタン)も実装しています。

使用技術

今回はNext.js(App router)を使用しています。UI componentライブラリはMUIを使用しました。また、アイコンの一部にreact-iconsも使用しています。

ディレクトリ構成(概略)

src配下の構成を記述しています。(いくつか不要なものは端折っています。)

.src
├─app
    ├─global.css
    ├─layout.tsx
    └─page.tsx
├─components
    └─FreeDrawingComponent
        ├─index.tsx
        └─style.css

実装手順

1. react-konvaでFree Drawingを実装

いきなりほぼほぼ本題ではありますが、react-konvaの公式デモにFree Drawingという項目があります。基本的にはこのサンプルコードを参考にしています。

参考:公式のサンプルコード
    import React from 'react';
    import { createRoot } from 'react-dom/client';
    import { Stage, Layer, Line, Text } from 'react-konva';
    
    const App = () => {
      const [tool, setTool] = React.useState('pen');
      const [lines, setLines] = React.useState([]);
      const isDrawing = React.useRef(false);
    
      const handleMouseDown = (e) => {
        isDrawing.current = true;
        const pos = e.target.getStage().getPointerPosition();
        setLines([...lines, { tool, points: [pos.x, pos.y] }]);
      };
    
      const handleMouseMove = (e) => {
        // no drawing - skipping
        if (!isDrawing.current) {
          return;
        }
        const stage = e.target.getStage();
        const point = stage.getPointerPosition();
        let lastLine = lines[lines.length - 1];
        // add point
        lastLine.points = lastLine.points.concat([point.x, point.y]);
    
        // replace last
        lines.splice(lines.length - 1, 1, lastLine);
        setLines(lines.concat());
      };
    
      const handleMouseUp = () => {
        isDrawing.current = false;
      };
    
      return (
        <div>
          <Stage
            width={window.innerWidth}
            height={window.innerHeight}
            onMouseDown={handleMouseDown}
            onMousemove={handleMouseMove}
            onMouseup={handleMouseUp}
          >
            <Layer>
              <Text text="Just start drawing" x={5} y={30} />
              {lines.map((line, i) => (
                <Line
                  key={i}
                  points={line.points}
                  stroke="#df4b26"
                  strokeWidth={5}
                  tension={0.5}
                  lineCap="round"
                  lineJoin="round"
                  globalCompositeOperation={
                    line.tool === 'eraser' ? 'destination-out' : 'source-over'
                  }
                />
              ))}
            </Layer>
          </Stage>
          <select
            value={tool}
            onChange={(e) => {
              setTool(e.target.value);
            }}
          >
            <option value="pen">Pen</option>
            <option value="eraser">Eraser</option>
          </select>
        </div>
      );
    };
    
    const container = document.getElementById('root');
    const root = createRoot(container);
    root.render(<App />);
    
    ```

ここに若干手を加えていきます。

index.tsx
"use client";

import { useState, useRef } from "react";

import { Stage, Layer, Line } from "react-konva";

import IconButton from "@mui/material/IconButton";

import { LuPencil, LuEraser } from "react-icons/lu";

const FreeDrawingComponent = () => {
  const [tool, setTool] = useState("pen");
  const [lines, setLines] = useState<any[]>([]);
  const isDrawing = useRef(false);

  const handleMouseDown = (e: any) => {
    isDrawing.current = true;
    const position = e.target.getStage().getPointerPosition();
    setLines([
      ...lines,
      {
        tool,
        points: [position.x, position.y],
        color: "#df4b26",
        strokeWidth: 0.5,
      },
    ]);
  };

  const handleMouseMove = (e: any) => {
    if (!isDrawing.current) {
      return;
    }
    const position = e.target.getStage().getPointerPosition();
    let lastLine = lines[lines.length - 1];
    lastLine.points = lastLine.points.concat([position.x, position.y]);

    lines.splice(lines.length - 1, 1, lastLine);
    setLines(lines.concat());
  };

  const handleMouseUp = () => {
    isDrawing.current = false;
  };

  const onClickChangeTool = (tool: string) => {
    setTool(tool);
  };

  return (
    <div className="free-drawing-container">
      <Stage
        width={window.innerWidth}
        height={window.innerHeight}
        onMouseDown={handleMouseDown}
        onMousemove={handleMouseMove}
        onMouseUp={handleMouseUp}
      >
        <Layer>
          {lines.map((line, i) => (
            <Line
              key={i}
              points={line.points}
              stroke={line.color}
              strokeWidth={line.strokeWidth}
              tension={0.5}
              lineCap="round"
              lineJoin="round"
              globalCompositeOperation={
                line.tool === "eraser" ? "destination-out" : "source-over"
              }
            />
          ))}
        </Layer>
      </Stage>
      <div className="toolbar">
        <IconButton
          onClick={() => onClickChangeTool("pen")}
          color={tool === "pen" ? "primary" : "default"}
        >
          <LuPencil />
        </IconButton>
        <IconButton
          onClick={() => onClickChangeTool("eraser")}
          color={tool === "eraser" ? "primary" : "default"}
        >
          <LuEraser />
        </IconButton>
      </div>
    </div>
  );
};

export default FreeDrawingComponent;

この後の実装のためにLineコンポーネントのstrokeプロパティおよびstrokeWidthプロパティには値をベタ書きではなく、stateを渡すようにしています。このstateはhandleMouseDown関数内で定義しています。

また、ツールの切り替えをonClickChangeToolという関数とIconButtonに変更しています。そしてIconButtonのカラープロパティはどのツールを現在使用しているか分かるようにするためです。

これでreact-konvaの公式デモの機能を損なわずに書き換えが完了しました。

2. react-color-paletteを用いて色変更機能の実装

1. 必要なもののインポート

index.tsx
import { useEffect } from "react";
    
import { ColorPicker, useColor } from "react-color-palette";
import "react-color-palette/css";
    
import { LuPalette } from "react-icons/lu";

2. react-color-paletteのuseColorフックの使用

初期値はblack(#000000)にしておきます。

index.tsx
    const [color, setColor] = useColor("#000000");

3. ColorPickerの実装

この時、ツールボタンを押したときだけ表示できるようにしたいので出し分けをします。
また、入力をHEX値だけに限定したいので hideInputのpropsに”rgb”および”hsv”を指定します。

index.tsx
    const [isDisplayColorPicker, setIsDisplayColorPicker] = useState(false);
    const onClickDisplayColorPicker = () => {
        setIsDisplayColorPicker(!isDisplayColorPicker);
    };
          
    return(
    	
        //中略
          
        <IconButton
            onClick={onClickDisplayColorPicker}
            color={isDisplayColorPicker ? "primary" : "default"}
        >
          <LuPalette />
        </IconButton>
          
        //中略
          
        <div style={{ display: isDisplayColorPicker ? "block" : "none" }}>
          <ColorPicker
              hideInput={["rgb", "hsv"]}
              color={color}
              onChange={setColor}
          />
        </div>

4. handleMouse関数内にcolorのstateを挿入

HEX値で指定しているため、colorではなく、color.hexを入れます。
ColorPickerでセットしたcolorが反映されるようにしたいので、useEffectの第二引数にcolorをとり、colorに変更が入るたびにcolorがセットされるようにしています。

index.tsx
    const handleMouseDown = (e: any) => {
        isDrawing.current = true;
        const position = e.target.getStage().getPointerPosition();
        setLines([
          ...lines,
          {
            tool,
            points: [position.x, position.y],
-           color: "#df4b26",
+           color: color.hex,
            strokeWidth: 0.5,
          },
        ]);
    };
      
    //中略  
      
    useEffect(() => {
        setColor(color);
    }, [color]);
ここまでのコード全体
index.tsx
    "use client";
    
    import { useState, useEffect, useRef } from "react";
    
    import { Stage, Layer, Line } from "react-konva";
    import { ColorPicker, useColor } from "react-color-palette";
    import "react-color-palette/css";
    
    import IconButton from "@mui/material/IconButton";
    
    import { LuPencil, LuEraser, LuPalette } from "react-icons/lu";
    
    const FreeDrawingComponent = () => {
      const [tool, setTool] = useState("pen");
      const [lines, setLines] = useState<any[]>([]);
      const isDrawing = useRef(false);
    
      const handleMouseDown = (e: any) => {
        isDrawing.current = true;
        const position = e.target.getStage().getPointerPosition();
        setLines([
          ...lines,
          {
            tool,
            points: [position.x, position.y],
            color: color.hex,
            strokeWidth: 0.5,
          },
        ]);
      };
    
      const handleMouseMove = (e: any) => {
        if (!isDrawing.current) {
          return;
        }
        const position = e.target.getStage().getPointerPosition();
        let lastLine = lines[lines.length - 1];
        lastLine.points = lastLine.points.concat([position.x, position.y]);
    
        lines.splice(lines.length - 1, 1, lastLine);
        setLines(lines.concat());
      };
    
      const handleMouseUp = () => {
        isDrawing.current = false;
      };
    
      const onClickChangeTool = (tool: string) => {
        setTool(tool);
      };
    
      const [color, setColor] = useColor("#000000");
      const [isDisplayColorPicker, setIsDisplayColorPicker] = useState(false);
      const onClickDisplayColorPicker = () => {
        setIsDisplayColorPicker(!isDisplayColorPicker);
      };
      useEffect(() => {
        setColor(color);
      }, [color]);
    
      return (
        <div className="free-drawing-container">
          <Stage
            width={window.innerWidth}
            height={window.innerHeight}
            onMouseDown={handleMouseDown}
            onMousemove={handleMouseMove}
            onMouseUp={handleMouseUp}
          >
            <Layer>
              {lines.map((line, i) => (
                <Line
                  key={i}
                  points={line.points}
                  stroke={line.color}
                  strokeWidth={line.strokeWidth}
                  tension={0.5}
                  lineCap="round"
                  lineJoin="round"
                  globalCompositeOperation={
                    line.tool === "eraser" ? "destination-out" : "source-over"
                  }
                />
              ))}
            </Layer>
          </Stage>
          <div className="toolbar">
            <IconButton
              onClick={() => onClickChangeTool("pen")}
              color={tool === "pen" ? "primary" : "default"}
            >
              <LuPencil />
            </IconButton>
            <IconButton
              onClick={() => onClickChangeTool("eraser")}
              color={tool === "eraser" ? "primary" : "default"}
            >
              <LuEraser />
            </IconButton>
            <IconButton
              onClick={onClickDisplayColorPicker}
              color={isDisplayColorPicker ? "primary" : "default"}
            >
              <LuPalette />
            </IconButton>
          </div>
          <div style={{ display: isDisplayColorPicker ? "block" : "none" }}>
            <ColorPicker
              hideInput={["rgb", "hsv"]}
              color={color}
              onChange={setColor}
            />
          </div>
        </div>
      );
    };
    
    export default FreeDrawingComponent;
    
    ```

3. その他のツールを実装

ペン・消しゴムの太さ変更機能の実装

今回は1~10の値で変更できるようにします。(お好みで)
ツールボタンを押すと選択肢が表示されて一意の値をセレクトできるようにしたいので、今回はMUIのMenuコンポーネントを利用します。

  1. 必要なもののインポート

    index.tsx
    import Menu from "@mui/material/Menu";
    import MenuItem from "@mui/material/MenuItem";
    
    import LineWeightIcon from "@mui/icons-material/LineWeight";
    
  2. Open / CloseMenuItems関数と、ChangeLineWeight関数の実装

    UIを考慮して、MenuItemは開いた時横並びにしたいので、MenuListPropsのstyleにdisplay: “flex” および flexDirection: “row” を指定しています。

    index.tsx
        const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
        const open = Boolean(anchorEl);
        const handleOpenMenuItems = (event: React.MouseEvent<HTMLButtonElement>) => {
            setAnchorEl(event.currentTarget);
        };
        const handleCloseMenuItems = () => {
            setAnchorEl(null);
        };
      
        const [lineWeight, setLineWeight] = useState(5);
        const handleChangeLineWeight = (weight: number) => {
            handleCloseMenuItems();
            setLineWeight(weight);
        };
      
        //中略
      
        return (
      
            //中略
    		 
            <IconButton
                onClick={onClickDisplayColorPicker}
                color={isDisplayColorPicker ? "primary" : "default"}
            >
              <LuPalette />
            </IconButton>
            <Menu
                id="line-weight-menu"
                anchorEl={anchorEl}
                open={open}
                onClose={handleCloseMenuItems}
                MenuListProps={{
                    "aria-labelledby": "line-weight-button",
                    style: {
                        display: "flex",
                        flexDirection: "row",
                    },
                }}
                className="menu-list"
            >
                {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((weight) => (
                  <MenuItem
                        key={weight}
                        onClick={() => handleChangeLineWeight(weight)}
                  >
                    {weight}
                  </MenuItem>
                ))}
            </Menu>
    
  3. handleMouseDown関数内にlineWeightのstateを挿入

    index.tsx
      const handleMouseDown = (e: any) => {
        isDrawing.current = true;
        const position = e.target.getStage().getPointerPosition();
        setLines([
          ...lines,
          {
            tool,
            points: [position.x, position.y],
            color: color.hex,
    -       strokeWidth: 0.5,
    +       strokeWidth: lineWeight,
          },
        ]);
      };
    

戻す機能の実装

今回はreact-konva公式デモのUndo/Redoは参照していません。Redo機能が欲しいと思ったら書き換えようと思います。

  1. 必要なもののインポート

    index.tsx
    import UndoIcon from "@mui/icons-material/Undo";
    
  2. Undo関数の作成

    index.tsx
        const handleUndo = () => {
            setLines(lines.slice(0, lines.length - 1));
        };
          
        //中略
          
        return (
          
            //中略
        	
            <IconButton onClick={handleUndo}>
              <UndoIcon />
            </IconButton>
    

描画リセット機能の実装

  1. 必要なもののインポート

    index.tsx
    import RefreshIcon from "@mui/icons-material/Refresh";
    
  2. ResetCanvas関数の作成

    index.tsx
        const onClickResetCanvas = () => {
            setLines([]);
        };
          
        //中略
          
        return (
          
            //中略
        	  
            <IconButton onClick={onClickResetCanvas}>
              <RefreshIcon />
            </IconButton>
    

ダウンロード機能の実装

  1. 必要なもののインポート

    index.tsx
    import Konva from "konva";
        
    import DownloadIcon from "@mui/icons-material/Download";
    
  2. ダウンロード対象のcanvasの参照

    index.tsx
        const stageRef = useRef<Konva.Stage>(null);
          
        return (
            <div className="free-drawing-container">
              <Stage
                width={window.innerWidth}
                height={window.innerHeight}
                onMouseDown={handleMouseDown}
                onMousemove={handleMouseMove}
                onMouseUp={handleMouseUp}
    +           ref={stageRef}
              >
    
  3. DownloadCanvas関数の作成

    index.tsx
        const onClickDownloadCanvas = () => {
            if (stageRef.current) {
                const a = document.createElement("a");
                a.href = stageRef.current.toCanvas().toDataURL();
                a.download = "canvas.png";
                a.click();
            }
        };
          
        return (
          
            //中略
        	  
            <IconButton onClick={onClickDownloadCanvas}>
              <DownloadIcon />
            </IconButton>
    

タッチ操作での描画を有効化

現在の実装ではマウスの操作でしか描画できません。タッチ操作しようとすると画面がスライドしてしまいます。そこで、StageコンポーネントのonTouchStartプロパティ、onTouchMoveプロパティ、onTouchEndプロパティに関数を割り当ててタブレット端末やモバイル端末のタッチ操作での描画に対応させようと思います。

  1. TouchStart, TouchMove, TouchEnd関数の作成

    基本的にはそれぞれMouseDown, MouseMove, MouseUp関数と同じですが、タッチ操作での描画時には画面スクロールを機能させたくないため、preventDefaultメソッドを追加します。

    index.tsx
        const handleTouchStart = (e: any) => {
            e.evt.preventDefault();
            const point = e.target.getStage().getPointerPosition();
            setLines([
                ...lines,
                {
                    tool,
                    points: [point.x, point.y],
                    color: color.hex,
                    strokeWidth: lineWeight,
                },
            ]);
            isDrawing.current = true;
        };
        
        const handleTouchMove = (e: any) => {
            e.evt.preventDefault();
            if (!isDrawing.current) {
                return;
            }
            const point = e.target.getStage().getPointerPosition();
            let lastLine = lines[lines.length - 1];
            lastLine.points = lastLine.points.concat([point.x, point.y]);
            lines.splice(lines.length - 1, 1, lastLine);
            setLines([...lines]);
        };
        
        const handleTouchEnd = (e: any) => {
            e.evt.preventDefault();
            isDrawing.current = false;
        };
    
  2. Stageコンポーネント内のプロパティ追加

    index.tsx
        <Stage
            width={window.innerWidth}
            height={window.innerHeight}
            onMouseDown={handleMouseDown}
            onMousemove={handleMouseMove}
            onMouseUp={handleMouseUp}
    +       onTouchStart={handleTouchStart}
    +       onTouchMove={handleTouchMove}
    +       onTouchEnd={handleTouchEnd}
            ref={stageRef}
        >
    

ツールバーのカスタマイズ

  1. DisplayColorPicker関数を追加

    現在の実装では、一度カラーピッカーを開くと他のツールボタンをクリックしたときにも表示されたままになってしまっていて認知負荷が大きいため、他のツールボタンの関数内にsetIsDisplayColorPicker(false)を挿入します。

    index.tsx
        const handleUndo = () => {
            setLines(lines.slice(0, lines.length - 1));
    +       setIsDisplayColorPicker(false);
        };
        
        const onClickChangeTool = (tool: string) => {
            setTool(tool);
    +       setIsDisplayColorPicker(false);
        };
        
        const handleOpenMenuItems = (event: React.MouseEvent<HTMLButtonElement>) => {
            setAnchorEl(event.currentTarget);
    +       setIsDisplayColorPicker(false);
        };
        
        const onClickResetCanvas = () => {
            setLines([]);
    +       setIsDisplayColorPicker(false);
        };
        
        const onClickDownloadCanvas = () => {
            if (stageRef.current) {
                const a = document.createElement("a");
                a.href = stageRef.current.toCanvas().toDataURL();
                a.download = "canvas.png";
                a.click();
            }
    +       setIsDisplayColorPicker(false);
        };
    
  2. ツールボタンにTooltipを追加

    ツールボタンはアイコンだけでも十分分かるようにしているつもりですが、UXを考慮してマウスホバー時にそのボタンがどのような機能なのか分かるようにします。今回はMUIのTooltipを用いて実装していきます。

    index.tsx
    import Tooltip from "@mui/material/Tooltip";
    
    index.tsx
        <Tooltip title="戻す" placement="top">
          <IconButton onClick={handleUndo}>
            <UndoIcon />
          </IconButton>
        </Tooltip>
        <Tooltip title="ペン" placement="top">
          <IconButton
              onClick={() => onClickChangeTool("pen")}
              color={tool === "pen" ? "primary" : "default"}
          >
            <LuPencil />
          </IconButton>
        </Tooltip>
        <Tooltip title="消しゴム" placement="top">
          <IconButton
              onClick={() => onClickChangeTool("eraser")}
              color={tool === "eraser" ? "primary" : "default"}
          >
            <LuEraser />
          </IconButton>
        </Tooltip>
        <Tooltip title="ペン / 消しゴムの太さ" placement="top">
          <IconButton
              id="line-weight-button"
              aria-controls={open ? "line-weight-menu" : undefined}
              aria-haspopup="true"
              aria-expanded={open ? "true" : undefined}
              onClick={handleOpenMenuItems}
              color={anchorEl ? "primary" : "default"}
          >
            <LineWeightIcon />
          </IconButton>
        </Tooltip>
        <Menu
            id="line-weight-menu"
            anchorEl={anchorEl}
            open={open}
            onClose={handleCloseMenuItems}
            MenuListProps={{
                "aria-labelledby": "line-weight-button",
                style: {
                    display: "flex",
                    flexDirection: "row",
                },
            }}
            className="menu-list"
        >
            {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((weight) => (
                <MenuItem
                    key={weight}
                    onClick={() => handleChangeLineWeight(weight)}
                >
                    {weight}
                </MenuItem>
            ))}
        </Menu>
        <Tooltip title="ペンの色" placement="top">
          <IconButton
              onClick={onClickDisplayColorPicker}
              color={isDisplayColorPicker ? "primary" : "default"}
          >
            <LuPalette />
          </IconButton>
        </Tooltip>
        <Tooltip title="描画のリセット" placement="top">
          <IconButton onClick={onClickResetCanvas}>
            <RefreshIcon />
          </IconButton>
        </Tooltip>
        <Tooltip title="ダウンロード" placement="top">
          <IconButton onClick={onClickDownloadCanvas}>
            <DownloadIcon />
          </IconButton>
        </Tooltip>
    

現在のツールの状態を表示

現在選択しているツールがペンなのか消しゴムなのか、太さは今いくつなのか、ペンの色は何なのか、といった情報を表示していきます。

index.tsx
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
index.tsx
    <div className="tool-info">
      <Typography>
        現在のツール:{tool === "pen" ? "ペン" : "消しゴム"}
      </Typography>
      <Typography>ツールの太さ:{lineWeight}</Typography>
      {tool === "pen" && (
        <Typography style={{ display: "flex", alignItems: "center" }}>
          ペンの色:
          <Box
              sx={{
                  width: "14px",
                  height: "14px",
                  backgroundColor: color.hex,
                  border: "1px solid black",
                  marginRight: "4px",
              }}
            />
          {color.hex}
        </Typography>
      )}
    </div>
ここまでのコード全体
index.tsx
    "use client";
    
    import { useState, useEffect, useRef } from "react";
    
    import Konva from "konva";
    import { Stage, Layer, Line } from "react-konva";
    import { ColorPicker, useColor } from "react-color-palette";
    import "react-color-palette/css";
    
    import Box from "@mui/material/Box";
    import IconButton from "@mui/material/IconButton";
    import Menu from "@mui/material/Menu";
    import MenuItem from "@mui/material/MenuItem";
    import Tooltip from "@mui/material/Tooltip";
    import Typography from "@mui/material/Typography";
    
    import DownloadIcon from "@mui/icons-material/Download";
    import LineWeightIcon from "@mui/icons-material/LineWeight";
    import RefreshIcon from "@mui/icons-material/Refresh";
    import UndoIcon from "@mui/icons-material/Undo";
    import { LuPencil, LuEraser, LuPalette } from "react-icons/lu";
    
    const FreeDrawingComponent = () => {
      const [tool, setTool] = useState("pen");
      const [lines, setLines] = useState<any[]>([]);
      const isDrawing = useRef(false);
    
      const handleMouseDown = (e: any) => {
        isDrawing.current = true;
        const position = e.target.getStage().getPointerPosition();
        setLines([
          ...lines,
          {
            tool,
            points: [position.x, position.y],
            color: color.hex,
            strokeWidth: lineWeight,
          },
        ]);
      };
    
      const handleMouseMove = (e: any) => {
        if (!isDrawing.current) {
          return;
        }
        const position = e.target.getStage().getPointerPosition();
        let lastLine = lines[lines.length - 1];
        lastLine.points = lastLine.points.concat([position.x, position.y]);
    
        lines.splice(lines.length - 1, 1, lastLine);
        setLines(lines.concat());
      };
    
      const handleMouseUp = () => {
        isDrawing.current = false;
      };
    
      const handleTouchStart = (e: any) => {
        e.evt.preventDefault();
        const point = e.target.getStage().getPointerPosition();
        setLines([
          ...lines,
          {
            tool,
            points: [point.x, point.y],
            color: color.hex,
            strokeWidth: lineWeight,
          },
        ]);
        isDrawing.current = true;
      };
    
      const handleTouchMove = (e: any) => {
        e.evt.preventDefault();
        if (!isDrawing.current) {
          return;
        }
        const point = e.target.getStage().getPointerPosition();
        let lastLine = lines[lines.length - 1];
        lastLine.points = lastLine.points.concat([point.x, point.y]);
        lines.splice(lines.length - 1, 1, lastLine);
        setLines([...lines]);
      };
    
      const handleTouchEnd = (e: any) => {
        e.evt.preventDefault();
        isDrawing.current = false;
      };
    
      const handleUndo = () => {
        setLines(lines.slice(0, lines.length - 1));
        setIsDisplayColorPicker(false);
      };
    
      const onClickChangeTool = (tool: string) => {
        setTool(tool);
        setIsDisplayColorPicker(false);
      };
    
      const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
      const open = Boolean(anchorEl);
      const handleOpenMenuItems = (event: React.MouseEvent<HTMLButtonElement>) => {
        setAnchorEl(event.currentTarget);
        setIsDisplayColorPicker(false);
      };
      const handleCloseMenuItems = () => {
        setAnchorEl(null);
      };
    
      const [lineWeight, setLineWeight] = useState(5);
      const handleChangeLineWeight = (weight: number) => {
        handleCloseMenuItems();
        setLineWeight(weight);
      };
    
      const [color, setColor] = useColor("#000000");
      const [isDisplayColorPicker, setIsDisplayColorPicker] = useState(false);
      const onClickDisplayColorPicker = () => {
        setIsDisplayColorPicker(!isDisplayColorPicker);
      };
      useEffect(() => {
        setColor(color);
      }, [color]);
    
      const onClickResetCanvas = () => {
        setLines([]);
        setIsDisplayColorPicker(false);
      };
    
      const stageRef = useRef<Konva.Stage>(null);
      const onClickDownloadCanvas = () => {
        if (stageRef.current) {
          const a = document.createElement("a");
          a.href = stageRef.current.toCanvas().toDataURL();
          a.download = "canvas.png";
          a.click();
        }
        setIsDisplayColorPicker(false);
      };
    
      return (
        <div className="free-drawing-container">
          <Stage
            width={window.innerWidth}
            height={window.innerHeight}
            onMouseDown={handleMouseDown}
            onMousemove={handleMouseMove}
            onMouseUp={handleMouseUp}
            onTouchStart={handleTouchStart}
            onTouchMove={handleTouchMove}
            onTouchEnd={handleTouchEnd}
            ref={stageRef}
          >
            <Layer>
              {lines.map((line, i) => (
                <Line
                  key={i}
                  points={line.points}
                  stroke={line.color}
                  strokeWidth={line.strokeWidth}
                  tension={0.5}
                  lineCap="round"
                  lineJoin="round"
                  globalCompositeOperation={
                    line.tool === "eraser" ? "destination-out" : "source-over"
                  }
                />
              ))}
            </Layer>
          </Stage>
          <div className="toolbar">
            <Tooltip title="戻す" placement="top">
              <IconButton onClick={handleUndo}>
                <UndoIcon />
              </IconButton>
            </Tooltip>
            <Tooltip title="ペン" placement="top">
              <IconButton
                onClick={() => onClickChangeTool("pen")}
                color={tool === "pen" ? "primary" : "default"}
              >
                <LuPencil />
              </IconButton>
            </Tooltip>
            <Tooltip title="消しゴム" placement="top">
              <IconButton
                onClick={() => onClickChangeTool("eraser")}
                color={tool === "eraser" ? "primary" : "default"}
              >
                <LuEraser />
              </IconButton>
            </Tooltip>
            <Tooltip title="ペン / 消しゴムの太さ" placement="top">
              <IconButton
                id="line-weight-button"
                aria-controls={open ? "line-weight-menu" : undefined}
                aria-haspopup="true"
                aria-expanded={open ? "true" : undefined}
                onClick={handleOpenMenuItems}
                color={anchorEl ? "primary" : "default"}
              >
                <LineWeightIcon />
              </IconButton>
            </Tooltip>
            <Menu
              id="line-weight-menu"
              anchorEl={anchorEl}
              open={open}
              onClose={handleCloseMenuItems}
              MenuListProps={{
                "aria-labelledby": "line-weight-button",
                style: {
                  display: "flex",
                  flexDirection: "row",
                },
              }}
              className="menu-list"
            >
              {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((weight) => (
                <MenuItem
                  key={weight}
                  onClick={() => handleChangeLineWeight(weight)}
                >
                  {weight}
                </MenuItem>
              ))}
            </Menu>
            <Tooltip title="ペンの色" placement="top">
              <IconButton
                onClick={onClickDisplayColorPicker}
                color={isDisplayColorPicker ? "primary" : "default"}
              >
                <LuPalette />
              </IconButton>
            </Tooltip>
            <Tooltip title="描画のリセット" placement="top">
              <IconButton onClick={onClickResetCanvas}>
                <RefreshIcon />
              </IconButton>
            </Tooltip>
            <Tooltip title="ダウンロード" placement="top">
              <IconButton onClick={onClickDownloadCanvas}>
                <DownloadIcon />
              </IconButton>
            </Tooltip>
          </div>
          <div className="tool-info">
            <Typography>
              現在のツール:{tool === "pen" ? "ペン" : "消しゴム"}
            </Typography>
            <Typography>ツールの太さ:{lineWeight}</Typography>
            {tool === "pen" && (
              <Typography style={{ display: "flex", alignItems: "center" }}>
                ペンの色:
                <Box
                  sx={{
                    width: "14px",
                    height: "14px",
                    backgroundColor: color.hex,
                    border: "1px solid black",
                    marginRight: "4px",
                  }}
                />
                {color.hex}
              </Typography>
            )}
          </div>
          <div style={{ display: isDisplayColorPicker ? "block" : "none" }}>
            <ColorPicker
              hideInput={["rgb", "hsv"]}
              color={color}
              onChange={setColor}
            />
          </div>
        </div>
      );
    };
    
    export default FreeDrawingComponent;
    ```

4. スタイルの修正

canvasのサイズ

現在canvasのサイズはwindow.innerWidthおよびwindow.innerHeightを指定していますが、その他のツールを入れたのでシングルページで全ての情報を表示できません。そこで、任意の倍数(お好みで)をかけて、全ての情報が表示できるようにします。

index.tsx
    <Stage
        style={{ border: "1px solid black", borderRadius: "32px" }}
-       width={window.innerWidth}
-       height={window.innerHeight}
+       width={window.innerWidth * 0.6}
+       height={window.innerHeight * 0.6}
        onMouseDown={handleMouseDown}
        onMousemove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onTouchStart={handleTouchStart}
        onTouchMove={handleTouchMove}
        onTouchEnd={handleTouchEnd}
        ref={stageRef}
    >

全体的なスタイリング

ここは本当にお好みで。
style.cssに以下を記述します。rcp-rootはreact-color-paletteのclassNameとなっています。

style.css
.free-drawing-container {
  display: flex;
  flex-direction: column;
  align-items: space-between;
  justify-content: center;
  gap: 32px;
  margin-top: 32px;
}

.toolbar {
  width: min-content;
  display: flex;
  flex-direction: row;
  gap: 16px;
  margin-inline: auto;
  padding-inline: 32px;
  border: 1px solid black;
  border-radius: 32px;
}

.menu-list {
  margin-top: 16px;
}

.tool-info {
  display: flex;
  flex-direction: row;
  justify-content: center;
  gap: 32px;
  margin-inline: auto;
}

.rcp-root {
  max-width: 1440px;
  margin-inline: auto;
}

ここまでのコード全体
index.tsx
    "use client";
    
    import { useState, useEffect, useRef } from "react";
    
    import Konva from "konva";
    import { Stage, Layer, Line } from "react-konva";
    import { ColorPicker, useColor } from "react-color-palette";
    import "react-color-palette/css";
    
    import Box from "@mui/material/Box";
    import IconButton from "@mui/material/IconButton";
    import Menu from "@mui/material/Menu";
    import MenuItem from "@mui/material/MenuItem";
    import Tooltip from "@mui/material/Tooltip";
    import Typography from "@mui/material/Typography";
    
    import DownloadIcon from "@mui/icons-material/Download";
    import LineWeightIcon from "@mui/icons-material/LineWeight";
    import RefreshIcon from "@mui/icons-material/Refresh";
    import UndoIcon from "@mui/icons-material/Undo";
    import { LuPencil, LuEraser, LuPalette } from "react-icons/lu";
    
    import "./style.css";
    
    const FreeDrawingComponent = () => {
      const [tool, setTool] = useState("pen");
      const [lines, setLines] = useState<any[]>([]);
      const isDrawing = useRef(false);
    
      const handleMouseDown = (e: any) => {
        isDrawing.current = true;
        const position = e.target.getStage().getPointerPosition();
        setLines([
          ...lines,
          {
            tool,
            points: [position.x, position.y],
            color: color.hex,
            strokeWidth: lineWeight,
          },
        ]);
      };
    
      const handleMouseMove = (e: any) => {
        if (!isDrawing.current) {
          return;
        }
        const position = e.target.getStage().getPointerPosition();
        let lastLine = lines[lines.length - 1];
        lastLine.points = lastLine.points.concat([position.x, position.y]);
    
        lines.splice(lines.length - 1, 1, lastLine);
        setLines([...lines]);
      };
    
      const handleMouseUp = () => {
        isDrawing.current = false;
      };
    
      const handleTouchStart = (e: any) => {
        e.evt.preventDefault();
        const point = e.target.getStage().getPointerPosition();
        setLines([
          ...lines,
          {
            tool,
            points: [point.x, point.y],
            color: color.hex,
            strokeWidth: lineWeight,
          },
        ]);
        isDrawing.current = true;
      };
    
      const handleTouchMove = (e: any) => {
        e.evt.preventDefault();
        if (!isDrawing.current) {
          return;
        }
        const point = e.target.getStage().getPointerPosition();
        let lastLine = lines[lines.length - 1];
        lastLine.points = lastLine.points.concat([point.x, point.y]);
        lines.splice(lines.length - 1, 1, lastLine);
        setLines([...lines]);
      };
    
      const handleTouchEnd = (e: any) => {
        e.evt.preventDefault();
        isDrawing.current = false;
      };
    
      const handleUndo = () => {
        setLines(lines.slice(0, lines.length - 1));
        setIsDisplayColorPicker(false);
      };
    
      const onClickChangeTool = (tool: string) => {
        setTool(tool);
        setIsDisplayColorPicker(false);
      };
    
      const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
      const open = Boolean(anchorEl);
      const handleOpenMenuItems = (event: React.MouseEvent<HTMLButtonElement>) => {
        setAnchorEl(event.currentTarget);
        setIsDisplayColorPicker(false);
      };
      const handleCloseMenuItems = () => {
        setAnchorEl(null);
      };
    
      const [lineWeight, setLineWeight] = useState(5);
      const handleChangeLineWeight = (weight: number) => {
        handleCloseMenuItems();
        setLineWeight(weight);
      };
    
      const [color, setColor] = useColor("#000000");
      const [isDisplayColorPicker, setIsDisplayColorPicker] = useState(false);
      const onClickDisplayColorPicker = () => {
        setIsDisplayColorPicker(!isDisplayColorPicker);
      };
      useEffect(() => {
        setColor(color);
      }, [color]);
    
      const onClickResetCanvas = () => {
        setLines([]);
        setIsDisplayColorPicker(false);
      };
    
      const stageRef = useRef<Konva.Stage>(null);
      const onClickDownloadCanvas = () => {
        if (stageRef.current) {
          const a = document.createElement("a");
          a.href = stageRef.current.toCanvas().toDataURL();
          a.download = "canvas.png";
          a.click();
        }
        setIsDisplayColorPicker(false);
      };
    
      return (
        <div className="free-drawing-container">
          <Stage
            style={{ border: "1px solid black", borderRadius: "32px" }}
            width={window.innerWidth * 0.6}
            height={window.innerHeight * 0.6}
            onMouseDown={handleMouseDown}
            onMousemove={handleMouseMove}
            onMouseUp={handleMouseUp}
            onTouchStart={handleTouchStart}
            onTouchMove={handleTouchMove}
            onTouchEnd={handleTouchEnd}
            ref={stageRef}
          >
            <Layer>
              {lines.map((line, i) => (
                <Line
                  key={i}
                  points={line.points}
                  stroke={line.color}
                  strokeWidth={line.strokeWidth}
                  tension={0.5}
                  lineCap="round"
                  lineJoin="round"
                  globalCompositeOperation={
                    line.tool === "eraser" ? "destination-out" : "source-over"
                  }
                />
              ))}
            </Layer>
          </Stage>
          <div className="toolbar">
            <Tooltip title="戻す" placement="top">
              <IconButton onClick={handleUndo}>
                <UndoIcon />
              </IconButton>
            </Tooltip>
            <Tooltip title="ペン" placement="top">
              <IconButton
                onClick={() => onClickChangeTool("pen")}
                color={tool === "pen" ? "primary" : "default"}
              >
                <LuPencil />
              </IconButton>
            </Tooltip>
            <Tooltip title="消しゴム" placement="top">
              <IconButton
                onClick={() => onClickChangeTool("eraser")}
                color={tool === "eraser" ? "primary" : "default"}
              >
                <LuEraser />
              </IconButton>
            </Tooltip>
            <Tooltip title="ペン / 消しゴムの太さ" placement="top">
              <IconButton
                id="line-weight-button"
                aria-controls={open ? "line-weight-menu" : undefined}
                aria-haspopup="true"
                aria-expanded={open ? "true" : undefined}
                onClick={handleOpenMenuItems}
                color={anchorEl ? "primary" : "default"}
              >
                <LineWeightIcon />
              </IconButton>
            </Tooltip>
            <Menu
              id="line-weight-menu"
              anchorEl={anchorEl}
              open={open}
              onClose={handleCloseMenuItems}
              MenuListProps={{
                "aria-labelledby": "line-weight-button",
                style: {
                  display: "flex",
                  flexDirection: "row",
                },
              }}
              className="menu-list"
            >
              {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((weight) => (
                <MenuItem
                  key={weight}
                  onClick={() => handleChangeLineWeight(weight)}
                >
                  {weight}
                </MenuItem>
              ))}
            </Menu>
            <Tooltip title="ペンの色" placement="top">
              <IconButton
                onClick={onClickDisplayColorPicker}
                color={isDisplayColorPicker ? "primary" : "default"}
              >
                <LuPalette />
              </IconButton>
            </Tooltip>
            <Tooltip title="描画のリセット" placement="top">
              <IconButton onClick={onClickResetCanvas}>
                <RefreshIcon />
              </IconButton>
            </Tooltip>
            <Tooltip title="ダウンロード" placement="top">
              <IconButton onClick={onClickDownloadCanvas}>
                <DownloadIcon />
              </IconButton>
            </Tooltip>
          </div>
          <div className="tool-info">
            <Typography>
              現在のツール:{tool === "pen" ? "ペン" : "消しゴム"}
            </Typography>
            <Typography>ツールの太さ:{lineWeight}</Typography>
            {tool === "pen" && (
              <Typography style={{ display: "flex", alignItems: "center" }}>
                ペンの色:
                <Box
                  sx={{
                    width: "14px",
                    height: "14px",
                    backgroundColor: color.hex,
                    border: "1px solid black",
                    marginRight: "4px",
                  }}
                />
                {color.hex}
              </Typography>
            )}
          </div>
          <div style={{ display: isDisplayColorPicker ? "block" : "none" }}>
            <ColorPicker
              hideInput={["rgb", "hsv"]}
              color={color}
              onChange={setColor}
            />
          </div>
        </div>
      );
    };
    
    export default FreeDrawingComponent;
    
    ```

5. appフォルダ直下のファイル

app / page.tsx
    import FreeDrawingComponent from "@/component/FreeDrawingComponent";
    
    export default function Home() {
      return (
        <div>
          <FreeDrawingComponent />
        </div>
      );
    }
    
app / global.css
    html,
    body {
      width: 100%;
      height: 100%;
      max-width: 1440px;
      margin-inline: auto;
      padding: 0;
      display: flex;
      flex-direction: column;
      align-items: center;
      font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
        Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
      line-height: 1.6;
      font-size: 16px;
    }
    
    * {
      box-sizing: border-box;
    }
    
    a {
      color: #0070f3;
      text-decoration: none;
    }
    
    a:hover {
      text-decoration: underline;
    }
    
    img {
      object-fit: cover;
    }
    

↓こちらはいじっていません。

app /global.tsx
    import type { Metadata } from "next";
    import { Inter } from "next/font/google";
    import "./globals.css";
    
    const inter = Inter({ subsets: ["latin"] });
    
    export const metadata: Metadata = {
      title: "Create Next App",
      description: "Generated by create next app",
    };
    
    export default function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode;
    }>) {
      return (
        <html lang="en">
          <body className={inter.className}>{children}</body>
        </html>
      );
    }
    

おわりに

今回はcanvasの使い方の一例としてお絵描きアプリの実装に挑戦しましたが、react-konvaもreact-color-paletteもあまり情報が出回っていなかったため若干苦労しました。しかしながらお絵描きアプリという、普段あまり使わないような新規機能を実装していくのは非常に面白かったです。完成後も、タッチ操作を有効化したことでiPadでApple Pencilの操作にも対応していますし、かなりしっかりお絵描きできるので休憩中のちょっとした息抜きにもなっています。(普通にお絵描きアプリ使え)(でもエンジニアとして自分の作ったもので遊べるのはこの上ない喜びを感じます)

もしこの記事が今後canvasを実装される方の一助になれば幸いです。お読みいただきありがとうございました。

株式会社コズム

Discussion