From e99bae428a30410bcf36539cc67df8e68515056f Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Thu, 7 May 2026 23:47:08 +0800 Subject: [PATCH] fix: resolve 50+ bugs across editor store, canvas, panels, and tools - Store: loadImage resets state, applyCrop updates sourceImageSize, setTool clears stale cropState, layer ordering preserved across layers, rotateCanvas/flipCanvas account for object dimensions, trimCanvas implemented, resizeCanvas supports anchor positioning - Canvas: crop overlay interactive, move tool drag events wired, hand tool panning, stage ref effect mount-only, checkerboard tracks pan offset, filter cache clearing - Panels: hslToHex color fix for hue 240-360, adjustments single setAdjustment call, curves draggingIndex after sort, history panel fresh temporal reads, export transparent setting, canvas resize dialog re-sync, hex input respects picker target - Tools: text tool fixed positioning and event cleanup, shape tool deferred addObject, selection mode wired to store, magic wand tolerance controlled, eyedropper coordinate transform - Options: dodge-burn/shape/clone/fill/gradient/pixel-brush use proper store actions instead of raw setState - Added 20+ store actions for tool settings - Updated tests for corrected rotation/flip/trim behavior - Excluded e2e-editor from vitest config --- apps/web/src/App.tsx | 10 +- .../editor/common/canvas-resize-dialog.tsx | 13 +- .../components/editor/common/color-swatch.tsx | 7 +- .../components/editor/common/context-menu.tsx | 8 +- .../editor/common/export-dialog.tsx | 50 +++- .../editor/common/image-resize-dialog.tsx | 4 +- .../editor/common/new-document-dialog.tsx | 12 +- .../src/components/editor/editor-canvas.tsx | 67 ++++- .../components/editor/editor-options-bar.tsx | 7 +- .../components/editor/editor-status-bar.tsx | 5 +- .../editor/options/clone-stamp-options.tsx | 14 +- .../editor/options/dodge-burn-options.tsx | 38 +-- .../editor/options/fill-options.tsx | 29 +- .../editor/options/gradient-options.tsx | 52 ++-- .../editor/options/move-options.tsx | 4 +- .../editor/options/pixel-brush-options.tsx | 14 +- .../editor/options/selection-options.tsx | 22 +- .../editor/options/shape-options.tsx | 46 +-- .../editor/options/text-options.tsx | 31 +- .../editor/panels/adjustments-panel.tsx | 44 ++- .../components/editor/panels/color-panel.tsx | 26 +- .../editor/panels/history-panel.tsx | 15 +- .../editor/panels/navigator-panel.tsx | 12 +- .../editor/tools/clone-stamp-tool.tsx | 29 +- .../src/components/editor/tools/crop-tool.tsx | 2 +- .../editor/tools/eyedropper-tool.tsx | 10 +- .../src/components/editor/tools/fill-tool.tsx | 31 +- .../components/editor/tools/gradient-tool.tsx | 42 +-- .../src/components/editor/tools/move-tool.tsx | 31 +- .../editor/tools/pixel-brush-tool.tsx | 12 +- .../editor/tools/selection-tool.tsx | 5 +- .../components/editor/tools/shape-tool.tsx | 44 ++- .../src/components/editor/tools/text-tool.tsx | 51 ++-- apps/web/src/hooks/use-canvas-zoom.ts | 7 +- apps/web/src/pages/editor-page.tsx | 11 +- apps/web/src/stores/editor-store.ts | 272 +++++++++++++++--- apps/web/src/types/editor.ts | 56 +++- tests/unit/web/editor-store.test.ts | 29 +- vitest.config.ts | 1 + 39 files changed, 772 insertions(+), 391 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0e834fd0..5cf5da3d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route import { Toaster } from "sonner"; import { ConnectionMonitor } from "./components/common/connection-monitor"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; +import { AppLayout } from "./components/layout/app-layout"; import { useAuth } from "./hooks/use-auth"; import { identify, initAnalytics } from "./lib/analytics"; import { useAnalyticsStore } from "./stores/analytics-store"; @@ -225,7 +226,14 @@ export function App() { } /> } /> } /> - } /> + + + + } + /> } /> } /> diff --git a/apps/web/src/components/editor/common/canvas-resize-dialog.tsx b/apps/web/src/components/editor/common/canvas-resize-dialog.tsx index 07958b89..5d1b64c9 100644 --- a/apps/web/src/components/editor/common/canvas-resize-dialog.tsx +++ b/apps/web/src/components/editor/common/canvas-resize-dialog.tsx @@ -1,5 +1,5 @@ import { X } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; import type { AnchorPosition } from "@/types/editor"; @@ -29,10 +29,17 @@ export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: const [anchor, setAnchor] = useState("center"); const [fill, setFill] = useState("#ffffff"); + useEffect(() => { + if (open) { + setWidth(canvasSize.width); + setHeight(canvasSize.height); + } + }, [open, canvasSize]); + const handleApply = useCallback(() => { - resizeCanvas(width, height, anchor); + resizeCanvas(width, height, anchor, fill); onClose(); - }, [width, height, anchor, resizeCanvas, onClose]); + }, [width, height, anchor, fill, resizeCanvas, onClose]); if (!open) return null; diff --git a/apps/web/src/components/editor/common/color-swatch.tsx b/apps/web/src/components/editor/common/color-swatch.tsx index 1042bb80..f5b90fa9 100644 --- a/apps/web/src/components/editor/common/color-swatch.tsx +++ b/apps/web/src/components/editor/common/color-swatch.tsx @@ -35,7 +35,12 @@ export function ColorSwatch({ label, ...dataProps }: ColorSwatchProps) { - const isTransparent = color === "transparent" || color.length === 9; + const isTransparent = + color.length === 9 || + color.length === 5 || + color.toLowerCase().includes("rgba") || + color.toLowerCase().includes("hsla") || + color === "transparent"; return ( - {pastStates.length} / 50 + + {useEditorStore.temporal.getState().pastStates.length} / 50 + {/* History list */} diff --git a/apps/web/src/components/editor/panels/navigator-panel.tsx b/apps/web/src/components/editor/panels/navigator-panel.tsx index 91b1876b..0426229a 100644 --- a/apps/web/src/components/editor/panels/navigator-panel.tsx +++ b/apps/web/src/components/editor/panels/navigator-panel.tsx @@ -196,8 +196,8 @@ export function NavigatorPanel() { const handleZoomSlider = useCallback( (e: React.ChangeEvent) => { - const val = Number.parseFloat(e.target.value); - setZoom(val); + const t = Number.parseFloat(e.target.value); + setZoom(MIN_ZOOM * (MAX_ZOOM / MIN_ZOOM) ** t); }, [setZoom], ); @@ -252,10 +252,10 @@ export function NavigatorPanel() { ) { const stampRef = useRef(null); const initialOffsetRef = useRef<{ x: number; y: number } | null>(null); + const setCloneSource = useEditorStore((s) => s.setCloneSource); const handleMouseDown = useCallback( (e: Konva.KonvaEventObject) => { - const { activeTool, cloneSource, brushSize, brushOpacity, canvasSize, zoom, panOffset } = - useEditorStore.getState(); + const { + activeTool, + cloneSource, + cloneAligned, + brushSize, + brushOpacity, + canvasSize, + zoom, + panOffset, + } = useEditorStore.getState(); if (activeTool !== "clone-stamp") return; @@ -49,9 +48,7 @@ export function useCloneStampTool(stageRef: React.RefObject) // Alt+click sets the clone source if (e.evt.altKey) { - useEditorStore.setState({ - cloneSource: { x, y, aligned: cloneAligned }, - }); + setCloneSource({ x, y, aligned: cloneAligned }); initialOffsetRef.current = null; return; } @@ -127,7 +124,7 @@ export function useCloneStampTool(stageRef: React.RefObject) offsetY, }; }, - [stageRef], + [stageRef, setCloneSource], ); const handleMouseMove = useCallback((e: Konva.KonvaEventObject) => { diff --git a/apps/web/src/components/editor/tools/crop-tool.tsx b/apps/web/src/components/editor/tools/crop-tool.tsx index 0026646c..57f45707 100644 --- a/apps/web/src/components/editor/tools/crop-tool.tsx +++ b/apps/web/src/components/editor/tools/crop-tool.tsx @@ -188,7 +188,7 @@ export function CropOverlay() { }; return ( - + {/* Darkened overlays: top, bottom, left, right */} s.setForegroundColor); const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor); + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); const [sampledColor, setSampledColor] = useState(null); const canvasCache = useRef(null); @@ -96,9 +98,9 @@ export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOpt const ctx = canvas.getContext("2d"); if (!ctx) return null; - // pointer coordinates are already in stage pixel space - const x = Math.round(pointer.x); - const y = Math.round(pointer.y); + // Transform pointer coordinates from screen space to canvas space + const x = Math.round((pointer.x - panOffset.x) / zoom); + const y = Math.round((pointer.y - panOffset.y) / zoom); // Bounds check if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) { @@ -109,7 +111,7 @@ export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOpt setSampledColor(color); return color; }, - [getStageCanvas, sampleSize], + [getStageCanvas, sampleSize, zoom, panOffset], ); /** diff --git a/apps/web/src/components/editor/tools/fill-tool.tsx b/apps/web/src/components/editor/tools/fill-tool.tsx index feb17493..544681f6 100644 --- a/apps/web/src/components/editor/tools/fill-tool.tsx +++ b/apps/web/src/components/editor/tools/fill-tool.tsx @@ -6,26 +6,6 @@ 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, @@ -157,8 +137,15 @@ function floodFill( export function useFillTool(stageRef: React.RefObject) { const handleMouseDown = useCallback( (_e: Konva.KonvaEventObject) => { - const { activeTool, foregroundColor, canvasSize, zoom, panOffset } = - useEditorStore.getState(); + const { + activeTool, + foregroundColor, + fillTolerance, + fillContiguous, + canvasSize, + zoom, + panOffset, + } = useEditorStore.getState(); if (activeTool !== "fill") return; diff --git a/apps/web/src/components/editor/tools/gradient-tool.tsx b/apps/web/src/components/editor/tools/gradient-tool.tsx index ae41eecd..16f7bfa4 100644 --- a/apps/web/src/components/editor/tools/gradient-tool.tsx +++ b/apps/web/src/components/editor/tools/gradient-tool.tsx @@ -6,36 +6,6 @@ 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; @@ -68,8 +38,16 @@ export function useGradientTool() { const handleMouseUp = useCallback((e: Konva.KonvaEventObject) => { if (!dragRef.current) return; - const { foregroundColor, backgroundColor, canvasSize, zoom, panOffset } = - useEditorStore.getState(); + const { + foregroundColor, + backgroundColor, + gradientType, + gradientOpacity, + gradientReverse, + canvasSize, + zoom, + panOffset, + } = useEditorStore.getState(); const stage = e.target.getStage(); if (!stage) return; diff --git a/apps/web/src/components/editor/tools/move-tool.tsx b/apps/web/src/components/editor/tools/move-tool.tsx index ecd3112a..523ee057 100644 --- a/apps/web/src/components/editor/tools/move-tool.tsx +++ b/apps/web/src/components/editor/tools/move-tool.tsx @@ -157,9 +157,10 @@ export function alignObjects( objectIds: string[], objects: { id: string; attrs: Record }[], updateObject: (id: string, attrs: Record) => void, + canvasSize?: { width: number; height: number }, ): void { const selected = objects.filter((o) => objectIds.includes(o.id)); - if (selected.length < 2 && !direction.startsWith("distribute")) return; + if (selected.length === 0) return; if (selected.length < 3 && direction.startsWith("distribute")) return; const bounds = selected.map((o) => ({ @@ -170,6 +171,34 @@ export function alignObjects( h: (o.attrs.height as number) ?? 0, })); + // Single object: align relative to canvas bounds + if (selected.length === 1 && canvasSize) { + const b = bounds[0]; + switch (direction) { + case "left": + updateObject(b.id, { x: 0 }); + break; + case "center-h": + updateObject(b.id, { x: canvasSize.width / 2 - b.w / 2 }); + break; + case "right": + updateObject(b.id, { x: canvasSize.width - b.w }); + break; + case "top": + updateObject(b.id, { y: 0 }); + break; + case "center-v": + updateObject(b.id, { y: canvasSize.height / 2 - b.h / 2 }); + break; + case "bottom": + updateObject(b.id, { y: canvasSize.height - b.h }); + break; + } + return; + } + + if (selected.length < 2) return; + switch (direction) { case "left": { const minX = Math.min(...bounds.map((b) => b.x)); diff --git a/apps/web/src/components/editor/tools/pixel-brush-tool.tsx b/apps/web/src/components/editor/tools/pixel-brush-tool.tsx index 68e0c9b3..ee0e871e 100644 --- a/apps/web/src/components/editor/tools/pixel-brush-tool.tsx +++ b/apps/web/src/components/editor/tools/pixel-brush-tool.tsx @@ -8,16 +8,6 @@ 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; @@ -141,7 +131,7 @@ function applyPixelBrush( centerY: number, canvasSize: { width: number; height: number }, ): void { - const { activeTool, brushSize } = useEditorStore.getState(); + const { activeTool, brushSize, pixelBrushStrength } = useEditorStore.getState(); const strength = pixelBrushStrength / 100; const halfSize = Math.floor(brushSize / 2); diff --git a/apps/web/src/components/editor/tools/selection-tool.tsx b/apps/web/src/components/editor/tools/selection-tool.tsx index cbf2806a..0e5b8de7 100644 --- a/apps/web/src/components/editor/tools/selection-tool.tsx +++ b/apps/web/src/components/editor/tools/selection-tool.tsx @@ -2,9 +2,8 @@ import type Konva from "konva"; import { useCallback, useEffect, useRef, useState } from "react"; import { Ellipse, Group, Line, Rect } from "react-konva"; import { useEditorStore } from "@/stores/editor-store"; -import type { SelectionState } from "@/types/editor"; +import type { SelectionMode, SelectionState } from "@/types/editor"; -type SelectionMode = "new" | "add" | "subtract"; type SelectionType = "rect" | "ellipse" | "lasso"; // --------------------------------------------------------------------------- @@ -240,7 +239,7 @@ export function useSelectionTool(): SelectionToolApi { const [currentPoints, setCurrentPoints] = useState([]); const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); - const [selectionMode] = useState("new"); + const selectionMode = useEditorStore((s) => s.selectionMode); const setSelection = useEditorStore((s) => s.setSelection); const canvasSize = useEditorStore((s) => s.canvasSize); diff --git a/apps/web/src/components/editor/tools/shape-tool.tsx b/apps/web/src/components/editor/tools/shape-tool.tsx index 5bd68d85..1786b051 100644 --- a/apps/web/src/components/editor/tools/shape-tool.tsx +++ b/apps/web/src/components/editor/tools/shape-tool.tsx @@ -6,6 +6,13 @@ import { generateId } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; import type { CanvasObject, ToolType } from "@/types/editor"; +interface PendingShape { + startX: number; + startY: number; + toolType: ToolType; + obj: CanvasObject; +} + interface DragState { startX: number; startY: number; @@ -41,6 +48,9 @@ function constrainToDimension( export function useShapeTool() { const dragRef = useRef(null); + const pendingRef = useRef(null); + + const MIN_DRAG_THRESHOLD = 2; const handleMouseDown = useCallback((e: Konva.KonvaEventObject) => { const stage = e.target.getStage(); @@ -183,11 +193,34 @@ export function useShapeTool() { return; } - useEditorStore.getState().addObject(obj); - dragRef.current = { startX: x, startY: y, objectId: id, toolType: activeTool }; + // Don't add the object yet -- wait until the user drags past the threshold + pendingRef.current = { startX: x, startY: y, toolType: activeTool, obj }; }, []); const handleMouseMove = useCallback((e: Konva.KonvaEventObject) => { + // If we have a pending shape but haven't committed it yet, check threshold + if (pendingRef.current && !dragRef.current) { + 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 dx = x - pendingRef.current.startX; + const dy = y - pendingRef.current.startY; + + if (Math.abs(dx) < MIN_DRAG_THRESHOLD && Math.abs(dy) < MIN_DRAG_THRESHOLD) return; + + // Threshold exceeded -- add the object to the store and promote to active drag + const { obj, startX, startY, toolType } = pendingRef.current; + useEditorStore.getState().addObject(obj); + dragRef.current = { startX, startY, objectId: obj.id, toolType }; + pendingRef.current = null; + } + if (!dragRef.current) return; const stage = e.target.getStage(); @@ -280,6 +313,11 @@ export function useShapeTool() { }, []); const handleMouseUp = useCallback(() => { + // Click without drag -- pending shape was never added, just discard it + if (pendingRef.current) { + pendingRef.current = null; + } + if (!dragRef.current) return; const { objectId } = dragRef.current; @@ -287,7 +325,7 @@ export function useShapeTool() { const obj = objects.find((o) => o.id === objectId); if (obj) { - // Remove zero-size shapes + // Remove degenerate shapes that are still too small const attrs = obj.attrs; let isDegenerate = false; if ("width" in attrs && "height" in attrs) { diff --git a/apps/web/src/components/editor/tools/text-tool.tsx b/apps/web/src/components/editor/tools/text-tool.tsx index 72e1f2c8..900d3e5b 100644 --- a/apps/web/src/components/editor/tools/text-tool.tsx +++ b/apps/web/src/components/editor/tools/text-tool.tsx @@ -14,6 +14,7 @@ import type { CanvasObject, TextAttrs } from "@/types/editor"; */ export function useTextTool() { const textareaRef = useRef(null); + const abortRef = useRef(null); const pendingRef = useRef<{ objectId: string; x: number; @@ -23,6 +24,8 @@ export function useTextTool() { // Clean up any lingering textarea on unmount useEffect(() => { return () => { + abortRef.current?.abort(); + abortRef.current = null; textareaRef.current?.remove(); textareaRef.current = null; }; @@ -34,6 +37,8 @@ export function useTextTool() { if (!textarea || !pending) return; const text = textarea.value.trim(); + abortRef.current?.abort(); + abortRef.current = null; textarea.remove(); textareaRef.current = null; pendingRef.current = null; @@ -114,7 +119,7 @@ export function useTextTool() { const containerRect = container.getBoundingClientRect(); const textarea = document.createElement("textarea"); - textarea.style.position = "absolute"; + textarea.style.position = "fixed"; textarea.style.left = `${pointer.x + containerRect.left}px`; textarea.style.top = `${pointer.y + containerRect.top}px`; textarea.style.fontSize = `${fontSize * zoom}px`; @@ -138,6 +143,10 @@ export function useTextTool() { document.body.appendChild(textarea); textareaRef.current = textarea; + const ac = new AbortController(); + abortRef.current = ac; + const { signal } = ac; + // Auto-resize as user types const autoResize = () => { textarea.style.height = "auto"; @@ -146,24 +155,32 @@ export function useTextTool() { textarea.style.width = `${Math.max(60, textarea.scrollWidth + 4)}px`; }; - textarea.addEventListener("input", autoResize); + textarea.addEventListener("input", autoResize, { signal }); - textarea.addEventListener("blur", () => { - commitText(); - }); + textarea.addEventListener( + "blur", + () => { + commitText(); + }, + { signal }, + ); - textarea.addEventListener("keydown", (ke) => { - // Enter without Shift commits; Shift+Enter inserts newline - if (ke.key === "Enter" && !ke.shiftKey) { - ke.preventDefault(); - textarea.blur(); - } - if (ke.key === "Escape") { - ke.preventDefault(); - textarea.value = ""; - textarea.blur(); - } - }); + textarea.addEventListener( + "keydown", + (ke) => { + // Enter without Shift commits; Shift+Enter inserts newline + if (ke.key === "Enter" && !ke.shiftKey) { + ke.preventDefault(); + textarea.blur(); + } + if (ke.key === "Escape") { + ke.preventDefault(); + textarea.value = ""; + textarea.blur(); + } + }, + { signal }, + ); // Focus after a microtask so the click doesn't immediately blur requestAnimationFrame(() => textarea.focus()); diff --git a/apps/web/src/hooks/use-canvas-zoom.ts b/apps/web/src/hooks/use-canvas-zoom.ts index 91ac3e79..67652d16 100644 --- a/apps/web/src/hooks/use-canvas-zoom.ts +++ b/apps/web/src/hooks/use-canvas-zoom.ts @@ -26,6 +26,10 @@ export function useCanvasZoom() { if (tweenRef.current) { tweenRef.current.destroy(); } + // Update store immediately so all tools get correct coordinates + setZoom(targetZoom); + setPanOffset(targetPos); + tweenRef.current = new Konva.Tween({ node: stage, scaleX: targetZoom, @@ -35,8 +39,7 @@ export function useCanvasZoom() { duration: ZOOM_ANIMATION_DURATION, easing: Konva.Easings.EaseOut, onFinish: () => { - setZoom(targetZoom); - setPanOffset(targetPos); + tweenRef.current?.destroy(); tweenRef.current = null; }, }); diff --git a/apps/web/src/pages/editor-page.tsx b/apps/web/src/pages/editor-page.tsx index ebf22a8d..ef227a0f 100644 --- a/apps/web/src/pages/editor-page.tsx +++ b/apps/web/src/pages/editor-page.tsx @@ -1,7 +1,9 @@ // apps/web/src/pages/editor-page.tsx import { Monitor } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; +import { CanvasResizeDialog } from "@/components/editor/common/canvas-resize-dialog"; import { ExportDialog, saveEditorState } from "@/components/editor/common/export-dialog"; +import { ImageResizeDialog } from "@/components/editor/common/image-resize-dialog"; import { WelcomeScreen } from "@/components/editor/common/welcome-screen"; import { EditorCanvas } from "@/components/editor/editor-canvas"; import { EditorOptionsBar } from "@/components/editor/editor-options-bar"; @@ -18,6 +20,8 @@ export function EditorPage() { const isDirty = useEditorStore((s) => s.isDirty); const loadImage = useEditorStore((s) => s.loadImage); const [showExport, setShowExport] = useState(false); + const [showCanvasResize, setShowCanvasResize] = useState(false); + const [showImageResize, setShowImageResize] = useState(false); // Issue #10: Shortcuts belong at page level, not canvas level useEditorShortcuts({ @@ -90,13 +94,18 @@ export function EditorPage() {
- + setShowCanvasResize(true)} + onImageResize={() => setShowImageResize(true)} + /> {!sourceImageUrl && }
{showExport && setShowExport(false)} />} + setShowCanvasResize(false)} /> + setShowImageResize(false)} /> ); } diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index 537193f8..96f24e94 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -9,6 +9,7 @@ import type { EditorLayer, EditorState, FilterConfig, + SelectionMode, ToolType, } from "@/types/editor"; @@ -79,7 +80,14 @@ function createDefaultLayer(id: string, name: string): EditorLayer { }; } -let layerCounter = 1; +function nextLayerNumber(layers: EditorLayer[]): number { + let max = 0; + for (const l of layers) { + const m = l.name.match(/^Layer (\d+)/); + if (m) max = Math.max(max, Number(m[1])); + } + return max + 1; +} export const useEditorStore = create()( temporal( @@ -118,6 +126,8 @@ export const useEditorStore = create()( // --- Selection --- selection: null, + selectionMode: "new" as SelectionMode, + magicWandTolerance: 32, // --- Crop --- cropState: null, @@ -143,6 +153,7 @@ export const useEditorStore = create()( // --- Clone stamp --- cloneSource: null, + cloneAligned: true, // --- Dodge/Burn/Sponge --- dodgeBurnRange: "midtones", @@ -150,6 +161,18 @@ export const useEditorStore = create()( spongeMode: "saturate", spongeFlow: 50, + // --- Fill tool --- + fillTolerance: 32, + fillContiguous: true, + + // --- Gradient tool --- + gradientType: "linear", + gradientOpacity: 1, + gradientReverse: false, + + // --- Pixel brush --- + pixelBrushStrength: 50, + // --- UI --- rightPanelTab: "layers", rightPanelVisible: true, @@ -184,6 +207,7 @@ export const useEditorStore = create()( activeTool: tool, previousTool: activeTool, isCropping: tool === "crop", + ...(activeTool === "crop" && tool !== "crop" ? { cropState: null } : {}), }); }, @@ -194,27 +218,78 @@ export const useEditorStore = create()( setPanOffset: (offset) => set({ panOffset: offset }), loadImage: (url, width, height) => { + const oldUrl = get().sourceImageUrl; + if (oldUrl?.startsWith("blob:")) { + URL.revokeObjectURL(oldUrl); + } set({ sourceImageUrl: url, sourceImageSize: { width, height }, canvasSize: { width, height }, zoom: 1, panOffset: { x: 0, y: 0 }, + objects: [], + selectedObjectIds: [], + selection: null, + cropState: null, + isCropping: false, + adjustments: { ...DEFAULT_ADJUSTMENTS }, + filters: DEFAULT_FILTERS.map((f) => ({ + ...f, + params: { ...f.params }, + })), + clipboard: null, + editingTextId: null, + layers: [createDefaultLayer(DEFAULT_LAYER_ID, "Layer 1")], + activeLayerId: DEFAULT_LAYER_ID, lastAction: "Load Image", _historyVersion: get()._historyVersion + 1, }); }, - resizeCanvas: (width, height, _anchor) => { + resizeCanvas: (width, height, anchor, _fill) => { + const { canvasSize, objects } = get(); + const dw = width - canvasSize.width; + const dh = height - canvasSize.height; + let offsetX = 0; + let offsetY = 0; + if (anchor === "center") { + offsetX = dw / 2; + offsetY = dh / 2; + } else { + if (anchor.includes("center")) { + offsetX = dw / 2; + } else if (anchor.includes("right")) { + offsetX = dw; + } + if (anchor.startsWith("center")) { + offsetY = dh / 2; + } else if (anchor.startsWith("bottom")) { + offsetY = dh; + } + } set({ canvasSize: { width, height }, + objects: + offsetX !== 0 || offsetY !== 0 + ? objects.map((obj) => { + const attrs = { ...obj.attrs }; + if ("x" in attrs) { + (attrs as { x: number }).x += offsetX; + } + if ("y" in attrs) { + (attrs as { y: number }).y += offsetY; + } + return { ...obj, attrs } as CanvasObject; + }) + : objects, isDirty: true, lastAction: "Resize Canvas", _historyVersion: get()._historyVersion + 1, }); }, - resizeImage: (width, height) => { + resizeImage: (width, height, _resample) => { set({ canvasSize: { width, height }, sourceImageSize: { width, height }, @@ -233,22 +308,35 @@ export const useEditorStore = create()( sourceImageSize: newSize, objects: objects.map((obj) => { const attrs = { ...obj.attrs }; - if ("x" in attrs && "y" in attrs) { + const hasPos = "x" in attrs && "y" in attrs; + const hasSize = "width" in attrs && "height" in attrs; + const a = attrs as unknown as Record; + if (hasPos) { if (degrees === 90) { - const newX = canvasSize.height - (attrs as { y: number }).y; - const newY = (attrs as { x: number }).x; - (attrs as { x: number }).x = newX; - (attrs as { y: number }).y = newY; + const newX = canvasSize.height - a.y - (hasSize ? a.height : 0); + const newY = a.x; + a.x = newX; + a.y = newY; } else if (degrees === 270) { - const newX = (attrs as { y: number }).y; - const newY = canvasSize.width - (attrs as { x: number }).x; - (attrs as { x: number }).x = newX; - (attrs as { y: number }).y = newY; + const newX = a.y; + const newY = canvasSize.width - a.x - (hasSize ? a.width : 0); + a.x = newX; + a.y = newY; } else { - (attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x; - (attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y; + a.x = canvasSize.width - a.x - (hasSize ? a.width : 0); + a.y = canvasSize.height - a.y - (hasSize ? a.height : 0); } } + if (hasSize && degrees !== 180) { + const oldW = a.width; + a.width = a.height; + a.height = oldW; + } + if ("radiusX" in attrs && "radiusY" in attrs && degrees !== 180) { + const oldRx = a.radiusX; + a.radiusX = a.radiusY; + a.radiusY = oldRx; + } return { ...obj, attrs } as CanvasObject; }), isDirty: true, @@ -263,7 +351,9 @@ export const useEditorStore = create()( objects: objects.map((obj) => { const attrs = { ...obj.attrs }; if ("x" in attrs) { - (attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x; + const a = attrs as unknown as Record; + const w = "width" in attrs ? a.width : 0; + a.x = canvasSize.width - a.x - w; } return { ...obj, attrs } as CanvasObject; }), @@ -279,7 +369,9 @@ export const useEditorStore = create()( objects: objects.map((obj) => { const attrs = { ...obj.attrs }; if ("y" in attrs) { - (attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y; + const a = attrs as unknown as Record; + const h = "height" in attrs ? a.height : 0; + a.y = canvasSize.height - a.y - h; } return { ...obj, attrs } as CanvasObject; }), @@ -290,7 +382,50 @@ export const useEditorStore = create()( }, trimCanvas: () => { + const { objects, canvasSize } = get(); + if (objects.length === 0) return; + let minX = canvasSize.width; + let minY = canvasSize.height; + let maxX = 0; + let maxY = 0; + for (const obj of objects) { + const a = obj.attrs as unknown as Record; + const x = "x" in obj.attrs ? a.x : 0; + const y = "y" in obj.attrs ? a.y : 0; + const w = "width" in obj.attrs ? a.width : "radiusX" in obj.attrs ? a.radiusX * 2 : 0; + const h = "height" in obj.attrs ? a.height : "radiusY" in obj.attrs ? a.radiusY * 2 : 0; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + w); + maxY = Math.max(maxY, y + h); + } + minX = Math.max(0, Math.floor(minX)); + minY = Math.max(0, Math.floor(minY)); + maxX = Math.min(canvasSize.width, Math.ceil(maxX)); + maxY = Math.min(canvasSize.height, Math.ceil(maxY)); + const newWidth = maxX - minX; + const newHeight = maxY - minY; + if (newWidth <= 0 || newHeight <= 0) return; + if ( + newWidth === canvasSize.width && + newHeight === canvasSize.height && + minX === 0 && + minY === 0 + ) + return; set({ + canvasSize: { width: newWidth, height: newHeight }, + sourceImageSize: { width: newWidth, height: newHeight }, + objects: objects.map((obj) => { + const attrs = { ...obj.attrs }; + if ("x" in attrs) { + (attrs as unknown as Record).x -= minX; + } + if ("y" in attrs) { + (attrs as unknown as Record).y -= minY; + } + return { ...obj, attrs } as CanvasObject; + }), isDirty: true, lastAction: "Trim Canvas", _historyVersion: get()._historyVersion + 1, @@ -307,7 +442,14 @@ export const useEditorStore = create()( set({ foregroundColor: color, recentColors: updated }); }, - setBackgroundColor: (color) => set({ backgroundColor: color }), + setBackgroundColor: (color) => { + const { recentColors } = get(); + const updated = [color, ...recentColors.filter((c) => c !== color)].slice( + 0, + MAX_RECENT_COLORS, + ); + set({ backgroundColor: color, recentColors: updated }); + }, swapColors: () => { const { foregroundColor, backgroundColor } = get(); @@ -358,11 +500,17 @@ export const useEditorStore = create()( const { objects } = get(); const obj = objects.find((o) => o.id === objectId); if (!obj) return; - const layerObjects = objects.filter((o) => o.layerId === obj.layerId); - const otherObjects = objects.filter((o) => o.layerId !== obj.layerId); - const reordered = [...layerObjects.filter((o) => o.id !== objectId), obj]; + const newObjects = objects.filter((o) => o.id !== objectId); + let insertIdx = newObjects.length; + for (let i = newObjects.length - 1; i >= 0; i--) { + if (newObjects[i].layerId === obj.layerId) { + insertIdx = i + 1; + break; + } + } + newObjects.splice(insertIdx, 0, obj); set({ - objects: [...otherObjects, ...reordered], + objects: newObjects, lastAction: "Bring to Front", _historyVersion: get()._historyVersion + 1, }); @@ -371,9 +519,18 @@ export const useEditorStore = create()( bringForward: (objectId) => { const { objects } = get(); const idx = objects.findIndex((o) => o.id === objectId); - if (idx === -1 || idx === objects.length - 1) return; + if (idx === -1) return; + const obj = objects[idx]; + let swapIdx = -1; + for (let i = idx + 1; i < objects.length; i++) { + if (objects[i].layerId === obj.layerId) { + swapIdx = i; + break; + } + } + if (swapIdx === -1) return; const newObjects = [...objects]; - [newObjects[idx], newObjects[idx + 1]] = [newObjects[idx + 1], newObjects[idx]]; + [newObjects[idx], newObjects[swapIdx]] = [newObjects[swapIdx], newObjects[idx]]; set({ objects: newObjects, lastAction: "Bring Forward", @@ -384,9 +541,18 @@ export const useEditorStore = create()( sendBackward: (objectId) => { const { objects } = get(); const idx = objects.findIndex((o) => o.id === objectId); - if (idx <= 0) return; + if (idx === -1) return; + const obj = objects[idx]; + let swapIdx = -1; + for (let i = idx - 1; i >= 0; i--) { + if (objects[i].layerId === obj.layerId) { + swapIdx = i; + break; + } + } + if (swapIdx === -1) return; const newObjects = [...objects]; - [newObjects[idx - 1], newObjects[idx]] = [newObjects[idx], newObjects[idx - 1]]; + [newObjects[swapIdx], newObjects[idx]] = [newObjects[idx], newObjects[swapIdx]]; set({ objects: newObjects, lastAction: "Send Backward", @@ -398,11 +564,17 @@ export const useEditorStore = create()( const { objects } = get(); const obj = objects.find((o) => o.id === objectId); if (!obj) return; - const layerObjects = objects.filter((o) => o.layerId === obj.layerId); - const otherObjects = objects.filter((o) => o.layerId !== obj.layerId); - const reordered = [obj, ...layerObjects.filter((o) => o.id !== objectId)]; + const newObjects = objects.filter((o) => o.id !== objectId); + let insertIdx = 0; + for (let i = 0; i < newObjects.length; i++) { + if (newObjects[i].layerId === obj.layerId) { + insertIdx = i; + break; + } + } + newObjects.splice(insertIdx, 0, obj); set({ - objects: [...reordered, ...otherObjects], + objects: newObjects, lastAction: "Send to Back", _historyVersion: get()._historyVersion + 1, }); @@ -410,9 +582,8 @@ export const useEditorStore = create()( // Layers addLayer: () => { - layerCounter++; const id = generateId(); - const name = `Layer ${layerCounter}`; + const name = `Layer ${nextLayerNumber(get().layers)}`; const newLayer = createDefaultLayer(id, name); const { layers, activeLayerId } = get(); const activeIndex = layers.findIndex((l) => l.id === activeLayerId); @@ -449,7 +620,6 @@ export const useEditorStore = create()( const source = layers.find((l) => l.id === id); if (!source) return; const newId = generateId(); - layerCounter++; const copy: EditorLayer = { ...source, id: newId, @@ -580,6 +750,8 @@ export const useEditorStore = create()( // Selection setSelection: (selection) => set({ selection }), + setSelectionMode: (mode) => set({ selectionMode: mode }), + setMagicWandTolerance: (v) => set({ magicWandTolerance: v }), invertSelection: () => { const { selection } = get(); @@ -601,6 +773,7 @@ export const useEditorStore = create()( if (!cropState) return; set({ canvasSize: { width: cropState.width, height: cropState.height }, + sourceImageSize: { width: cropState.width, height: cropState.height }, objects: objects.map((obj) => { const attrs = { ...obj.attrs }; if ("x" in attrs) { @@ -710,6 +883,41 @@ export const useEditorStore = create()( markClean: () => set({ isDirty: false }), setLoadingState: (state) => set({ loadingState: state }), + // Text + setEditingTextId: (id) => set({ editingTextId: id }), + + // Dodge/Burn/Sponge settings + setDodgeBurnRange: (range) => set({ dodgeBurnRange: range }), + setDodgeBurnExposure: (exposure) => set({ dodgeBurnExposure: exposure }), + setSpongeMode: (mode) => set({ spongeMode: mode }), + setSpongeFlow: (flow) => set({ spongeFlow: flow }), + + // Shape settings + setShapeFill: (fill) => set({ shapeFill: fill }), + setShapeStroke: (stroke) => set({ shapeStroke: stroke }), + setShapeStrokeWidth: (width) => set({ shapeStrokeWidth: width }), + setShapeCornerRadius: (radius) => set({ shapeCornerRadius: radius }), + setShapePolygonSides: (sides) => set({ shapePolygonSides: sides }), + setShapeStarPoints: (points) => set({ shapeStarPoints: points }), + + // Clone stamp + setCloneSource: (source) => set({ cloneSource: source }), + setCloneAligned: (aligned) => set({ cloneAligned: aligned }), + + // Fill tool settings + setFillTolerance: (tolerance) => + set({ fillTolerance: Math.max(0, Math.min(255, tolerance)) }), + setFillContiguous: (contiguous) => set({ fillContiguous: contiguous }), + + // Gradient tool settings + setGradientType: (type) => set({ gradientType: type }), + setGradientOpacity: (opacity) => set({ gradientOpacity: Math.max(0, Math.min(1, opacity)) }), + setGradientReverse: (reverse) => set({ gradientReverse: reverse }), + + // Pixel brush settings + setPixelBrushStrength: (strength) => + set({ pixelBrushStrength: Math.max(1, Math.min(100, strength)) }), + // Right panel setRightPanelTab: (tab) => set({ rightPanelTab: tab }), toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }), diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts index 4515f7e2..feb83dab 100644 --- a/apps/web/src/types/editor.ts +++ b/apps/web/src/types/editor.ts @@ -1,5 +1,7 @@ // apps/web/src/types/editor.ts +export type SelectionMode = "new" | "add" | "subtract"; + export type ToolType = | "move" | "marquee-rect" @@ -287,6 +289,8 @@ export interface EditorState { // Selection selection: SelectionState | null; + selectionMode: SelectionMode; + magicWandTolerance: number; // Crop cropState: CropState | null; @@ -309,6 +313,7 @@ export interface EditorState { // Clone stamp cloneSource: CloneSource | null; + cloneAligned: boolean; // Dodge/Burn/Sponge dodgeBurnRange: "shadows" | "midtones" | "highlights"; @@ -316,6 +321,18 @@ export interface EditorState { spongeMode: "saturate" | "desaturate"; spongeFlow: number; + // Fill tool + fillTolerance: number; + fillContiguous: boolean; + + // Gradient tool + gradientType: "linear" | "radial"; + gradientOpacity: number; + gradientReverse: boolean; + + // Pixel brush + pixelBrushStrength: number; + // UI rightPanelTab: "layers" | "adjustments" | "history"; rightPanelVisible: boolean; @@ -352,8 +369,8 @@ export interface EditorState { setZoom: (zoom: number) => void; setPanOffset: (offset: { x: number; y: number }) => void; loadImage: (url: string, width: number, height: number) => void; - resizeCanvas: (width: number, height: number, anchor: AnchorPosition) => void; - resizeImage: (width: number, height: number) => void; + resizeCanvas: (width: number, height: number, anchor: AnchorPosition, fill?: string) => void; + resizeImage: (width: number, height: number, resample?: string) => void; rotateCanvas: (degrees: 90 | 180 | 270) => void; flipCanvasHorizontal: () => void; flipCanvasVertical: () => void; @@ -393,6 +410,8 @@ export interface EditorState { // Selection setSelection: (selection: SelectionState | null) => void; + setSelectionMode: (mode: SelectionMode) => void; + setMagicWandTolerance: (v: number) => void; invertSelection: () => void; // Crop @@ -424,6 +443,39 @@ export interface EditorState { markClean: () => void; setLoadingState: (state: LoadingState | null) => void; + // Text + setEditingTextId: (id: string | null) => void; + + // Dodge/Burn/Sponge settings + setDodgeBurnRange: (range: "shadows" | "midtones" | "highlights") => void; + setDodgeBurnExposure: (exposure: number) => void; + setSpongeMode: (mode: "saturate" | "desaturate") => void; + setSpongeFlow: (flow: number) => void; + + // Shape settings + setShapeFill: (fill: string) => void; + setShapeStroke: (stroke: string) => void; + setShapeStrokeWidth: (width: number) => void; + setShapeCornerRadius: (radius: number) => void; + setShapePolygonSides: (sides: number) => void; + setShapeStarPoints: (points: number) => void; + + // Clone stamp + setCloneSource: (source: CloneSource | null) => void; + setCloneAligned: (aligned: boolean) => void; + + // Fill tool settings + setFillTolerance: (tolerance: number) => void; + setFillContiguous: (contiguous: boolean) => void; + + // Gradient tool settings + setGradientType: (type: "linear" | "radial") => void; + setGradientOpacity: (opacity: number) => void; + setGradientReverse: (reverse: boolean) => void; + + // Pixel brush settings + setPixelBrushStrength: (strength: number) => void; + // Right panel setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void; toggleRightPanel: () => void; diff --git a/tests/unit/web/editor-store.test.ts b/tests/unit/web/editor-store.test.ts index 7fd51f8a..96f9a1a4 100644 --- a/tests/unit/web/editor-store.test.ts +++ b/tests/unit/web/editor-store.test.ts @@ -1168,8 +1168,8 @@ describe("Canvas Transforms", () => { act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 }))); act((s) => s.rotateCanvas(90)); const obj = state().objects[0]; - // After 90 rotation: newX = canvasHeight - y = 600 - 200, newY = x = 100 - expect(obj.type === "rect" && obj.attrs.x).toBe(400); + // After 90 rotation: newX = canvasHeight - y - height = 600 - 200 - 50, newY = x = 100 + expect(obj.type === "rect" && obj.attrs.x).toBe(350); expect(obj.type === "rect" && obj.attrs.y).toBe(100); }); @@ -1178,8 +1178,9 @@ describe("Canvas Transforms", () => { act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 }))); act((s) => s.flipCanvasHorizontal()); const obj = state().objects[0]; - expect(obj.type === "rect" && obj.attrs.x).toBe(700); // 800 - 100 - expect(obj.type === "rect" && obj.attrs.y).toBe(200); // unchanged + // 800 - 100 - width(100) = 600 + expect(obj.type === "rect" && obj.attrs.x).toBe(600); + expect(obj.type === "rect" && obj.attrs.y).toBe(200); }); it("flipCanvasVertical flips object y positions", () => { @@ -1187,13 +1188,27 @@ describe("Canvas Transforms", () => { act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 }))); act((s) => s.flipCanvasVertical()); const obj = state().objects[0]; - expect(obj.type === "rect" && obj.attrs.x).toBe(100); // unchanged - expect(obj.type === "rect" && obj.attrs.y).toBe(400); // 600 - 200 + expect(obj.type === "rect" && obj.attrs.x).toBe(100); + // 600 - 200 - height(50) = 350 + expect(obj.type === "rect" && obj.attrs.y).toBe(350); }); - it("trimCanvas marks dirty", () => { + it("trimCanvas trims to object bounds", () => { + act((s) => s.loadImage("blob:test", 800, 600)); + act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 }))); act((s) => s.trimCanvas()); expect(state().isDirty).toBe(true); + expect(state().canvasSize).toEqual({ width: 100, height: 50 }); + const obj = state().objects[0]; + expect(obj.type === "rect" && obj.attrs.x).toBe(0); + expect(obj.type === "rect" && obj.attrs.y).toBe(0); + }); + + it("trimCanvas no-ops when no objects exist", () => { + act((s) => s.loadImage("blob:test", 800, 600)); + act((s) => s.trimCanvas()); + expect(state().isDirty).toBe(false); + expect(state().canvasSize).toEqual({ width: 800, height: 600 }); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 46947e4c..b0597d34 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ exclude: [ "tests/e2e/**", "tests/e2e-docs/**", + "tests/e2e-editor/**", "tests/e2e-landing/**", "tests/e2e-docker/**", "tests/e2e-analytics/**",