diff --git a/apps/web/src/components/editor/common/fill-dialog.tsx b/apps/web/src/components/editor/common/fill-dialog.tsx new file mode 100644 index 00000000..bf66d3a4 --- /dev/null +++ b/apps/web/src/components/editor/common/fill-dialog.tsx @@ -0,0 +1,217 @@ +// apps/web/src/components/editor/common/fill-dialog.tsx + +import { useCallback, useEffect, useState } from "react"; +import { cn, generateId } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { CanvasObject } from "@/types/editor"; + +type FillContent = "foreground" | "background" | "color" | "white" | "black" | "50gray"; + +interface FillDialogProps { + open: boolean; + onClose: () => void; +} + +const FILL_PRESETS: { value: FillContent; label: string }[] = [ + { value: "foreground", label: "Foreground Color" }, + { value: "background", label: "Background Color" }, + { value: "color", label: "Color..." }, + { value: "white", label: "White" }, + { value: "black", label: "Black" }, + { value: "50gray", label: "50% Gray" }, +]; + +function resolveColor( + content: FillContent, + customColor: string, + foreground: string, + background: string, +): string { + switch (content) { + case "foreground": + return foreground; + case "background": + return background; + case "color": + return customColor; + case "white": + return "#ffffff"; + case "black": + return "#000000"; + case "50gray": + return "#808080"; + } +} + +export function FillDialog({ open, onClose }: FillDialogProps) { + const [content, setContent] = useState("foreground"); + const [customColor, setCustomColor] = useState("#ff0000"); + const [opacity, setOpacity] = useState(100); + + const foregroundColor = useEditorStore((s) => s.foregroundColor); + const backgroundColor = useEditorStore((s) => s.backgroundColor); + const canvasSize = useEditorStore((s) => s.canvasSize); + const activeLayerId = useEditorStore((s) => s.activeLayerId); + const addObject = useEditorStore((s) => s.addObject); + + // Close on Escape + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + const handleFill = useCallback(() => { + const fillColor = resolveColor(content, customColor, foregroundColor, backgroundColor); + + // Create a canvas with the solid fill + const canvas = document.createElement("canvas"); + canvas.width = canvasSize.width; + canvas.height = canvasSize.height; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.globalAlpha = opacity / 100; + ctx.fillStyle = fillColor; + ctx.fillRect(0, 0, canvasSize.width, canvasSize.height); + + const dataUrl = canvas.toDataURL(); + + const obj: CanvasObject = { + id: generateId(), + type: "image", + layerId: activeLayerId, + attrs: { + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + rotation: 0, + opacity: 1, + src: dataUrl, + }, + }; + + addObject(obj); + onClose(); + }, [ + content, + customColor, + foregroundColor, + backgroundColor, + canvasSize, + activeLayerId, + opacity, + addObject, + onClose, + ]); + + if (!open) return null; + + return ( +
+
+

Fill

