mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add drawing and shape tools for image editor
Implement 8 tool hooks, 7 options bar components, and 1 fill dialog: Tools: - Brush: Konva.Line with smooth strokes, hardness via shadowBlur - Eraser: brush with globalCompositeOperation destination-out - Shape: Rectangle, Ellipse, Line, Arrow, Polygon, Star with Shift constraint - Fill (Paint Bucket): scanline flood fill with tolerance and contiguous mode - Gradient: linear/radial via drag gesture on offscreen canvas - Clone Stamp: Alt+click source, paint cloned pixels with aligned mode - Dodge/Burn/Sponge: per-pixel brightness/saturation manipulation by range - Pixel Brush: blur (box blur), sharpen (unsharp), smudge (directional blend) Options bars: - Brush: size/opacity/hardness sliders - Shape: type selector, fill/stroke color, stroke width, corner radius, sides - Fill: tolerance slider, contiguous checkbox - Gradient: linear/radial toggle, opacity, reverse - Clone Stamp: size/opacity/hardness, aligned toggle - Dodge/Burn: tool toggle, range dropdown, exposure/flow sliders - Pixel Brush: tool toggle, size, strength Common: - Fill Dialog: Shift+Backspace modal with foreground/background/color/white/black/50% gray
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
// apps/web/src/components/editor/tools/brush-tool.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject, LineAttrs } from "@/types/editor";
|
||||
|
||||
interface StrokeState {
|
||||
points: number[];
|
||||
objectId: string;
|
||||
}
|
||||
|
||||
export function useBrushTool() {
|
||||
const strokeRef = useRef<StrokeState | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const { activeTool, foregroundColor, brushSize, brushOpacity, brushHardness, zoom, panOffset } =
|
||||
useEditorStore.getState();
|
||||
|
||||
if (activeTool !== "brush" && activeTool !== "pencil") return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = (pointer.x - panOffset.x) / zoom;
|
||||
const y = (pointer.y - panOffset.y) / zoom;
|
||||
|
||||
const id = generateId();
|
||||
const shadowBlurValue = activeTool === "pencil" ? 0 : brushSize * 0.4 * (1 - brushHardness);
|
||||
|
||||
const attrs: LineAttrs = {
|
||||
points: [x, y],
|
||||
stroke: foregroundColor,
|
||||
strokeWidth: brushSize,
|
||||
tension: activeTool === "pencil" ? 0 : 0.5,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
opacity: brushOpacity,
|
||||
globalCompositeOperation: "source-over",
|
||||
...(shadowBlurValue > 0 && {
|
||||
shadowBlur: shadowBlurValue,
|
||||
shadowColor: foregroundColor,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
const obj: CanvasObject = {
|
||||
id,
|
||||
type: "line",
|
||||
layerId: useEditorStore.getState().activeLayerId,
|
||||
attrs,
|
||||
};
|
||||
|
||||
useEditorStore.getState().addObject(obj);
|
||||
strokeRef.current = { points: [x, y], objectId: id };
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (!strokeRef.current) return;
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const { zoom, panOffset } = useEditorStore.getState();
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = (pointer.x - panOffset.x) / zoom;
|
||||
const y = (pointer.y - panOffset.y) / zoom;
|
||||
|
||||
strokeRef.current.points = [...strokeRef.current.points, x, y];
|
||||
|
||||
useEditorStore.getState().updateObject(strokeRef.current.objectId, {
|
||||
points: [...strokeRef.current.points],
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
strokeRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// apps/web/src/components/editor/tools/clone-stamp-tool.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject } from "@/types/editor";
|
||||
|
||||
let cloneAligned = true;
|
||||
|
||||
export function setCloneAligned(value: boolean) {
|
||||
cloneAligned = value;
|
||||
}
|
||||
|
||||
export function getCloneAligned(): boolean {
|
||||
return cloneAligned;
|
||||
}
|
||||
|
||||
interface StampState {
|
||||
objectId: string;
|
||||
canvas: HTMLCanvasElement;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
sourceSnapshot: ImageData;
|
||||
startX: number;
|
||||
startY: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
export function useCloneStampTool(stageRef: React.RefObject<Konva.Stage | null>) {
|
||||
const stampRef = useRef<StampState | null>(null);
|
||||
const initialOffsetRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const { activeTool, cloneSource, brushSize, brushOpacity, canvasSize, zoom, panOffset } =
|
||||
useEditorStore.getState();
|
||||
|
||||
if (activeTool !== "clone-stamp") return;
|
||||
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = Math.floor((pointer.x - panOffset.x) / zoom);
|
||||
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
||||
|
||||
// Alt+click sets the clone source
|
||||
if (e.evt.altKey) {
|
||||
useEditorStore.setState({
|
||||
cloneSource: { x, y, aligned: cloneAligned },
|
||||
});
|
||||
initialOffsetRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cloneSource) return;
|
||||
|
||||
// Capture a snapshot of the current stage pixels
|
||||
const stageCanvas = stage.toCanvas({
|
||||
pixelRatio: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
});
|
||||
|
||||
const stageCtx = stageCanvas.getContext("2d");
|
||||
if (!stageCtx) return;
|
||||
|
||||
const sourceSnapshot = stageCtx.getImageData(0, 0, canvasSize.width, canvasSize.height);
|
||||
|
||||
// Create an offscreen canvas for the clone output
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvasSize.width;
|
||||
canvas.height = canvasSize.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Compute offset from source to destination
|
||||
let offsetX: number;
|
||||
let offsetY: number;
|
||||
|
||||
if (cloneAligned && initialOffsetRef.current) {
|
||||
offsetX = initialOffsetRef.current.x;
|
||||
offsetY = initialOffsetRef.current.y;
|
||||
} else {
|
||||
offsetX = cloneSource.x - x;
|
||||
offsetY = cloneSource.y - y;
|
||||
if (cloneAligned) {
|
||||
initialOffsetRef.current = { x: offsetX, y: offsetY };
|
||||
}
|
||||
}
|
||||
|
||||
// Paint the first dab
|
||||
paintDab(ctx, sourceSnapshot, x, y, offsetX, offsetY, brushSize, brushOpacity, canvasSize);
|
||||
|
||||
const id = generateId();
|
||||
const dataUrl = canvas.toDataURL();
|
||||
|
||||
const obj: CanvasObject = {
|
||||
id,
|
||||
type: "image",
|
||||
layerId: useEditorStore.getState().activeLayerId,
|
||||
attrs: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
src: dataUrl,
|
||||
},
|
||||
};
|
||||
|
||||
useEditorStore.getState().addObject(obj);
|
||||
stampRef.current = {
|
||||
objectId: id,
|
||||
canvas,
|
||||
ctx,
|
||||
sourceSnapshot,
|
||||
startX: x,
|
||||
startY: y,
|
||||
offsetX,
|
||||
offsetY,
|
||||
};
|
||||
},
|
||||
[stageRef],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (!stampRef.current) return;
|
||||
|
||||
const { brushSize, brushOpacity, canvasSize, zoom, panOffset } = useEditorStore.getState();
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = Math.floor((pointer.x - panOffset.x) / zoom);
|
||||
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
||||
|
||||
const { ctx, sourceSnapshot, offsetX, offsetY, canvas, objectId } = stampRef.current;
|
||||
|
||||
paintDab(ctx, sourceSnapshot, x, y, offsetX, offsetY, brushSize, brushOpacity, canvasSize);
|
||||
|
||||
const dataUrl = canvas.toDataURL();
|
||||
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
stampRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp };
|
||||
}
|
||||
|
||||
function paintDab(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
source: ImageData,
|
||||
destX: number,
|
||||
destY: number,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
brushSize: number,
|
||||
opacity: number,
|
||||
canvasSize: { width: number; height: number },
|
||||
): void {
|
||||
const halfSize = Math.floor(brushSize / 2);
|
||||
const srcX = destX + offsetX;
|
||||
const srcY = destY + offsetY;
|
||||
|
||||
for (let dy = -halfSize; dy <= halfSize; dy++) {
|
||||
for (let dx = -halfSize; dx <= halfSize; dx++) {
|
||||
// Circle mask
|
||||
if (dx * dx + dy * dy > halfSize * halfSize) continue;
|
||||
|
||||
const px = destX + dx;
|
||||
const py = destY + dy;
|
||||
const sx = srcX + dx;
|
||||
const sy = srcY + dy;
|
||||
|
||||
if (
|
||||
px < 0 ||
|
||||
px >= canvasSize.width ||
|
||||
py < 0 ||
|
||||
py >= canvasSize.height ||
|
||||
sx < 0 ||
|
||||
sx >= canvasSize.width ||
|
||||
sy < 0 ||
|
||||
sy >= canvasSize.height
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const si = (sy * canvasSize.width + sx) * 4;
|
||||
const r = source.data[si];
|
||||
const g = source.data[si + 1];
|
||||
const b = source.data[si + 2];
|
||||
const a = source.data[si + 3];
|
||||
|
||||
ctx.globalAlpha = (a / 255) * opacity;
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
ctx.fillRect(px, py, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// apps/web/src/components/editor/tools/dodge-burn-tool.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject, ToolType } from "@/types/editor";
|
||||
|
||||
const DODGE_BURN_TOOLS = new Set<ToolType>(["dodge", "burn", "sponge"]);
|
||||
|
||||
interface StrokeState {
|
||||
objectId: string;
|
||||
canvas: HTMLCanvasElement;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
sourceSnapshot: ImageData;
|
||||
}
|
||||
|
||||
function getRangeFactor(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
range: "shadows" | "midtones" | "highlights",
|
||||
): number {
|
||||
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
switch (range) {
|
||||
case "shadows":
|
||||
return luminance < 85 ? 1 : luminance < 128 ? (128 - luminance) / 43 : 0;
|
||||
case "highlights":
|
||||
return luminance > 170 ? 1 : luminance > 128 ? (luminance - 128) / 42 : 0;
|
||||
default:
|
||||
if (luminance < 64) return luminance / 64;
|
||||
if (luminance > 192) return (255 - luminance) / 63;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToHsl(rIn: number, gIn: number, bIn: number): [number, number, number] {
|
||||
const r = rIn / 255;
|
||||
const g = gIn / 255;
|
||||
const b = bIn / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
if (s === 0) {
|
||||
const v = Math.round(l * 255);
|
||||
return [v, v, v];
|
||||
}
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
|
||||
return [
|
||||
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||||
Math.round(hue2rgb(p, q, h) * 255),
|
||||
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export function useDodgeBurnTool(stageRef: React.RefObject<Konva.Stage | null>) {
|
||||
const strokeRef = useRef<StrokeState | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(_e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const { activeTool, canvasSize, zoom, panOffset } = useEditorStore.getState();
|
||||
|
||||
if (!DODGE_BURN_TOOLS.has(activeTool)) return;
|
||||
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = Math.floor((pointer.x - panOffset.x) / zoom);
|
||||
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
||||
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
|
||||
|
||||
// Snapshot current stage pixels
|
||||
const stageCanvas = stage.toCanvas({
|
||||
pixelRatio: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
});
|
||||
|
||||
const stageCtx = stageCanvas.getContext("2d");
|
||||
if (!stageCtx) return;
|
||||
|
||||
const sourceSnapshot = stageCtx.getImageData(0, 0, canvasSize.width, canvasSize.height);
|
||||
|
||||
// Create output canvas
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvasSize.width;
|
||||
canvas.height = canvasSize.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.putImageData(sourceSnapshot, 0, 0);
|
||||
|
||||
applyBrushDab(ctx, sourceSnapshot, x, y, canvasSize);
|
||||
|
||||
const id = generateId();
|
||||
const dataUrl = canvas.toDataURL();
|
||||
|
||||
const obj: CanvasObject = {
|
||||
id,
|
||||
type: "image",
|
||||
layerId: useEditorStore.getState().activeLayerId,
|
||||
attrs: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
src: dataUrl,
|
||||
},
|
||||
};
|
||||
|
||||
useEditorStore.getState().addObject(obj);
|
||||
strokeRef.current = { objectId: id, canvas, ctx, sourceSnapshot };
|
||||
},
|
||||
[stageRef],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (!strokeRef.current) return;
|
||||
|
||||
const { canvasSize, zoom, panOffset } = useEditorStore.getState();
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = Math.floor((pointer.x - panOffset.x) / zoom);
|
||||
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
||||
|
||||
const { ctx, sourceSnapshot, canvas, objectId } = strokeRef.current;
|
||||
|
||||
applyBrushDab(ctx, sourceSnapshot, x, y, canvasSize);
|
||||
|
||||
const dataUrl = canvas.toDataURL();
|
||||
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
strokeRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp };
|
||||
}
|
||||
|
||||
function applyBrushDab(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_source: ImageData,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
canvasSize: { width: number; height: number },
|
||||
): void {
|
||||
const { activeTool, brushSize, dodgeBurnExposure, dodgeBurnRange, spongeMode, spongeFlow } =
|
||||
useEditorStore.getState();
|
||||
|
||||
const halfSize = Math.floor(brushSize / 2);
|
||||
const exposure = dodgeBurnExposure / 100;
|
||||
const flow = spongeFlow / 100;
|
||||
|
||||
const imageData = ctx.getImageData(
|
||||
Math.max(0, centerX - halfSize),
|
||||
Math.max(0, centerY - halfSize),
|
||||
Math.min(canvasSize.width, centerX + halfSize + 1) - Math.max(0, centerX - halfSize),
|
||||
Math.min(canvasSize.height, centerY + halfSize + 1) - Math.max(0, centerY - halfSize),
|
||||
);
|
||||
|
||||
const startPx = Math.max(0, centerX - halfSize);
|
||||
const startPy = Math.max(0, centerY - halfSize);
|
||||
|
||||
for (let py = 0; py < imageData.height; py++) {
|
||||
for (let px = 0; px < imageData.width; px++) {
|
||||
const dx = startPx + px - centerX;
|
||||
const dy = startPy + py - centerY;
|
||||
|
||||
if (dx * dx + dy * dy > halfSize * halfSize) continue;
|
||||
|
||||
const idx = (py * imageData.width + px) * 4;
|
||||
let r = imageData.data[idx];
|
||||
let g = imageData.data[idx + 1];
|
||||
let b = imageData.data[idx + 2];
|
||||
|
||||
if (activeTool === "dodge") {
|
||||
const factor = getRangeFactor(r, g, b, dodgeBurnRange);
|
||||
const multiplier = 1 + exposure * factor;
|
||||
r = clamp(Math.round(r * multiplier), 0, 255);
|
||||
g = clamp(Math.round(g * multiplier), 0, 255);
|
||||
b = clamp(Math.round(b * multiplier), 0, 255);
|
||||
} else if (activeTool === "burn") {
|
||||
const factor = getRangeFactor(r, g, b, dodgeBurnRange);
|
||||
const multiplier = 1 - exposure * factor;
|
||||
r = clamp(Math.round(r * multiplier), 0, 255);
|
||||
g = clamp(Math.round(g * multiplier), 0, 255);
|
||||
b = clamp(Math.round(b * multiplier), 0, 255);
|
||||
} else if (activeTool === "sponge") {
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
let newS: number;
|
||||
if (spongeMode === "saturate") {
|
||||
newS = Math.min(1, s + flow * 0.1);
|
||||
} else {
|
||||
newS = Math.max(0, s - flow * 0.1);
|
||||
}
|
||||
const [nr, ng, nb] = hslToRgb(h, newS, l);
|
||||
r = nr;
|
||||
g = ng;
|
||||
b = nb;
|
||||
}
|
||||
|
||||
imageData.data[idx] = r;
|
||||
imageData.data[idx + 1] = g;
|
||||
imageData.data[idx + 2] = b;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, startPx, startPy);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// apps/web/src/components/editor/tools/eraser-tool.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject, LineAttrs } from "@/types/editor";
|
||||
|
||||
interface StrokeState {
|
||||
points: number[];
|
||||
objectId: string;
|
||||
}
|
||||
|
||||
export function useEraserTool() {
|
||||
const strokeRef = useRef<StrokeState | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const { activeTool, brushSize, brushOpacity, brushHardness, zoom, panOffset } =
|
||||
useEditorStore.getState();
|
||||
|
||||
if (activeTool !== "eraser") return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = (pointer.x - panOffset.x) / zoom;
|
||||
const y = (pointer.y - panOffset.y) / zoom;
|
||||
|
||||
const id = generateId();
|
||||
const shadowBlurValue = brushSize * 0.4 * (1 - brushHardness);
|
||||
|
||||
const attrs: LineAttrs = {
|
||||
points: [x, y],
|
||||
stroke: "#000000",
|
||||
strokeWidth: brushSize,
|
||||
tension: 0.5,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
opacity: brushOpacity,
|
||||
globalCompositeOperation: "destination-out",
|
||||
...(shadowBlurValue > 0 && {
|
||||
shadowBlur: shadowBlurValue,
|
||||
shadowColor: "#000000",
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
const obj: CanvasObject = {
|
||||
id,
|
||||
type: "line",
|
||||
layerId: useEditorStore.getState().activeLayerId,
|
||||
attrs,
|
||||
};
|
||||
|
||||
useEditorStore.getState().addObject(obj);
|
||||
strokeRef.current = { points: [x, y], objectId: id };
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (!strokeRef.current) return;
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const { zoom, panOffset } = useEditorStore.getState();
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = (pointer.x - panOffset.x) / zoom;
|
||||
const y = (pointer.y - panOffset.y) / zoom;
|
||||
|
||||
strokeRef.current.points = [...strokeRef.current.points, x, y];
|
||||
|
||||
useEditorStore.getState().updateObject(strokeRef.current.objectId, {
|
||||
points: [...strokeRef.current.points],
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
strokeRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp };
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// apps/web/src/components/editor/tools/fill-tool.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import { useCallback } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject } from "@/types/editor";
|
||||
|
||||
/** Tolerance for flood fill (0-255). Stored outside store for simplicity. */
|
||||
let fillTolerance = 32;
|
||||
let fillContiguous = true;
|
||||
|
||||
export function setFillTolerance(value: number) {
|
||||
fillTolerance = Math.max(0, Math.min(255, value));
|
||||
}
|
||||
|
||||
export function getFillTolerance(): number {
|
||||
return fillTolerance;
|
||||
}
|
||||
|
||||
export function setFillContiguous(value: boolean) {
|
||||
fillContiguous = value;
|
||||
}
|
||||
|
||||
export function getFillContiguous(): boolean {
|
||||
return fillContiguous;
|
||||
}
|
||||
|
||||
function colorDistance(
|
||||
r1: number,
|
||||
g1: number,
|
||||
b1: number,
|
||||
r2: number,
|
||||
g2: number,
|
||||
b2: number,
|
||||
): number {
|
||||
const dr = r1 - r2;
|
||||
const dg = g1 - g2;
|
||||
const db = b1 - b2;
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const num = Number.parseInt(hex.replace("#", ""), 16);
|
||||
return [(num >> 16) & 0xff, (num >> 8) & 0xff, num & 0xff];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanline flood fill algorithm.
|
||||
* Fills pixels within tolerance of the target color with the replacement color.
|
||||
*/
|
||||
function floodFill(
|
||||
imageData: ImageData,
|
||||
startX: number,
|
||||
startY: number,
|
||||
fillR: number,
|
||||
fillG: number,
|
||||
fillB: number,
|
||||
tolerance: number,
|
||||
contiguous: boolean,
|
||||
): void {
|
||||
const { width, height, data } = imageData;
|
||||
const startIdx = (startY * width + startX) * 4;
|
||||
const targetR = data[startIdx];
|
||||
const targetG = data[startIdx + 1];
|
||||
const targetB = data[startIdx + 2];
|
||||
|
||||
// Already same color, skip
|
||||
if (fillR === targetR && fillG === targetG && fillB === targetB) return;
|
||||
|
||||
const maxDist = tolerance * Math.sqrt(3);
|
||||
|
||||
if (!contiguous) {
|
||||
// Non-contiguous: fill all pixels matching target color
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const dist = colorDistance(data[i], data[i + 1], data[i + 2], targetR, targetG, targetB);
|
||||
if (dist <= maxDist) {
|
||||
data[i] = fillR;
|
||||
data[i + 1] = fillG;
|
||||
data[i + 2] = fillB;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Contiguous scanline flood fill
|
||||
const visited = new Uint8Array(width * height);
|
||||
|
||||
function matches(idx: number): boolean {
|
||||
const pi = idx * 4;
|
||||
if (visited[idx]) return false;
|
||||
return (
|
||||
colorDistance(data[pi], data[pi + 1], data[pi + 2], targetR, targetG, targetB) <= maxDist
|
||||
);
|
||||
}
|
||||
|
||||
function fillPixel(idx: number): void {
|
||||
const pi = idx * 4;
|
||||
data[pi] = fillR;
|
||||
data[pi + 1] = fillG;
|
||||
data[pi + 2] = fillB;
|
||||
data[pi + 3] = 255;
|
||||
visited[idx] = 1;
|
||||
}
|
||||
|
||||
const stack: [number, number][] = [[startX, startY]];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const entry = stack.pop();
|
||||
if (!entry) break;
|
||||
const [sx, sy] = entry;
|
||||
let x = sx;
|
||||
|
||||
// Move left to find the start of the line
|
||||
while (x > 0 && matches(sy * width + (x - 1))) {
|
||||
x--;
|
||||
}
|
||||
|
||||
let spanAbove = false;
|
||||
let spanBelow = false;
|
||||
|
||||
while (x < width && matches(sy * width + x)) {
|
||||
fillPixel(sy * width + x);
|
||||
|
||||
// Check above
|
||||
if (sy > 0) {
|
||||
const aboveIdx = (sy - 1) * width + x;
|
||||
if (matches(aboveIdx)) {
|
||||
if (!spanAbove) {
|
||||
stack.push([x, sy - 1]);
|
||||
spanAbove = true;
|
||||
}
|
||||
} else {
|
||||
spanAbove = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check below
|
||||
if (sy < height - 1) {
|
||||
const belowIdx = (sy + 1) * width + x;
|
||||
if (matches(belowIdx)) {
|
||||
if (!spanBelow) {
|
||||
stack.push([x, sy + 1]);
|
||||
spanBelow = true;
|
||||
}
|
||||
} else {
|
||||
spanBelow = false;
|
||||
}
|
||||
}
|
||||
|
||||
x++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useFillTool(stageRef: React.RefObject<Konva.Stage | null>) {
|
||||
const handleMouseDown = useCallback(
|
||||
(_e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const { activeTool, foregroundColor, canvasSize, zoom, panOffset } =
|
||||
useEditorStore.getState();
|
||||
|
||||
if (activeTool !== "fill") return;
|
||||
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = Math.floor((pointer.x - panOffset.x) / zoom);
|
||||
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
||||
|
||||
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
|
||||
|
||||
// Export the current stage to a canvas for pixel access
|
||||
const stageCanvas = stage.toCanvas({
|
||||
pixelRatio: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
});
|
||||
|
||||
const ctx = stageCanvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvasSize.width, canvasSize.height);
|
||||
const [fillR, fillG, fillB] = hexToRgb(foregroundColor);
|
||||
|
||||
floodFill(imageData, x, y, fillR, fillG, fillB, fillTolerance, fillContiguous);
|
||||
|
||||
// Put the modified data back and create an image object
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const dataUrl = stageCanvas.toDataURL();
|
||||
|
||||
const obj: CanvasObject = {
|
||||
id: generateId(),
|
||||
type: "image",
|
||||
layerId: useEditorStore.getState().activeLayerId,
|
||||
attrs: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
src: dataUrl,
|
||||
},
|
||||
};
|
||||
|
||||
useEditorStore.getState().addObject(obj);
|
||||
},
|
||||
[stageRef],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback((_e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
// No-op: fill is a single-click operation
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
// No-op
|
||||
}, []);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// apps/web/src/components/editor/tools/gradient-tool.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject } from "@/types/editor";
|
||||
|
||||
export type GradientType = "linear" | "radial";
|
||||
|
||||
let gradientType: GradientType = "linear";
|
||||
let gradientOpacity = 1;
|
||||
let gradientReverse = false;
|
||||
|
||||
export function setGradientType(type: GradientType) {
|
||||
gradientType = type;
|
||||
}
|
||||
|
||||
export function getGradientType(): GradientType {
|
||||
return gradientType;
|
||||
}
|
||||
|
||||
export function setGradientOpacity(value: number) {
|
||||
gradientOpacity = Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
export function getGradientOpacity(): number {
|
||||
return gradientOpacity;
|
||||
}
|
||||
|
||||
export function setGradientReverse(value: boolean) {
|
||||
gradientReverse = value;
|
||||
}
|
||||
|
||||
export function getGradientReverse(): boolean {
|
||||
return gradientReverse;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
}
|
||||
|
||||
export function useGradientTool() {
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const { activeTool, zoom, panOffset } = useEditorStore.getState();
|
||||
|
||||
if (activeTool !== "gradient") return;
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = (pointer.x - panOffset.x) / zoom;
|
||||
const y = (pointer.y - panOffset.y) / zoom;
|
||||
|
||||
dragRef.current = { startX: x, startY: y };
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((_e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
// Could show a preview line/circle here; keeping simple for now
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (!dragRef.current) return;
|
||||
|
||||
const { foregroundColor, backgroundColor, canvasSize, zoom, panOffset } =
|
||||
useEditorStore.getState();
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const endX = (pointer.x - panOffset.x) / zoom;
|
||||
const endY = (pointer.y - panOffset.y) / zoom;
|
||||
const { startX, startY } = dragRef.current;
|
||||
|
||||
const dx = endX - startX;
|
||||
const dy = endY - startY;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < 2) {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build gradient on an offscreen canvas
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvasSize.width;
|
||||
canvas.height = canvasSize.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const color1 = gradientReverse ? backgroundColor : foregroundColor;
|
||||
const color2 = gradientReverse ? foregroundColor : backgroundColor;
|
||||
|
||||
let gradient: CanvasGradient;
|
||||
|
||||
if (gradientType === "radial") {
|
||||
const radius = Math.sqrt(dx * dx + dy * dy);
|
||||
gradient = ctx.createRadialGradient(startX, startY, 0, startX, startY, radius);
|
||||
} else {
|
||||
gradient = ctx.createLinearGradient(startX, startY, endX, endY);
|
||||
}
|
||||
|
||||
gradient.addColorStop(0, color1);
|
||||
gradient.addColorStop(1, color2);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
|
||||
|
||||
const dataUrl = canvas.toDataURL();
|
||||
|
||||
const obj: CanvasObject = {
|
||||
id: generateId(),
|
||||
type: "image",
|
||||
layerId: useEditorStore.getState().activeLayerId,
|
||||
attrs: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
rotation: 0,
|
||||
opacity: gradientOpacity,
|
||||
src: dataUrl,
|
||||
},
|
||||
};
|
||||
|
||||
useEditorStore.getState().addObject(obj);
|
||||
dragRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp };
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// apps/web/src/components/editor/tools/pixel-brush-tool.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject, ToolType } from "@/types/editor";
|
||||
|
||||
const PIXEL_BRUSH_TOOLS = new Set<ToolType>(["blur-brush", "sharpen-brush", "smudge"]);
|
||||
|
||||
let pixelBrushStrength = 50;
|
||||
|
||||
export function setPixelBrushStrength(value: number) {
|
||||
pixelBrushStrength = Math.max(1, Math.min(100, value));
|
||||
}
|
||||
|
||||
export function getPixelBrushStrength(): number {
|
||||
return pixelBrushStrength;
|
||||
}
|
||||
|
||||
interface StrokeState {
|
||||
objectId: string;
|
||||
canvas: HTMLCanvasElement;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
sourceSnapshot: ImageData;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export function usePixelBrushTool(stageRef: React.RefObject<Konva.Stage | null>) {
|
||||
const strokeRef = useRef<StrokeState | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(_e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const { activeTool, canvasSize, zoom, panOffset } = useEditorStore.getState();
|
||||
|
||||
if (!PIXEL_BRUSH_TOOLS.has(activeTool)) return;
|
||||
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = Math.floor((pointer.x - panOffset.x) / zoom);
|
||||
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
||||
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
|
||||
|
||||
// Snapshot stage
|
||||
const stageCanvas = stage.toCanvas({
|
||||
pixelRatio: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
});
|
||||
|
||||
const stageCtx = stageCanvas.getContext("2d");
|
||||
if (!stageCtx) return;
|
||||
|
||||
const sourceSnapshot = stageCtx.getImageData(0, 0, canvasSize.width, canvasSize.height);
|
||||
|
||||
// Create output canvas
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvasSize.width;
|
||||
canvas.height = canvasSize.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.putImageData(sourceSnapshot, 0, 0);
|
||||
|
||||
applyPixelBrush(ctx, sourceSnapshot, x, y, canvasSize);
|
||||
|
||||
const id = generateId();
|
||||
const dataUrl = canvas.toDataURL();
|
||||
|
||||
const obj: CanvasObject = {
|
||||
id,
|
||||
type: "image",
|
||||
layerId: useEditorStore.getState().activeLayerId,
|
||||
attrs: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
src: dataUrl,
|
||||
},
|
||||
};
|
||||
|
||||
useEditorStore.getState().addObject(obj);
|
||||
strokeRef.current = { objectId: id, canvas, ctx, sourceSnapshot, lastX: x, lastY: y };
|
||||
},
|
||||
[stageRef],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (!strokeRef.current) return;
|
||||
|
||||
const { canvasSize, zoom, panOffset } = useEditorStore.getState();
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = Math.floor((pointer.x - panOffset.x) / zoom);
|
||||
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
||||
|
||||
const { ctx, sourceSnapshot, canvas, objectId } = strokeRef.current;
|
||||
|
||||
applyPixelBrush(ctx, sourceSnapshot, x, y, canvasSize);
|
||||
|
||||
// Update source snapshot for smudge continuity
|
||||
const updatedData = ctx.getImageData(0, 0, canvasSize.width, canvasSize.height);
|
||||
strokeRef.current.sourceSnapshot = updatedData;
|
||||
strokeRef.current.lastX = x;
|
||||
strokeRef.current.lastY = y;
|
||||
|
||||
const dataUrl = canvas.toDataURL();
|
||||
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
strokeRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp };
|
||||
}
|
||||
|
||||
function applyPixelBrush(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
source: ImageData,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
canvasSize: { width: number; height: number },
|
||||
): void {
|
||||
const { activeTool, brushSize } = useEditorStore.getState();
|
||||
const strength = pixelBrushStrength / 100;
|
||||
const halfSize = Math.floor(brushSize / 2);
|
||||
|
||||
const left = Math.max(0, centerX - halfSize);
|
||||
const top = Math.max(0, centerY - halfSize);
|
||||
const right = Math.min(canvasSize.width, centerX + halfSize + 1);
|
||||
const bottom = Math.min(canvasSize.height, centerY + halfSize + 1);
|
||||
const w = right - left;
|
||||
const h = bottom - top;
|
||||
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
const imageData = ctx.getImageData(left, top, w, h);
|
||||
|
||||
if (activeTool === "blur-brush") {
|
||||
applyBoxBlur(imageData, source, left, top, canvasSize.width, halfSize, strength);
|
||||
} else if (activeTool === "sharpen-brush") {
|
||||
applySharpen(
|
||||
imageData,
|
||||
source,
|
||||
left,
|
||||
top,
|
||||
centerX,
|
||||
centerY,
|
||||
canvasSize.width,
|
||||
halfSize,
|
||||
strength,
|
||||
);
|
||||
} else if (activeTool === "smudge") {
|
||||
applySmudge(
|
||||
imageData,
|
||||
source,
|
||||
left,
|
||||
top,
|
||||
centerX,
|
||||
centerY,
|
||||
canvasSize.width,
|
||||
halfSize,
|
||||
strength,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, left, top);
|
||||
}
|
||||
|
||||
function applyBoxBlur(
|
||||
imageData: ImageData,
|
||||
source: ImageData,
|
||||
startX: number,
|
||||
startY: number,
|
||||
sourceWidth: number,
|
||||
halfSize: number,
|
||||
strength: number,
|
||||
): void {
|
||||
const kernelSize = Math.max(1, Math.round(strength * 3));
|
||||
|
||||
for (let py = 0; py < imageData.height; py++) {
|
||||
for (let px = 0; px < imageData.width; px++) {
|
||||
const dx = startX + px - (startX + imageData.width / 2);
|
||||
const dy = startY + py - (startY + imageData.height / 2);
|
||||
if (dx * dx + dy * dy > halfSize * halfSize) continue;
|
||||
|
||||
let sumR = 0;
|
||||
let sumG = 0;
|
||||
let sumB = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let ky = -kernelSize; ky <= kernelSize; ky++) {
|
||||
for (let kx = -kernelSize; kx <= kernelSize; kx++) {
|
||||
const sx = startX + px + kx;
|
||||
const sy = startY + py + ky;
|
||||
if (
|
||||
sx < 0 ||
|
||||
sx >= sourceWidth ||
|
||||
sy < 0 ||
|
||||
sy >= source.height / (source.width / sourceWidth)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const si = (sy * sourceWidth + sx) * 4;
|
||||
sumR += source.data[si];
|
||||
sumG += source.data[si + 1];
|
||||
sumB += source.data[si + 2];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
const idx = (py * imageData.width + px) * 4;
|
||||
imageData.data[idx] = Math.round(sumR / count);
|
||||
imageData.data[idx + 1] = Math.round(sumG / count);
|
||||
imageData.data[idx + 2] = Math.round(sumB / count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySharpen(
|
||||
imageData: ImageData,
|
||||
source: ImageData,
|
||||
startX: number,
|
||||
startY: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
sourceWidth: number,
|
||||
halfSize: number,
|
||||
strength: number,
|
||||
): void {
|
||||
const factor = 1 + strength * 4;
|
||||
|
||||
for (let py = 0; py < imageData.height; py++) {
|
||||
for (let px = 0; px < imageData.width; px++) {
|
||||
const dxC = startX + px - centerX;
|
||||
const dyC = startY + py - centerY;
|
||||
if (dxC * dxC + dyC * dyC > halfSize * halfSize) continue;
|
||||
|
||||
const sx = startX + px;
|
||||
const sy = startY + py;
|
||||
const ci = (sy * sourceWidth + sx) * 4;
|
||||
|
||||
// Simple unsharp: pixel + factor * (pixel - average of neighbors)
|
||||
let avgR = 0;
|
||||
let avgG = 0;
|
||||
let avgB = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let ky = -1; ky <= 1; ky++) {
|
||||
for (let kx = -1; kx <= 1; kx++) {
|
||||
if (kx === 0 && ky === 0) continue;
|
||||
const nx = sx + kx;
|
||||
const ny = sy + ky;
|
||||
if (nx >= 0 && nx < sourceWidth && ny >= 0) {
|
||||
const ni = (ny * sourceWidth + nx) * 4;
|
||||
if (ni >= 0 && ni < source.data.length) {
|
||||
avgR += source.data[ni];
|
||||
avgG += source.data[ni + 1];
|
||||
avgB += source.data[ni + 2];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
avgR /= count;
|
||||
avgG /= count;
|
||||
avgB /= count;
|
||||
}
|
||||
|
||||
const idx = (py * imageData.width + px) * 4;
|
||||
imageData.data[idx] = clamp(
|
||||
Math.round(source.data[ci] + factor * (source.data[ci] - avgR)),
|
||||
0,
|
||||
255,
|
||||
);
|
||||
imageData.data[idx + 1] = clamp(
|
||||
Math.round(source.data[ci + 1] + factor * (source.data[ci + 1] - avgG)),
|
||||
0,
|
||||
255,
|
||||
);
|
||||
imageData.data[idx + 2] = clamp(
|
||||
Math.round(source.data[ci + 2] + factor * (source.data[ci + 2] - avgB)),
|
||||
0,
|
||||
255,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySmudge(
|
||||
imageData: ImageData,
|
||||
source: ImageData,
|
||||
startX: number,
|
||||
startY: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
sourceWidth: number,
|
||||
halfSize: number,
|
||||
strength: number,
|
||||
): void {
|
||||
// Smudge: blend current pixel with its neighbor in the direction of movement
|
||||
for (let py = 0; py < imageData.height; py++) {
|
||||
for (let px = 0; px < imageData.width; px++) {
|
||||
const dxC = startX + px - centerX;
|
||||
const dyC = startY + py - centerY;
|
||||
if (dxC * dxC + dyC * dyC > halfSize * halfSize) continue;
|
||||
|
||||
const sx = startX + px;
|
||||
const sy = startY + py;
|
||||
const ci = (sy * sourceWidth + sx) * 4;
|
||||
|
||||
// Blend with the pixel at previous position
|
||||
const prevX = sx - Math.sign(dxC || 1);
|
||||
const prevY = sy - Math.sign(dyC || 1);
|
||||
if (prevX >= 0 && prevX < sourceWidth && prevY >= 0) {
|
||||
const pi = (prevY * sourceWidth + prevX) * 4;
|
||||
if (pi >= 0 && pi + 2 < source.data.length) {
|
||||
const idx = (py * imageData.width + px) * 4;
|
||||
imageData.data[idx] = Math.round(
|
||||
source.data[ci] * (1 - strength) + source.data[pi] * strength,
|
||||
);
|
||||
imageData.data[idx + 1] = Math.round(
|
||||
source.data[ci + 1] * (1 - strength) + source.data[pi + 1] * strength,
|
||||
);
|
||||
imageData.data[idx + 2] = Math.round(
|
||||
source.data[ci + 2] * (1 - strength) + source.data[pi + 2] * strength,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// apps/web/src/components/editor/tools/shape-tool.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject, ToolType } from "@/types/editor";
|
||||
|
||||
interface DragState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
objectId: string;
|
||||
toolType: ToolType;
|
||||
}
|
||||
|
||||
const SHAPE_TOOLS = new Set<ToolType>([
|
||||
"shape-rect",
|
||||
"shape-ellipse",
|
||||
"shape-line",
|
||||
"shape-arrow",
|
||||
"shape-polygon",
|
||||
"shape-star",
|
||||
]);
|
||||
|
||||
function constrainToDimension(
|
||||
startX: number,
|
||||
startY: number,
|
||||
currentX: number,
|
||||
currentY: number,
|
||||
shiftHeld: boolean,
|
||||
): { w: number; h: number } {
|
||||
let w = currentX - startX;
|
||||
let h = currentY - startY;
|
||||
if (shiftHeld) {
|
||||
const size = Math.max(Math.abs(w), Math.abs(h));
|
||||
w = size * Math.sign(w || 1);
|
||||
h = size * Math.sign(h || 1);
|
||||
}
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
export function useShapeTool() {
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const {
|
||||
activeTool,
|
||||
shapeFill,
|
||||
shapeStroke,
|
||||
shapeStrokeWidth,
|
||||
shapeCornerRadius,
|
||||
shapePolygonSides,
|
||||
shapeStarPoints,
|
||||
activeLayerId,
|
||||
zoom,
|
||||
panOffset,
|
||||
} = useEditorStore.getState();
|
||||
|
||||
if (!SHAPE_TOOLS.has(activeTool)) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = (pointer.x - panOffset.x) / zoom;
|
||||
const y = (pointer.y - panOffset.y) / zoom;
|
||||
|
||||
const id = generateId();
|
||||
let obj: CanvasObject;
|
||||
|
||||
switch (activeTool) {
|
||||
case "shape-rect":
|
||||
obj = {
|
||||
id,
|
||||
type: "rect",
|
||||
layerId: activeLayerId,
|
||||
attrs: {
|
||||
x,
|
||||
y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fill: shapeFill,
|
||||
stroke: shapeStroke,
|
||||
strokeWidth: shapeStrokeWidth,
|
||||
cornerRadius: shapeCornerRadius,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "shape-ellipse":
|
||||
obj = {
|
||||
id,
|
||||
type: "ellipse",
|
||||
layerId: activeLayerId,
|
||||
attrs: {
|
||||
x,
|
||||
y,
|
||||
radiusX: 0,
|
||||
radiusY: 0,
|
||||
fill: shapeFill,
|
||||
stroke: shapeStroke,
|
||||
strokeWidth: shapeStrokeWidth,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "shape-line":
|
||||
obj = {
|
||||
id,
|
||||
type: "line",
|
||||
layerId: activeLayerId,
|
||||
attrs: {
|
||||
points: [x, y, x, y],
|
||||
stroke: shapeStroke,
|
||||
strokeWidth: shapeStrokeWidth,
|
||||
tension: 0,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
opacity: 1,
|
||||
globalCompositeOperation: "source-over",
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "shape-arrow":
|
||||
obj = {
|
||||
id,
|
||||
type: "arrow",
|
||||
layerId: activeLayerId,
|
||||
attrs: {
|
||||
points: [x, y, x, y],
|
||||
fill: shapeFill,
|
||||
stroke: shapeStroke,
|
||||
strokeWidth: shapeStrokeWidth,
|
||||
pointerLength: 15,
|
||||
pointerWidth: 12,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "shape-polygon":
|
||||
obj = {
|
||||
id,
|
||||
type: "polygon",
|
||||
layerId: activeLayerId,
|
||||
attrs: {
|
||||
x,
|
||||
y,
|
||||
sides: shapePolygonSides,
|
||||
radius: 0,
|
||||
fill: shapeFill,
|
||||
stroke: shapeStroke,
|
||||
strokeWidth: shapeStrokeWidth,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "shape-star":
|
||||
obj = {
|
||||
id,
|
||||
type: "star",
|
||||
layerId: activeLayerId,
|
||||
attrs: {
|
||||
x,
|
||||
y,
|
||||
numPoints: shapeStarPoints,
|
||||
innerRadius: 0,
|
||||
outerRadius: 0,
|
||||
fill: shapeFill,
|
||||
stroke: shapeStroke,
|
||||
strokeWidth: shapeStrokeWidth,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
useEditorStore.getState().addObject(obj);
|
||||
dragRef.current = { startX: x, startY: y, objectId: id, toolType: activeTool };
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (!dragRef.current) return;
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const { zoom, panOffset } = useEditorStore.getState();
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = (pointer.x - panOffset.x) / zoom;
|
||||
const y = (pointer.y - panOffset.y) / zoom;
|
||||
const { startX, startY, objectId, toolType } = dragRef.current;
|
||||
const shiftHeld = e.evt.shiftKey;
|
||||
|
||||
switch (toolType) {
|
||||
case "shape-rect": {
|
||||
const { w, h } = constrainToDimension(startX, startY, x, y, shiftHeld);
|
||||
const rx = w < 0 ? startX + w : startX;
|
||||
const ry = h < 0 ? startY + h : startY;
|
||||
useEditorStore.getState().updateObject(objectId, {
|
||||
x: rx,
|
||||
y: ry,
|
||||
width: Math.abs(w),
|
||||
height: Math.abs(h),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "shape-ellipse": {
|
||||
const { w, h } = constrainToDimension(startX, startY, x, y, shiftHeld);
|
||||
useEditorStore.getState().updateObject(objectId, {
|
||||
x: startX + w / 2,
|
||||
y: startY + h / 2,
|
||||
radiusX: Math.abs(w) / 2,
|
||||
radiusY: Math.abs(h) / 2,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "shape-line": {
|
||||
let endX = x;
|
||||
let endY = y;
|
||||
if (shiftHeld) {
|
||||
const dx = x - startX;
|
||||
const dy = y - startY;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
endX = startX + Math.cos(snapped) * dist;
|
||||
endY = startY + Math.sin(snapped) * dist;
|
||||
}
|
||||
useEditorStore.getState().updateObject(objectId, {
|
||||
points: [startX, startY, endX, endY],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "shape-arrow": {
|
||||
let endX = x;
|
||||
let endY = y;
|
||||
if (shiftHeld) {
|
||||
const dx = x - startX;
|
||||
const dy = y - startY;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
endX = startX + Math.cos(snapped) * dist;
|
||||
endY = startY + Math.sin(snapped) * dist;
|
||||
}
|
||||
useEditorStore.getState().updateObject(objectId, {
|
||||
points: [startX, startY, endX, endY],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "shape-polygon": {
|
||||
const dx = x - startX;
|
||||
const dy = y - startY;
|
||||
const radius = Math.sqrt(dx * dx + dy * dy);
|
||||
useEditorStore.getState().updateObject(objectId, { radius });
|
||||
break;
|
||||
}
|
||||
case "shape-star": {
|
||||
const dx = x - startX;
|
||||
const dy = y - startY;
|
||||
const outerRadius = Math.sqrt(dx * dx + dy * dy);
|
||||
useEditorStore.getState().updateObject(objectId, {
|
||||
outerRadius,
|
||||
innerRadius: outerRadius * 0.4,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!dragRef.current) return;
|
||||
|
||||
const { objectId } = dragRef.current;
|
||||
const objects = useEditorStore.getState().objects;
|
||||
const obj = objects.find((o) => o.id === objectId);
|
||||
|
||||
if (obj) {
|
||||
// Remove zero-size shapes
|
||||
const attrs = obj.attrs;
|
||||
let isDegenerate = false;
|
||||
if ("width" in attrs && "height" in attrs) {
|
||||
isDegenerate =
|
||||
(attrs as { width: number }).width < 1 && (attrs as { height: number }).height < 1;
|
||||
} else if ("radius" in attrs) {
|
||||
isDegenerate = (attrs as { radius: number }).radius < 1;
|
||||
} else if ("outerRadius" in attrs) {
|
||||
isDegenerate = (attrs as { outerRadius: number }).outerRadius < 1;
|
||||
}
|
||||
|
||||
if (isDegenerate) {
|
||||
useEditorStore.getState().removeObjects([objectId]);
|
||||
} else {
|
||||
useEditorStore.getState().setSelectedObjects([objectId]);
|
||||
}
|
||||
}
|
||||
|
||||
dragRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp };
|
||||
}
|
||||
Reference in New Issue
Block a user