Skip to content

Commit

Permalink
🎉 お絵かきキャンバス実装
Browse files Browse the repository at this point in the history
  • Loading branch information
SSlime-s committed May 16, 2024
1 parent 7712535 commit 1abf747
Show file tree
Hide file tree
Showing 9 changed files with 785 additions and 0 deletions.
174 changes: 174 additions & 0 deletions apps/client/app/features/artboard/bucketFill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// ref: https://github.com/cat-crosswalk/nascalay-frontend/blob/main/src/components/Canvas/bucketFill.ts

import {
type Color,
type LikeEqualColor,
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<Color[][]>,
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,
color: `#${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 colorObj = hexToColor(color);
if (colorObj === null) {
console.error("invalid color");
return;
}
if (likeEqualColor(colorObj, 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,
colorObj,
xRange,
targetColor,
likeEqualColor,
) ?? x;
const rightX =
drawToRight(
formattedData,
x,
y,
colorObj,
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);
}
17 changes: 17 additions & 0 deletions apps/client/app/features/artboard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import styled from "@emotion/styled";
import type { RefObject } from "react";
import type { MouseHandlers } from "./types";

interface Props {
canvasRef: RefObject<HTMLCanvasElement>;
mouseHandlers: MouseHandlers;
isLocked?: boolean;
}

export function Artboard({ canvasRef, mouseHandlers, isLocked }: Props) {
return <Canvas ref={canvasRef} width={640} height={480} {...mouseHandlers} />;
}

const Canvas = styled.canvas`
border: 1px solid black;
`;
26 changes: 26 additions & 0 deletions apps/client/app/features/artboard/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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<HTMLElement>,
"onMouseDown" | "onMouseMove" | "onMouseUp" | "onMouseOut" | "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;
135 changes: 135 additions & 0 deletions apps/client/app/features/artboard/useArtboard.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLCanvasElement>,
penData: PenData,
): Handler {
// NOTE: initial state が重くなる場合は https://ja.react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents のようにする
const state = useRef<State>(createInitialState());
const {
canRedo,
canUndo,
clearHistory,
pushHistory,
undoHistory,
redoHistory,
} = useHistory<CanvasData>();
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,
};
}
Loading

0 comments on commit 1abf747

Please sign in to comment.