+ + {/* Content selector */} +
+ Contents + +
+ + {/* Custom color picker (only when "color" selected) */} + {content === "color" && ( +
+ Custom Color + setCustomColor(e.target.value)} + className="w-full h-8 border border-border rounded cursor-pointer" + /> +
+ )} + + {/* Opacity */} +
+ Opacity +
+ setOpacity(Number(e.target.value))} + className="flex-1 h-1 accent-primary" + /> + setOpacity(Number(e.target.value))} + className="w-14 h-7 text-xs text-center bg-muted border border-border rounded px-1" + /> + % +
+
+ + {/* Color preview */} +
+ Preview: +
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/options/brush-options.tsx b/apps/web/src/components/editor/options/brush-options.tsx new file mode 100644 index 00000000..c0c968bc --- /dev/null +++ b/apps/web/src/components/editor/options/brush-options.tsx @@ -0,0 +1,89 @@ +// apps/web/src/components/editor/options/brush-options.tsx + +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +const BRUSH_OPTION_TOOLS = new Set(["brush", "eraser", "pencil"]); + +export function BrushOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const brushSize = useEditorStore((s) => s.brushSize); + const brushOpacity = useEditorStore((s) => s.brushOpacity); + const brushHardness = useEditorStore((s) => s.brushHardness); + const setBrushSize = useEditorStore((s) => s.setBrushSize); + const setBrushOpacity = useEditorStore((s) => s.setBrushOpacity); + const setBrushHardness = useEditorStore((s) => s.setBrushHardness); + + if (!BRUSH_OPTION_TOOLS.has(activeTool)) return null; + + return ( +
+ {/* Size */} + + + {/* Opacity */} + + + {/* Hardness (not for pencil -- pencil is always hard) */} + {activeTool !== "pencil" && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/editor/options/clone-stamp-options.tsx b/apps/web/src/components/editor/options/clone-stamp-options.tsx new file mode 100644 index 00000000..808a7534 --- /dev/null +++ b/apps/web/src/components/editor/options/clone-stamp-options.tsx @@ -0,0 +1,105 @@ +// apps/web/src/components/editor/options/clone-stamp-options.tsx + +import { useCallback, useState } from "react"; +import { useEditorStore } from "@/stores/editor-store"; +import { getCloneAligned, setCloneAligned } from "../tools/clone-stamp-tool"; + +export function CloneStampOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const brushSize = useEditorStore((s) => s.brushSize); + const brushOpacity = useEditorStore((s) => s.brushOpacity); + const brushHardness = useEditorStore((s) => s.brushHardness); + const setBrushSize = useEditorStore((s) => s.setBrushSize); + const setBrushOpacity = useEditorStore((s) => s.setBrushOpacity); + const setBrushHardness = useEditorStore((s) => s.setBrushHardness); + const [aligned, setLocalAligned] = useState(getCloneAligned); + + const handleAlignedChange = useCallback((value: boolean) => { + setCloneAligned(value); + setLocalAligned(value); + }, []); + + if (activeTool !== "clone-stamp") return null; + + return ( +
+ {/* Size */} + + + {/* Opacity */} + + + {/* Hardness */} + + + {/* Aligned */} + + + Alt+Click to set source +
+ ); +} diff --git a/apps/web/src/components/editor/options/dodge-burn-options.tsx b/apps/web/src/components/editor/options/dodge-burn-options.tsx new file mode 100644 index 00000000..74b1182a --- /dev/null +++ b/apps/web/src/components/editor/options/dodge-burn-options.tsx @@ -0,0 +1,163 @@ +// apps/web/src/components/editor/options/dodge-burn-options.tsx + +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +const DODGE_BURN_TOOLS = new Set(["dodge", "burn", "sponge"]); + +export function DodgeBurnOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const brushSize = useEditorStore((s) => s.brushSize); + const setBrushSize = useEditorStore((s) => s.setBrushSize); + const dodgeBurnRange = useEditorStore((s) => s.dodgeBurnRange); + const dodgeBurnExposure = useEditorStore((s) => s.dodgeBurnExposure); + const spongeMode = useEditorStore((s) => s.spongeMode); + const spongeFlow = useEditorStore((s) => s.spongeFlow); + + if (!DODGE_BURN_TOOLS.has(activeTool)) return null; + + const isDodgeBurn = activeTool === "dodge" || activeTool === "burn"; + + return ( +
+ {/* Tool toggle */} + + + {/* Size */} + + + {/* Range (dodge/burn only) */} + {isDodgeBurn && ( + + )} + + {/* Exposure (dodge/burn only) */} + {isDodgeBurn && ( + + )} + + {/* Sponge mode */} + {activeTool === "sponge" && ( + + )} + + {/* Flow (sponge only) */} + {activeTool === "sponge" && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/editor/options/fill-options.tsx b/apps/web/src/components/editor/options/fill-options.tsx new file mode 100644 index 00000000..d00b4296 --- /dev/null +++ b/apps/web/src/components/editor/options/fill-options.tsx @@ -0,0 +1,64 @@ +// apps/web/src/components/editor/options/fill-options.tsx + +import { useCallback, useState } from "react"; +import { useEditorStore } from "@/stores/editor-store"; +import { + getFillContiguous, + getFillTolerance, + setFillContiguous, + setFillTolerance, +} from "../tools/fill-tool"; + +export function FillOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const [tolerance, setLocalTolerance] = useState(getFillTolerance); + const [contiguous, setLocalContiguous] = useState(getFillContiguous); + + const handleToleranceChange = useCallback((value: number) => { + setFillTolerance(value); + setLocalTolerance(value); + }, []); + + const handleContiguousChange = useCallback((value: boolean) => { + setFillContiguous(value); + setLocalContiguous(value); + }, []); + + if (activeTool !== "fill") return null; + + return ( +
+ {/* Tolerance */} + + + {/* Contiguous */} + +
+ ); +} diff --git a/apps/web/src/components/editor/options/gradient-options.tsx b/apps/web/src/components/editor/options/gradient-options.tsx new file mode 100644 index 00000000..b156d9eb --- /dev/null +++ b/apps/web/src/components/editor/options/gradient-options.tsx @@ -0,0 +1,87 @@ +// apps/web/src/components/editor/options/gradient-options.tsx + +import { useCallback, useState } from "react"; +import { useEditorStore } from "@/stores/editor-store"; +import { + type GradientType, + getGradientOpacity, + getGradientReverse, + getGradientType, + setGradientOpacity, + setGradientReverse, + setGradientType, +} from "../tools/gradient-tool"; + +export function GradientOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const [type, setLocalType] = useState(getGradientType); + const [opacity, setLocalOpacity] = useState(() => Math.round(getGradientOpacity() * 100)); + const [reverse, setLocalReverse] = useState(getGradientReverse); + + const handleTypeChange = useCallback((value: GradientType) => { + setGradientType(value); + setLocalType(value); + }, []); + + const handleOpacityChange = useCallback((value: number) => { + setGradientOpacity(value / 100); + setLocalOpacity(value); + }, []); + + const handleReverseChange = useCallback((value: boolean) => { + setGradientReverse(value); + setLocalReverse(value); + }, []); + + if (activeTool !== "gradient") return null; + + return ( +
+ {/* Type toggle */} + + + {/* Opacity */} + + + {/* Reverse */} + +
+ ); +} diff --git a/apps/web/src/components/editor/options/pixel-brush-options.tsx b/apps/web/src/components/editor/options/pixel-brush-options.tsx new file mode 100644 index 00000000..961b2215 --- /dev/null +++ b/apps/web/src/components/editor/options/pixel-brush-options.tsx @@ -0,0 +1,84 @@ +// apps/web/src/components/editor/options/pixel-brush-options.tsx + +import { useCallback, useState } from "react"; +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; +import { getPixelBrushStrength, setPixelBrushStrength } from "../tools/pixel-brush-tool"; + +const PIXEL_BRUSH_TOOLS = new Set(["blur-brush", "sharpen-brush", "smudge"]); + +export function PixelBrushOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const brushSize = useEditorStore((s) => s.brushSize); + const setBrushSize = useEditorStore((s) => s.setBrushSize); + const [strength, setLocalStrength] = useState(getPixelBrushStrength); + + const handleStrengthChange = useCallback((value: number) => { + setPixelBrushStrength(value); + setLocalStrength(value); + }, []); + + if (!PIXEL_BRUSH_TOOLS.has(activeTool)) return null; + + return ( +
+ {/* Tool toggle */} + + + {/* Size */} + + + {/* Strength */} + +
+ ); +} diff --git a/apps/web/src/components/editor/options/shape-options.tsx b/apps/web/src/components/editor/options/shape-options.tsx new file mode 100644 index 00000000..6ade30d8 --- /dev/null +++ b/apps/web/src/components/editor/options/shape-options.tsx @@ -0,0 +1,175 @@ +// apps/web/src/components/editor/options/shape-options.tsx + +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +const SHAPE_TOOLS = new Set([ + "shape-rect", + "shape-ellipse", + "shape-line", + "shape-arrow", + "shape-polygon", + "shape-star", +]); + +const SHAPE_TYPE_OPTIONS: { value: ToolType; label: string }[] = [ + { value: "shape-rect", label: "Rectangle" }, + { value: "shape-ellipse", label: "Ellipse" }, + { value: "shape-line", label: "Line" }, + { value: "shape-arrow", label: "Arrow" }, + { value: "shape-polygon", label: "Polygon" }, + { value: "shape-star", label: "Star" }, +]; + +export function ShapeOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const shapeFill = useEditorStore((s) => s.shapeFill); + const shapeStroke = useEditorStore((s) => s.shapeStroke); + const shapeStrokeWidth = useEditorStore((s) => s.shapeStrokeWidth); + const shapeCornerRadius = useEditorStore((s) => s.shapeCornerRadius); + const shapePolygonSides = useEditorStore((s) => s.shapePolygonSides); + const shapeStarPoints = useEditorStore((s) => s.shapeStarPoints); + + if (!SHAPE_TOOLS.has(activeTool)) return null; + + return ( +
+ {/* Shape type selector */} + + + {/* Fill color */} + + + {/* Stroke color */} + + + {/* Stroke width */} + + + {/* Corner radius (only for rect) */} + {activeTool === "shape-rect" && ( + + )} + + {/* Polygon sides */} + {activeTool === "shape-polygon" && ( + + )} + + {/* Star points */} + {activeTool === "shape-star" && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/editor/tools/brush-tool.tsx b/apps/web/src/components/editor/tools/brush-tool.tsx new file mode 100644 index 00000000..a5879db3 --- /dev/null +++ b/apps/web/src/components/editor/tools/brush-tool.tsx @@ -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(null); + + const handleMouseDown = useCallback((e: Konva.KonvaEventObject) => { + 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) => { + 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 }; +} diff --git a/apps/web/src/components/editor/tools/clone-stamp-tool.tsx b/apps/web/src/components/editor/tools/clone-stamp-tool.tsx new file mode 100644 index 00000000..0432426f --- /dev/null +++ b/apps/web/src/components/editor/tools/clone-stamp-tool.tsx @@ -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) { + const stampRef = useRef(null); + const initialOffsetRef = useRef<{ x: number; y: number } | null>(null); + + const handleMouseDown = useCallback( + (e: Konva.KonvaEventObject) => { + 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) => { + 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; +} diff --git a/apps/web/src/components/editor/tools/dodge-burn-tool.tsx b/apps/web/src/components/editor/tools/dodge-burn-tool.tsx new file mode 100644 index 00000000..e6191d8c --- /dev/null +++ b/apps/web/src/components/editor/tools/dodge-burn-tool.tsx @@ -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(["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) { + const strokeRef = useRef(null); + + const handleMouseDown = useCallback( + (_e: Konva.KonvaEventObject) => { + 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) => { + 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); +} diff --git a/apps/web/src/components/editor/tools/eraser-tool.tsx b/apps/web/src/components/editor/tools/eraser-tool.tsx new file mode 100644 index 00000000..9cee823d --- /dev/null +++ b/apps/web/src/components/editor/tools/eraser-tool.tsx @@ -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(null); + + const handleMouseDown = useCallback((e: Konva.KonvaEventObject) => { + 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) => { + 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 }; +} diff --git a/apps/web/src/components/editor/tools/fill-tool.tsx b/apps/web/src/components/editor/tools/fill-tool.tsx new file mode 100644 index 00000000..feb17493 --- /dev/null +++ b/apps/web/src/components/editor/tools/fill-tool.tsx @@ -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) { + const handleMouseDown = useCallback( + (_e: Konva.KonvaEventObject) => { + 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) => { + // No-op: fill is a single-click operation + }, []); + + const handleMouseUp = useCallback(() => { + // No-op + }, []); + + return { handleMouseDown, handleMouseMove, handleMouseUp }; +} diff --git a/apps/web/src/components/editor/tools/gradient-tool.tsx b/apps/web/src/components/editor/tools/gradient-tool.tsx new file mode 100644 index 00000000..ae41eecd --- /dev/null +++ b/apps/web/src/components/editor/tools/gradient-tool.tsx @@ -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(null); + + const handleMouseDown = useCallback((e: Konva.KonvaEventObject) => { + 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) => { + // Could show a preview line/circle here; keeping simple for now + }, []); + + const handleMouseUp = useCallback((e: Konva.KonvaEventObject) => { + 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 }; +} diff --git a/apps/web/src/components/editor/tools/pixel-brush-tool.tsx b/apps/web/src/components/editor/tools/pixel-brush-tool.tsx new file mode 100644 index 00000000..68e0c9b3 --- /dev/null +++ b/apps/web/src/components/editor/tools/pixel-brush-tool.tsx @@ -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(["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) { + const strokeRef = useRef(null); + + const handleMouseDown = useCallback( + (_e: Konva.KonvaEventObject) => { + 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) => { + 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, + ); + } + } + } + } +} diff --git a/apps/web/src/components/editor/tools/shape-tool.tsx b/apps/web/src/components/editor/tools/shape-tool.tsx new file mode 100644 index 00000000..5bd68d85 --- /dev/null +++ b/apps/web/src/components/editor/tools/shape-tool.tsx @@ -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([ + "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(null); + + const handleMouseDown = useCallback((e: Konva.KonvaEventObject) => { + 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) => { + 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 }; +}