Next.js(App router) + react-konva + react-color-paletteでお絵描きアプリを実装
はじめに
こんにちは、株式会社コズムの穴澤です。
以前「node-canvasのビルド時に遭遇したエラー」というタイトルで記事を投稿したのですが、せっかくなのでnode-canvasを使用してどのように実装できるのかというものをお見せしようと思い、今回こちらの記事を執筆させていただいております。
実装したもの
さて、何を実装したのかというとズバリ、「お絵描きアプリ」です。実際には以下のようの動作ができます。
他にも、手順を戻したり、描画をリセットしたり・・・
もちろん消しゴムや太さの変更、HEX値での色の変更が行えます。
あとは描画したcanvasをpngでダウンロードできる機能(一番右のボタン)も実装しています。
使用技術
今回はNext.js(App router)を使用しています。UI componentライブラリはMUIを使用しました。また、アイコンの一部にreact-iconsも使用しています。
- next v14.2.4
- react v18.3.1
- react-konva v18.2.10
- konva v9.3.11
- react-color-palette v7.1.1
- @mui/material v5.15.20, @mui/icons-material v5.15.20
- react-icons v5.2.1
ディレクトリ構成(概略)
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 />);
```
ここに若干手を加えていきます。
"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. 必要なもののインポート
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)にしておきます。
const [color, setColor] = useColor("#000000");
3. ColorPickerの実装
この時、ツールボタンを押したときだけ表示できるようにしたいので出し分けをします。
また、入力をHEX値だけに限定したいので hideInputのpropsに”rgb”および”hsv”を指定します。
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がセットされるようにしています。
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]);
ここまでのコード全体
"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コンポーネントを利用します。
-
必要なもののインポート
index.tsximport Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import LineWeightIcon from "@mui/icons-material/LineWeight";
-
Open / CloseMenuItems関数と、ChangeLineWeight関数の実装
UIを考慮して、MenuItemは開いた時横並びにしたいので、MenuListPropsのstyleにdisplay: “flex” および flexDirection: “row” を指定しています。
index.tsxconst [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>
-
handleMouseDown関数内にlineWeightのstateを挿入
index.tsxconst 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機能が欲しいと思ったら書き換えようと思います。
-
必要なもののインポート
index.tsximport UndoIcon from "@mui/icons-material/Undo";
-
Undo関数の作成
index.tsxconst handleUndo = () => { setLines(lines.slice(0, lines.length - 1)); }; //中略 return ( //中略 <IconButton onClick={handleUndo}> <UndoIcon /> </IconButton>
描画リセット機能の実装
-
必要なもののインポート
index.tsximport RefreshIcon from "@mui/icons-material/Refresh";
-
ResetCanvas関数の作成
index.tsxconst onClickResetCanvas = () => { setLines([]); }; //中略 return ( //中略 <IconButton onClick={onClickResetCanvas}> <RefreshIcon /> </IconButton>
ダウンロード機能の実装
-
必要なもののインポート
index.tsximport Konva from "konva"; import DownloadIcon from "@mui/icons-material/Download";
-
ダウンロード対象のcanvasの参照
index.tsxconst 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} >
-
DownloadCanvas関数の作成
index.tsxconst 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プロパティに関数を割り当ててタブレット端末やモバイル端末のタッチ操作での描画に対応させようと思います。
-
TouchStart, TouchMove, TouchEnd関数の作成
基本的にはそれぞれMouseDown, MouseMove, MouseUp関数と同じですが、タッチ操作での描画時には画面スクロールを機能させたくないため、preventDefaultメソッドを追加します。
index.tsxconst 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; };
-
Stageコンポーネント内のプロパティ追加
index.tsx<Stage width={window.innerWidth} height={window.innerHeight} onMouseDown={handleMouseDown} onMousemove={handleMouseMove} onMouseUp={handleMouseUp} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} ref={stageRef} >
ツールバーのカスタマイズ
-
DisplayColorPicker関数を追加
現在の実装では、一度カラーピッカーを開くと他のツールボタンをクリックしたときにも表示されたままになってしまっていて認知負荷が大きいため、他のツールボタンの関数内にsetIsDisplayColorPicker(false)を挿入します。
index.tsxconst 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); };
-
ツールボタンにTooltipを追加
ツールボタンはアイコンだけでも十分分かるようにしているつもりですが、UXを考慮してマウスホバー時にそのボタンがどのような機能なのか分かるようにします。今回はMUIのTooltipを用いて実装していきます。
index.tsximport 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>
現在のツールの状態を表示
現在選択しているツールがペンなのか消しゴムなのか、太さは今いくつなのか、ペンの色は何なのか、といった情報を表示していきます。
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
<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>
ここまでのコード全体
"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を指定していますが、その他のツールを入れたのでシングルページで全ての情報を表示できません。そこで、任意の倍数(お好みで)をかけて、全ての情報が表示できるようにします。
<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となっています。
.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;
}
ここまでのコード全体
"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フォルダ直下のファイル
import FreeDrawingComponent from "@/component/FreeDrawingComponent";
export default function Home() {
return (
<div>
<FreeDrawingComponent />
</div>
);
}
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;
}
↓こちらはいじっていません。
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