diff --git a/apps/client/app/features/artboard/bucketFill.ts b/apps/client/app/features/artboard/bucketFill.ts new file mode 100644 index 0000000..ec9019c --- /dev/null +++ b/apps/client/app/features/artboard/bucketFill.ts @@ -0,0 +1,167 @@ +// ref: https://github.com/cat-crosswalk/nascalay-frontend/blob/main/src/components/Canvas/bucketFill.ts + +import type { Color, LikeEqualColor } from "./types"; +import { colorsToRaw, equalColor, hexToColor, rawToColors } from "./utils"; + +// https://nullpon.moe/dev/sample/canvas/bucketfill.html めちゃめちゃ参考にしてる +// シードフィルアルゴリズムで塗りつぶす + +/** + * 指定された座標から右方向にまっすぐ targetColor と等しい色を塗りつぶす + * + * @returns 右端の x 座標 + */ +function drawToRight( + data: Color[][], + x: number, + y: number, + color: Color, + widthRange: readonly [number, number], + targetColor: Color, + likeEqualColor: LikeEqualColor, +) { + let rightEnd = null; + for (let nowX = x + 1; nowX < widthRange[1]; nowX++) { + const nowColor = data[y][nowX]; + if (!likeEqualColor(nowColor, targetColor)) break; + data[y][nowX] = color; + rightEnd = nowX; + } + return rightEnd; +} + +/** + * 指定された座標から左方向にまっすぐ targetColor と等しい色を塗りつぶす + * + * @returns 左端の x 座標 + */ +function drawToLeft( + data: Color[][], + x: number, + y: number, + color: Color, + widthRange: readonly [number, number], + targetColor: Color, + likeEqualColor: LikeEqualColor, +) { + let leftEnd = null; + for (let nowX = x; nowX >= widthRange[0]; nowX--) { + const nowColor = data[y][nowX]; + if (!likeEqualColor(nowColor, targetColor)) break; + data[y][nowX] = color; + leftEnd = nowX; + } + return leftEnd; +} + +/** + * seeds を破壊的に更新する + */ +function updateSeeds( + data: Readonly, + xLeft: number, + xRight: number, + y: number, + seeds: { x: number; y: number }[], + targetColor: Color, + heightRange: readonly [number, number], + likeEqualColor: LikeEqualColor, +) { + if (y < heightRange[0] || y >= heightRange[1]) return; + + let prevIsTarget = false; + for (let nowX = xLeft; nowX <= xRight; nowX++) { + const nowColor = data[y][nowX]; + if (likeEqualColor(nowColor, targetColor)) { + if (!prevIsTarget) { + seeds.push({ x: nowX, y }); + } + prevIsTarget = true; + } else { + prevIsTarget = false; + } + } +} + +export function bucketFill( + canvas: HTMLCanvasElement, + x: number, + y: number, + colorCode: `#${string}`, + widthRange?: [number, number], + heightRange?: [number, number], + likeEqualColor: LikeEqualColor = equalColor, +) { + const ctx = canvas.getContext("2d"); + if (ctx === null) { + return; + } + + const width = canvas.width; + const height = canvas.height; + const imageData = ctx.getImageData(0, 0, width, height); + const data = imageData.data; + const formattedData = rawToColors(data, width, height); + const targetColor = formattedData[y][x]; + const color = hexToColor(colorCode); + if (color === null) { + console.error("invalid color"); + return; + } + if (likeEqualColor(color, targetColor)) return; + + const xRange = widthRange ?? [0, width]; + const yRange = heightRange ?? [0, height]; + + const seeds = [{ x, y }]; + while (seeds.length > 0) { + // biome-ignore lint/style/noNonNullAssertion: while の条件式から pop は undefined にならない + const { x, y } = seeds.pop()!; + + // 左右に塗りつぶす + const leftX = + drawToLeft( + formattedData, + x, + y, + color, + xRange, + targetColor, + likeEqualColor, + ) ?? x; + const rightX = + drawToRight( + formattedData, + x, + y, + color, + xRange, + targetColor, + likeEqualColor, + ) ?? x; + + updateSeeds( + formattedData, + leftX, + rightX, + y + 1, + seeds, + targetColor, + yRange, + likeEqualColor, + ); + updateSeeds( + formattedData, + leftX, + rightX, + y - 1, + seeds, + targetColor, + yRange, + likeEqualColor, + ); + } + + imageData.data.set(colorsToRaw(formattedData, width, height)); + ctx.putImageData(imageData, 0, 0); +} diff --git a/apps/client/app/features/artboard/index.tsx b/apps/client/app/features/artboard/index.tsx new file mode 100644 index 0000000..f996c8a --- /dev/null +++ b/apps/client/app/features/artboard/index.tsx @@ -0,0 +1,30 @@ +import { boolAttr } from "@/utils/boolAttr"; +import styled from "@emotion/styled"; +import type { RefObject } from "react"; +import type { MouseHandlers } from "./types"; + +interface Props { + canvasRef: RefObject; + mouseHandlers: MouseHandlers; + isLocked?: boolean; +} + +export function Artboard({ canvasRef, mouseHandlers, isLocked }: Props) { + return ( + + ); +} + +const Canvas = styled.canvas` + border: 1px solid black; + + &[data-is-locked] { + pointer-events: none; + } +`; diff --git a/apps/client/app/features/artboard/types.ts b/apps/client/app/features/artboard/types.ts new file mode 100644 index 0000000..c83eacb --- /dev/null +++ b/apps/client/app/features/artboard/types.ts @@ -0,0 +1,30 @@ +export const penTypes = ["pen", "eraser", "bucket"] as const; +export type PenType = (typeof penTypes)[number]; + +export interface Pos { + x: number; + y: number; +} + +export type MouseHandlers = Required< + Pick< + React.DOMAttributes, + | "onMouseDown" + | "onMouseMove" + | "onMouseUp" + | "onMouseLeave" + | "onMouseEnter" + > +>; + +/** + * 色を扱いやすくするための型 + * r, g, b, a はそれぞれ 0 から 255 の範囲の整数 + */ +export interface Color { + r: number; + g: number; + b: number; + a: number; +} +export type LikeEqualColor = (a: Color, b: Color) => boolean; diff --git a/apps/client/app/features/artboard/useArtboard.ts b/apps/client/app/features/artboard/useArtboard.ts new file mode 100644 index 0000000..73c658e --- /dev/null +++ b/apps/client/app/features/artboard/useArtboard.ts @@ -0,0 +1,135 @@ +import type React from "react"; +import { type RefObject, useCallback, useRef } from "react"; +import type { MouseHandlers, Pos } from "./types"; +import { useHistory } from "./useHistory"; +import { type PenData, useSketch } from "./useSketch"; +import { type CanvasData, applyCanvasData, getCanvasData } from "./utils"; + +interface Handler { + clear(): void; + undo(): void; + redo(): void; + resetCanvas(): void; + shortcut(e: React.KeyboardEvent): void; + exportDataURL(): string; + canRedo: boolean; + canUndo: boolean; + mouseHandlers: MouseHandlers; +} + +interface State { + lastPos: Pos | null; +} +const createInitialState = (): State => ({ + lastPos: null, +}); + +export function useArtboard( + canvasRef: RefObject, + penData: PenData, +): Handler { + // NOTE: initial state が重くなる場合は https://ja.react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents のようにする + const state = useRef(createInitialState()); + const { + canRedo, + canUndo, + clearHistory, + pushHistory, + undoHistory, + redoHistory, + } = useHistory(); + const { mouseHandlers } = useSketch(canvasRef, penData, pushHistory); + + const resetCanvas = useCallback(() => { + const canvas = canvasRef.current; + if (canvas === null) return; + const ctx = canvas.getContext("2d"); + if (ctx === null) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + state.current = createInitialState(); + + clearHistory(); + }, [canvasRef.current, clearHistory]); + + const saveCanvas = useCallback(() => { + const canvas = canvasRef.current; + if (canvas === null) return; + + const savedData = getCanvasData(canvas); + if (savedData === null) return; + + pushHistory(savedData); + }, [canvasRef.current, pushHistory]); + + const undo = useCallback(() => { + const canvas = canvasRef.current; + if (canvas === null) return; + + const now = getCanvasData(canvas); + if (now === null) return; + + const next = undoHistory(now); + if (next === null) return; + + applyCanvasData(canvas, next); + }, [canvasRef.current, undoHistory]); + + const redo = useCallback(() => { + const canvas = canvasRef.current; + if (canvas === null) return; + + const now = getCanvasData(canvas); + if (now === null) return; + + const next = redoHistory(now); + if (next === undefined) return; + + applyCanvasData(canvas, next); + }, [canvasRef.current, redoHistory]); + + const clear = useCallback(() => { + const canvas = canvasRef.current; + if (canvas === null) return; + + saveCanvas(); + + const ctx = canvas.getContext("2d"); + if (ctx === null) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + }, [canvasRef.current, saveCanvas]); + + const shortcut = useCallback( + (e: React.KeyboardEvent) => { + // NOTE: shift を押した状態だと e.key が大文字になるので小文字に変換してから比較する + if (e.key.toLocaleLowerCase() === "z" && (e.ctrlKey || e.metaKey)) { + if (e.shiftKey) { + redo(); + } else { + undo(); + } + } + }, + [redo, undo], + ); + + const exportDataURL = useCallback(() => { + const canvas = canvasRef.current; + if (canvas === null) return ""; + + return canvas.toDataURL(); + }, [canvasRef.current]); + + return { + clear, + undo, + redo, + resetCanvas, + shortcut, + exportDataURL, + canRedo, + canUndo, + mouseHandlers, + }; +} diff --git a/apps/client/app/features/artboard/useHistory.ts b/apps/client/app/features/artboard/useHistory.ts new file mode 100644 index 0000000..b670c04 --- /dev/null +++ b/apps/client/app/features/artboard/useHistory.ts @@ -0,0 +1,67 @@ +import { useCallback, useRef, useState } from "react"; + +interface History { + redoList: Entry[]; + undoList: Entry[]; +} +const createInitialHistory = (): History => ({ + redoList: [], + undoList: [], +}); + +export function useHistory() { + const history = useRef>(createInitialHistory()); + + const [canRedo, setCanRedo] = useState(false); + const [canUndo, setCanUndo] = useState(false); + + const clearHistory = useCallback(() => { + history.current = createInitialHistory(); + setCanRedo(false); + setCanUndo(false); + }, []); + + const pushHistory = useCallback((data: Entry) => { + history.current.undoList.push(data); + history.current.redoList = []; + setCanUndo(true); + setCanRedo(false); + }, []); + + const undoHistory = useCallback((currentData: Entry) => { + if (history.current.undoList.length === 0) return null; + + const last = history.current.undoList.pop(); + if (last === undefined) return null; + + history.current.redoList.push(currentData); + + setCanRedo(true); + setCanUndo(history.current.undoList.length > 0); + + return last; + }, []); + + const redoHistory = useCallback((currentData: Entry) => { + if (history.current.redoList.length === 0) return; + + const last = history.current.redoList.pop(); + if (last === undefined) return; + + history.current.undoList.push(currentData); + + setCanRedo(history.current.redoList.length > 0); + setCanUndo(true); + + return last; + }, []); + + return { + canRedo, + canUndo, + clearHistory, + pushHistory, + undoHistory, + redoHistory, + }; +} diff --git a/apps/client/app/features/artboard/useSketch.ts b/apps/client/app/features/artboard/useSketch.ts new file mode 100644 index 0000000..0f9b3f0 --- /dev/null +++ b/apps/client/app/features/artboard/useSketch.ts @@ -0,0 +1,227 @@ +import { unreachable } from "@/utils/unreachable"; +import type React from "react"; +import { type RefObject, useCallback, useMemo, useRef } from "react"; +import { bucketFill } from "./bucketFill"; +import type { LikeEqualColor, MouseHandlers, Pos } from "./types"; +import { type CanvasData, getCanvasData, rangedEqualColor } from "./utils"; + +type PenType = "pen" | "eraser" | "bucket"; +export interface PenData { + color: `#${string}`; + size: number; + type: PenType; +} + +export function useSketch( + canvasRef: RefObject, + penData: PenData, + pushHistory: (data: CanvasData) => void, +) { + const lastPos = useRef(null); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!isPrimaryClicking(e)) return; + + const canvas = canvasRef.current; + if (canvas === null) return; + const now = getCanvasData(canvas); + if (now === null) return; + + pushHistory(now); + + const pos = getIntMousePos(e); + + switch (penData.type) { + case "pen": + case "eraser": + // NOTE: type narrowing がうまくいかないので明示的に指定 + draw(canvas, lastPos.current, pos, { + ...penData, + type: penData.type, + }); + break; + case "bucket": + // NOTE: type narrowing がうまくいかないので明示的に指定 + fill(canvas, pos, { ...penData, type: penData.type }); + break; + default: + unreachable(penData.type); + } + + lastPos.current = pos; + }, + [canvasRef.current, penData, pushHistory], + ); + + const onMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!isPrimaryClicking(e)) { + lastPos.current = null; + return; + } + + const canvas = canvasRef.current; + if (canvas === null) return; + const pos = getIntMousePos(e); + + switch (penData.type) { + case "pen": + case "eraser": + // NOTE: type narrowing がうまくいかないので明示的に指定 + draw(canvas, lastPos.current, pos, { + ...penData, + type: penData.type, + }); + break; + case "bucket": + // nop + break; + default: + unreachable(penData.type); + } + + lastPos.current = pos; + }, + [canvasRef.current, penData], + ); + + const onMouseUp = useCallback(() => { + lastPos.current = null; + }, []); + + const onMouseLeave = useCallback( + (e: React.MouseEvent) => { + if ( + isPrimaryClicking(e) && + lastPos.current !== null && + penData.type !== "bucket" + ) { + const canvas = canvasRef.current; + if (canvas === null) return; + + const pos = getIntMousePos(e); + + draw(canvas, lastPos.current, pos, { + ...penData, + type: penData.type, + }); + + console.debug(e.movementX, e.movementY); + } + + lastPos.current = null; + }, + [canvasRef.current, penData], + ); + + const onMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!isPrimaryClicking(e)) return; + if (penData.type === "bucket") return; + + const canvas = canvasRef.current; + if (canvas === null) return; + const now = getCanvasData(canvas); + if (now === null) return; + + pushHistory(now); + + const pos = getIntMousePos(e); + + // NOTE: 前の座標がわからないため、最寄りの辺からとする + const sidePos = nearestSidePos(pos, canvas.width, canvas.height); + if (sidePos !== null) { + draw(canvas, sidePos, pos, { + ...penData, + type: penData.type, + }); + } + + lastPos.current = pos; + }, + [pushHistory, canvasRef.current, penData], + ); + + const mouseHandlers = useMemo((): MouseHandlers => { + return { + onMouseDown, + onMouseMove, + onMouseUp, + onMouseLeave, + onMouseEnter, + }; + }, [onMouseDown, onMouseMove, onMouseUp, onMouseLeave, onMouseEnter]); + + return { mouseHandlers }; +} + +function getIntMousePos(e: React.MouseEvent) { + const rect = e.currentTarget.getBoundingClientRect(); + + return { + x: Math.round(e.clientX - rect.left), + y: Math.round(e.clientY - rect.top), + }; +} + +function isPrimaryClicking(e: React.MouseEvent) { + return e.buttons === 1; +} + +function draw( + canvas: HTMLCanvasElement, + lastPos: Pos | null, + pos: Pos, + penData: PenData & { type: "pen" | "eraser" }, +) { + const ctx = canvas.getContext("2d"); + if (ctx === null) return; + + ctx.strokeStyle = penData.color; + ctx.lineWidth = penData.size; + ctx.lineCap = "round"; + ctx.globalCompositeOperation = + penData.type === "eraser" ? "destination-out" : "source-over"; + + ctx.beginPath(); + if (lastPos === null) { + ctx.moveTo(pos.x, pos.y); + } else { + ctx.moveTo(lastPos.x, lastPos.y); + } + ctx.lineTo(pos.x, pos.y); + ctx.stroke(); +} + +const isSameColor: LikeEqualColor = rangedEqualColor(10); +function fill( + canvas: HTMLCanvasElement, + pos: Pos, + penData: PenData & { type: "bucket" }, +) { + const ctx = canvas.getContext("2d"); + if (ctx === null) return; + + bucketFill( + canvas, + pos.x, + pos.y, + penData.color, + [0, canvas.width], + [0, canvas.height], + isSameColor, + ); +} + +function nearestSidePos(pos: Pos, width: number, height: number): Pos | null { + if (pos.x < 0 || pos.x >= width || pos.y < 0 || pos.y >= height) { + return null; + } + + if (Math.min(pos.x, width - pos.x) < Math.min(pos.y, height - pos.y)) { + return { x: pos.x < width / 2 ? 0 : width, y: pos.y }; + } + + return { x: pos.x, y: pos.y < height / 2 ? 0 : height }; +} diff --git a/apps/client/app/features/artboard/utils.ts b/apps/client/app/features/artboard/utils.ts new file mode 100644 index 0000000..08b330e --- /dev/null +++ b/apps/client/app/features/artboard/utils.ts @@ -0,0 +1,114 @@ +import type { Color, LikeEqualColor } from "./types"; + +export type CanvasData = ImageData; +export function getCanvasData(canvas: HTMLCanvasElement): CanvasData | null { + const ctx = canvas.getContext("2d"); + if (ctx === null) return null; + return ctx.getImageData(0, 0, canvas.width, canvas.height); +} + +export function applyCanvasData(canvas: HTMLCanvasElement, data: CanvasData) { + const ctx = canvas.getContext("2d"); + if (ctx === null) return; + ctx.putImageData(data, 0, 0); +} + +/** + * Color オブジェクトが厳密に等しいかどうかを判定する + */ +export function equalColor(a: Color, b: Color) { + return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a; +} +/** + * Color オブジェクトが指定された等しいかどうかを判定する + * ただし、それぞれの差が range 以下であるときに等しいとみなす + */ +export function rangedEqualColor(range: number): LikeEqualColor { + return (a: Color, b: Color) => { + return ( + Math.abs(a.r - b.r) <= range && + Math.abs(a.g - b.g) <= range && + Math.abs(a.b - b.b) <= range && + Math.abs(a.a - b.a) <= range + ); + }; +} + +/** + * カラーコードから rgba 形式の Color オブジェクトを作る + * + * valid なのは #123, #1234, #123456, #12345678 の形式 + */ +export function hexToColor(hex: `#${string}`): Color | null { + // 16進数部分の長さが 3, 4, 6, 8 であるかどうかを判定 + if ( + /^#([A-Fa-f0-9]{3}){1,2}$/.test(hex) || + /^#([A-Fa-f0-9]{4}){1,2}$/.test(hex) + ) { + let c = hex.substring(1).split(""); + // #12345678 の形式に統一 + if (c.length === 3) { + c = [c[0], c[0], c[1], c[1], c[2], c[2], "F", "F"]; + } else if (c.length === 4) { + c = [c[0], c[0], c[1], c[1], c[2], c[2], c[3], c[3]]; + } else if (c.length === 6) { + c = [c[0], c[1], c[2], c[3], c[4], c[5], "F", "F"]; + } + const num = Number.parseInt(`0x${c.join("")}`, 16); + return { + r: (num >> 24) & 255, + g: (num >> 16) & 255, + b: (num >> 8) & 255, + a: num & 255, + }; + } + + return null; +} + +/** + * Uint8ClampedArray から Color[][] へ変換する + */ +export function rawToColors( + data: Uint8ClampedArray, + width: number, + height: number, +): Color[][] { + console.assert(data.length === width * height * 4); + const result = []; + for (let i = 0; i < height; i++) { + const row = []; + for (let j = 0; j < width; j++) { + const index = (i * width + j) * 4; + const r = data[index]; + const g = data[index + 1]; + const b = data[index + 2]; + const a = data[index + 3]; + row.push({ r, g, b, a }); + } + result.push(row); + } + return result; +} +/** + * Color[][] を Uint8ClampedArray に変換する + */ +export function colorsToRaw( + data: Color[][], + width: number, + height: number, +): Uint8ClampedArray { + const result = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < height; i++) { + const row = data[i]; + for (let j = 0; j < width; j++) { + const index = (i * width + j) * 4; + const color = row[j]; + result[index] = color.r; + result[index + 1] = color.g; + result[index + 2] = color.b; + result[index + 3] = color.a; + } + } + return result; +} diff --git a/apps/client/app/routes/tmp2.tsx b/apps/client/app/routes/tmp2.tsx new file mode 100644 index 0000000..7804e02 --- /dev/null +++ b/apps/client/app/routes/tmp2.tsx @@ -0,0 +1,88 @@ +import { Artboard } from "@/features/artboard"; +import type { PenType } from "@/features/artboard/types"; +import { useArtboard } from "@/features/artboard/useArtboard"; +import { useRef, useState } from "react"; + +export default function Tmp2() { + const [penType, setPenType] = useState("pen"); + const [penSize, setPenSize] = useState(1); + const [penColor, setPenColor] = useState<`#${string}`>("#000000"); + + const canvasRef = useRef(null); + const { + canRedo, + canUndo, + clear, + redo, + resetCanvas, + shortcut, + undo, + mouseHandlers, + } = useArtboard(canvasRef, { + color: penColor, + size: penSize, + type: penType, + }); + + return ( +
+
+ + + + +
+
+ setPenColor(e.target.value as `#${string}`)} + /> + setPenSize(Number(e.target.value))} + /> + + + +
+ +
+ ); +} diff --git a/apps/client/app/utils/boolAttr.ts b/apps/client/app/utils/boolAttr.ts new file mode 100644 index 0000000..87e943a --- /dev/null +++ b/apps/client/app/utils/boolAttr.ts @@ -0,0 +1,3 @@ +export function boolAttr(value?: boolean): "" | undefined { + return value === true ? "" : undefined; +} diff --git a/apps/client/app/utils/unreachable.ts b/apps/client/app/utils/unreachable.ts new file mode 100644 index 0000000..d939e8e --- /dev/null +++ b/apps/client/app/utils/unreachable.ts @@ -0,0 +1,3 @@ +export function unreachable(x: never): never { + throw new Error(`unreachable: ${x}`); +}