From 711ddf8debd2e17969e00aa718295ab7bf302672 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:01:47 +0800 Subject: [PATCH 01/65] chore: add konva, react-konva, and editor dependencies --- apps/web/package.json | 8 ++- pnpm-lock.yaml | 125 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index d0db389a..0c2fefeb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,25 +12,31 @@ "clean": "rm -rf dist" }, "dependencies": { - "@snapotter/shared": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@sentry/react": "^10.49.0", + "@snapotter/shared": "workspace:*", "@use-gesture/react": "^10.3.1", "clsx": "^2.1.0", "fflate": "^0.8.2", "jszip": "^3.10.1", + "konva": "^10", "leaflet": "^1.9.4", "lucide-react": "^0.469.0", "posthog-js": "^1.370.0", "qr-code-styling": "^1.9.2", "react": "^19.0.0", + "react-colorful": "^5", "react-dom": "^19.0.0", + "react-hotkeys-hook": "^5", "react-image-crop": "^11.0.10", + "react-konva": "^19", "react-router-dom": "^7.1.0", "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", + "use-image": "^1", + "zundo": "^2", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6e03bf5..b91e259e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,9 @@ importers: jszip: specifier: ^3.10.1 version: 3.10.1 + konva: + specifier: ^10 + version: 10.3.0 leaflet: specifier: ^1.9.4 version: 1.9.4 @@ -293,12 +296,21 @@ importers: react: specifier: ^19.0.0 version: 19.2.4 + react-colorful: + specifier: ^5 + version: 5.6.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + react-hotkeys-hook: + specifier: ^5 + version: 5.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-image-crop: specifier: ^11.0.10 version: 11.0.10(react@19.2.4) + react-konva: + specifier: ^19 + version: 19.2.3(@types/react@19.2.14)(konva@10.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-router-dom: specifier: ^7.1.0 version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -308,6 +320,12 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.1 + use-image: + specifier: ^1 + version: 1.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + zundo: + specifier: ^2 + version: 2.3.0(zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)) zustand: specifier: ^5.0.0 version: 5.0.12(@types/react@19.2.14)(react@19.2.4) @@ -3400,6 +3418,16 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + + '@types/react-reconciler@0.33.0': + resolution: {integrity: sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==} + peerDependencies: + '@types/react': '*' + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -4852,6 +4880,11 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -4944,6 +4977,9 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + konva@10.3.0: + resolution: {integrity: sha512-gt19K2gzY4lHbnkvsku7eSmB+A9PTS2jG4F9coBMsdjM1UKfJNxJbDbXVpeCW1wjEGRwBD3nBamcHnqJhAeKlg==} + lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} @@ -5884,11 +5920,23 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-colorful@5.6.1: + resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: react: ^19.2.4 + react-hotkeys-hook@5.3.2: + resolution: {integrity: sha512-DDDy9xK6mbTQ6aPlQvIl0dA/a90T/AWml4Rm21JXFDLlRHalIg4/Rv3equUQYs5xPTWq+oEl6RD7mi/nBpU3Uw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-image-crop@11.0.10: resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} peerDependencies: @@ -5897,6 +5945,19 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-konva@19.2.3: + resolution: {integrity: sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==} + peerDependencies: + konva: ^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0 + react: ^19.2.0 + react-dom: ^19.2.0 + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -6608,6 +6669,12 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + use-image@1.1.4: + resolution: {integrity: sha512-P+swhszzHHgEb2X2yQ+vQNPCq/8Ks3hyfdXAVN133pvnvK7UK++bUaZUa5E+A3S02Mw8xOCBr9O6CLhk2fjrWA==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + utif@2.0.1: resolution: {integrity: sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==} @@ -6899,6 +6966,11 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zundo@2.3.0: + resolution: {integrity: sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==} + peerDependencies: + zustand: ^4.3.0 || ^5.0.0 + zustand@5.0.12: resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} engines: {node: '>=12.20.0'} @@ -9771,6 +9843,14 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-reconciler@0.33.0(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -11265,6 +11345,13 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.4 + transitivePeerDependencies: + - '@types/react' + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -11377,6 +11464,8 @@ snapshots: kind-of@6.0.3: {} + konva@10.3.0: {} + lazystream@1.0.1: dependencies: readable-stream: 2.3.8 @@ -12329,17 +12418,44 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-colorful@5.6.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 scheduler: 0.27.0 + react-hotkeys-hook@5.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-image-crop@11.0.10(react@19.2.4): dependencies: react: 19.2.4 react-is@17.0.2: {} + react-konva@19.2.3(@types/react@19.2.14)(konva@10.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@types/react-reconciler': 0.33.0(@types/react@19.2.14) + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.4) + konva: 10.3.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-reconciler: 0.33.0(react@19.2.4) + scheduler: 0.27.0 + transitivePeerDependencies: + - '@types/react' + + react-reconciler@0.33.0(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + react-refresh@0.17.0: {} react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -13159,6 +13275,11 @@ snapshots: url-join@5.0.0: {} + use-image@1.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + utif@2.0.1: dependencies: pako: 1.0.11 @@ -13507,6 +13628,10 @@ snapshots: zod@4.3.6: {} + zundo@2.3.0(zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)): + dependencies: + zustand: 5.0.12(@types/react@19.2.14)(react@19.2.4) + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4): optionalDependencies: '@types/react': 19.2.14 From eb6f3c33ac77a37e330de9a5c18c0949ca71ffe2 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:05:33 +0800 Subject: [PATCH 02/65] feat: add image editor type definitions and zustand store Add complete type system for the editor (ToolType, CanvasObject, EditorLayer, EditorState, etc.) and the central Zustand store with zundo undo/redo middleware, 50-entry history limit, 500ms throttle, and O(1) _historyVersion equality checks. --- apps/web/src/stores/editor-store.ts | 724 ++++++++++++++++++++++++++++ apps/web/src/types/editor.ts | 430 +++++++++++++++++ 2 files changed, 1154 insertions(+) create mode 100644 apps/web/src/stores/editor-store.ts create mode 100644 apps/web/src/types/editor.ts diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts new file mode 100644 index 00000000..20d786d1 --- /dev/null +++ b/apps/web/src/stores/editor-store.ts @@ -0,0 +1,724 @@ +// apps/web/src/stores/editor-store.ts + +import { temporal } from "zundo"; +import { create } from "zustand"; +import { generateId } from "@/lib/utils"; +import type { + AdjustmentValues, + CanvasObject, + EditorLayer, + EditorState, + FilterConfig, + ToolType, +} from "@/types/editor"; + +const DEFAULT_CANVAS_SIZE = { width: 1920, height: 1080 }; +const DEFAULT_LAYER_ID = "layer-1"; +const MAX_RECENT_COLORS = 12; +const MIN_ZOOM = 0.01; +const MAX_ZOOM = 64; +const MAX_BRUSH_SIZE = 500; +const MAX_HISTORY = 50; + +const DEFAULT_ADJUSTMENTS: AdjustmentValues = { + brightness: 0, + contrast: 0, + hue: 0, + saturation: 0, + luminance: 0, + exposure: 0, + vibrance: 0, + warmth: 0, +}; + +const DEFAULT_FILTERS: FilterConfig[] = [ + { type: "blur", enabled: false, params: { radius: 0 } }, + { type: "sharpen", enabled: false, params: { amount: 0 } }, + { type: "noise", enabled: false, params: { amount: 0 } }, + { type: "pixelate", enabled: false, params: { size: 1 } }, + { type: "emboss", enabled: false, params: { strength: 0 } }, + { type: "grayscale", enabled: false, params: {} }, + { type: "sepia", enabled: false, params: {} }, + { type: "invert", enabled: false, params: {} }, + { type: "posterize", enabled: false, params: { levels: 8 } }, + { type: "solarize", enabled: false, params: {} }, + { type: "threshold", enabled: false, params: { level: 0.5 } }, + { type: "kaleidoscope", enabled: false, params: { power: 2, angle: 0 } }, + { type: "motionBlur", enabled: false, params: { angle: 0, distance: 10 } }, + { + type: "radialBlur", + enabled: false, + params: { amount: 10, centerX: 0.5, centerY: 0.5 }, + }, + { + type: "surfaceBlur", + enabled: false, + params: { radius: 5, threshold: 25 }, + }, + { + type: "vignette", + enabled: false, + params: { amount: 50, midpoint: 50, roundness: 0, feather: 50 }, + }, + { + type: "grain", + enabled: false, + params: { amount: 25, size: 25, roughness: 50 }, + }, +]; + +function createDefaultLayer(id: string, name: string): EditorLayer { + return { + id, + name, + visible: true, + locked: false, + opacity: 1, + blendMode: "source-over", + thumbnail: null, + }; +} + +let layerCounter = 1; + +export const useEditorStore = create()( + temporal( + (set, get) => ({ + // --- Canvas --- + canvasSize: DEFAULT_CANVAS_SIZE, + zoom: 1, + panOffset: { x: 0, y: 0 }, + cursorPosition: { x: 0, y: 0 }, + + // --- Image --- + sourceImageUrl: null, + sourceImageSize: null, + + // --- Tool --- + activeTool: "move" as ToolType, + previousTool: null, + + // --- Brush --- + brushSize: 10, + brushOpacity: 1, + brushHardness: 1, + + // --- Colors --- + foregroundColor: "#000000", + backgroundColor: "#ffffff", + recentColors: [], + + // --- Layers --- + layers: [createDefaultLayer(DEFAULT_LAYER_ID, "Layer 1")], + activeLayerId: DEFAULT_LAYER_ID, + + // --- Objects --- + objects: [], + selectedObjectIds: [], + + // --- Selection --- + selection: null, + + // --- Crop --- + cropState: null, + isCropping: false, + + // --- Adjustments --- + adjustments: { ...DEFAULT_ADJUSTMENTS }, + filters: DEFAULT_FILTERS.map((f) => ({ + ...f, + params: { ...f.params }, + })), + + // --- Text --- + editingTextId: null, + + // --- Shape settings --- + shapeFill: "#3b82f6", + shapeStroke: "#000000", + shapeStrokeWidth: 2, + shapeCornerRadius: 0, + shapePolygonSides: 6, + shapeStarPoints: 5, + + // --- Clone stamp --- + cloneSource: null, + + // --- Dodge/Burn/Sponge --- + dodgeBurnRange: "midtones", + dodgeBurnExposure: 50, + spongeMode: "saturate", + spongeFlow: 50, + + // --- UI --- + rightPanelTab: "layers", + rightPanelVisible: true, + isSpaceHeld: false, + + // --- Document --- + isDirty: false, + lastAutoSave: null, + + // --- Clipboard --- + clipboard: null, + + // --- Guides --- + guides: [], + snappingEnabled: true, + rulersVisible: false, + guidesVisible: true, + gridVisible: false, + + // --- Loading --- + loadingState: null, + + // --- History --- + lastAction: "Initial State", + _historyVersion: 0, + + // ===== ACTIONS ===== + + setTool: (tool) => { + const { activeTool } = get(); + set({ + activeTool: tool, + previousTool: activeTool, + isCropping: tool === "crop", + }); + }, + + setCursorPosition: (pos) => set({ cursorPosition: pos }), + + setZoom: (zoom) => set({ zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)) }), + + setPanOffset: (offset) => set({ panOffset: offset }), + + loadImage: (url, width, height) => { + set({ + sourceImageUrl: url, + sourceImageSize: { width, height }, + canvasSize: { width, height }, + zoom: 1, + panOffset: { x: 0, y: 0 }, + lastAction: "Load Image", + _historyVersion: get()._historyVersion + 1, + }); + }, + + resizeCanvas: (width, height, _anchor) => { + set({ + canvasSize: { width, height }, + isDirty: true, + lastAction: "Resize Canvas", + _historyVersion: get()._historyVersion + 1, + }); + }, + + resizeImage: (width, height) => { + set({ + canvasSize: { width, height }, + sourceImageSize: { width, height }, + isDirty: true, + lastAction: "Resize Image", + _historyVersion: get()._historyVersion + 1, + }); + }, + + rotateCanvas: (degrees) => { + const { canvasSize, objects } = get(); + const newSize = + degrees === 180 ? canvasSize : { width: canvasSize.height, height: canvasSize.width }; + set({ + canvasSize: newSize, + sourceImageSize: newSize, + objects: objects.map((obj) => { + const attrs = { ...obj.attrs }; + if ("x" in attrs && "y" in attrs) { + 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; + } 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; + } 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; + } + } + return { ...obj, attrs } as CanvasObject; + }), + isDirty: true, + lastAction: `Rotate Canvas ${degrees}`, + _historyVersion: get()._historyVersion + 1, + }); + }, + + flipCanvasHorizontal: () => { + const { canvasSize, objects } = get(); + set({ + objects: objects.map((obj) => { + const attrs = { ...obj.attrs }; + if ("x" in attrs) { + (attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x; + } + return { ...obj, attrs } as CanvasObject; + }), + isDirty: true, + lastAction: "Flip Horizontal", + _historyVersion: get()._historyVersion + 1, + }); + }, + + flipCanvasVertical: () => { + const { canvasSize, objects } = get(); + set({ + objects: objects.map((obj) => { + const attrs = { ...obj.attrs }; + if ("y" in attrs) { + (attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y; + } + return { ...obj, attrs } as CanvasObject; + }), + isDirty: true, + lastAction: "Flip Vertical", + _historyVersion: get()._historyVersion + 1, + }); + }, + + trimCanvas: () => { + set({ + isDirty: true, + lastAction: "Trim Canvas", + _historyVersion: get()._historyVersion + 1, + }); + }, + + // Colors + setForegroundColor: (color) => { + const { recentColors } = get(); + const updated = [color, ...recentColors.filter((c) => c !== color)].slice( + 0, + MAX_RECENT_COLORS, + ); + set({ foregroundColor: color, recentColors: updated }); + }, + + setBackgroundColor: (color) => set({ backgroundColor: color }), + + swapColors: () => { + const { foregroundColor, backgroundColor } = get(); + set({ + foregroundColor: backgroundColor, + backgroundColor: foregroundColor, + }); + }, + + resetColors: () => set({ foregroundColor: "#000000", backgroundColor: "#ffffff" }), + + // Objects + addObject: (obj) => { + set({ + objects: [...get().objects, { ...obj, layerId: obj.layerId || get().activeLayerId }], + isDirty: true, + lastAction: `Add ${obj.type.charAt(0).toUpperCase() + obj.type.slice(1)}`, + _historyVersion: get()._historyVersion + 1, + }); + }, + + updateObject: (id, attrs) => { + set({ + objects: get().objects.map((obj) => + obj.id === id ? ({ ...obj, attrs: { ...obj.attrs, ...attrs } } as CanvasObject) : obj, + ), + isDirty: true, + _historyVersion: get()._historyVersion + 1, + }); + }, + + removeObjects: (ids) => { + const idSet = new Set(ids); + set({ + objects: get().objects.filter((obj) => !idSet.has(obj.id)), + selectedObjectIds: get().selectedObjectIds.filter((id) => !idSet.has(id)), + isDirty: true, + lastAction: "Delete", + _historyVersion: get()._historyVersion + 1, + }); + }, + + setSelectedObjects: (ids) => set({ selectedObjectIds: ids }), + + bringToFront: (objectId) => { + 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]; + set({ + objects: [...otherObjects, ...reordered], + lastAction: "Bring to Front", + _historyVersion: get()._historyVersion + 1, + }); + }, + + bringForward: (objectId) => { + const { objects } = get(); + const idx = objects.findIndex((o) => o.id === objectId); + if (idx === -1 || idx === objects.length - 1) return; + const newObjects = [...objects]; + [newObjects[idx], newObjects[idx + 1]] = [newObjects[idx + 1], newObjects[idx]]; + set({ + objects: newObjects, + lastAction: "Bring Forward", + _historyVersion: get()._historyVersion + 1, + }); + }, + + sendBackward: (objectId) => { + const { objects } = get(); + const idx = objects.findIndex((o) => o.id === objectId); + if (idx <= 0) return; + const newObjects = [...objects]; + [newObjects[idx - 1], newObjects[idx]] = [newObjects[idx], newObjects[idx - 1]]; + set({ + objects: newObjects, + lastAction: "Send Backward", + _historyVersion: get()._historyVersion + 1, + }); + }, + + sendToBack: (objectId) => { + 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)]; + set({ + objects: [...reordered, ...otherObjects], + lastAction: "Send to Back", + _historyVersion: get()._historyVersion + 1, + }); + }, + + // Layers + addLayer: () => { + layerCounter++; + const id = generateId(); + const name = `Layer ${layerCounter}`; + const newLayer = createDefaultLayer(id, name); + const { layers, activeLayerId } = get(); + const activeIndex = layers.findIndex((l) => l.id === activeLayerId); + const newLayers = [...layers]; + newLayers.splice(activeIndex + 1, 0, newLayer); + set({ + layers: newLayers, + activeLayerId: id, + isDirty: true, + lastAction: "Add Layer", + _historyVersion: get()._historyVersion + 1, + }); + }, + + removeLayer: (id) => { + const { layers, objects, activeLayerId } = get(); + if (layers.length <= 1) return; + const idx = layers.findIndex((l) => l.id === id); + const newLayers = layers.filter((l) => l.id !== id); + const newActiveId = + activeLayerId === id ? newLayers[Math.min(idx, newLayers.length - 1)].id : activeLayerId; + set({ + layers: newLayers, + objects: objects.filter((o) => o.layerId !== id), + activeLayerId: newActiveId, + isDirty: true, + lastAction: "Delete Layer", + _historyVersion: get()._historyVersion + 1, + }); + }, + + duplicateLayer: (id) => { + const { layers, objects } = get(); + const source = layers.find((l) => l.id === id); + if (!source) return; + const newId = generateId(); + layerCounter++; + const copy: EditorLayer = { + ...source, + id: newId, + name: `${source.name} (copy)`, + thumbnail: null, + }; + const sourceObjects = objects + .filter((o) => o.layerId === id) + .map((o) => ({ ...o, id: generateId(), layerId: newId }) as CanvasObject); + const idx = layers.findIndex((l) => l.id === id); + const newLayers = [...layers]; + newLayers.splice(idx + 1, 0, copy); + set({ + layers: newLayers, + objects: [...objects, ...sourceObjects], + activeLayerId: newId, + isDirty: true, + lastAction: "Duplicate Layer", + _historyVersion: get()._historyVersion + 1, + }); + }, + + setActiveLayer: (id) => set({ activeLayerId: id }), + + updateLayer: (id, updates) => { + set({ + layers: get().layers.map((l) => (l.id === id ? { ...l, ...updates } : l)), + isDirty: true, + _historyVersion: get()._historyVersion + 1, + }); + }, + + reorderLayers: (fromIndex, toIndex) => { + const newLayers = [...get().layers]; + const [moved] = newLayers.splice(fromIndex, 1); + newLayers.splice(toIndex, 0, moved); + set({ + layers: newLayers, + isDirty: true, + lastAction: "Reorder Layers", + _historyVersion: get()._historyVersion + 1, + }); + }, + + mergeDown: (id) => { + const { layers, objects } = get(); + const idx = layers.findIndex((l) => l.id === id); + if (idx <= 0) return; + const belowLayer = layers[idx - 1]; + const mergedObjects = objects.map((o) => + o.layerId === id ? ({ ...o, layerId: belowLayer.id } as CanvasObject) : o, + ); + set({ + layers: layers.filter((l) => l.id !== id), + objects: mergedObjects, + activeLayerId: belowLayer.id, + isDirty: true, + lastAction: "Merge Down", + _historyVersion: get()._historyVersion + 1, + }); + }, + + flattenAll: () => { + const { layers, objects } = get(); + const bottomLayer = layers[0]; + set({ + layers: [{ ...bottomLayer, name: "Flattened" }], + objects: objects.map((o) => ({ ...o, layerId: bottomLayer.id }) as CanvasObject), + activeLayerId: bottomLayer.id, + isDirty: true, + lastAction: "Flatten All", + _historyVersion: get()._historyVersion + 1, + }); + }, + + // Adjustments + setAdjustment: (key, value) => { + const clamps: Record = { + brightness: [-100, 100], + contrast: [-100, 100], + hue: [0, 359], + saturation: [-100, 100], + luminance: [-100, 100], + exposure: [-100, 100], + vibrance: [-100, 100], + warmth: [-100, 100], + }; + const [min, max] = clamps[key] || [-100, 100]; + set({ + adjustments: { + ...get().adjustments, + [key]: Math.max(min, Math.min(max, value)), + }, + }); + }, + + resetAdjustments: () => set({ adjustments: { ...DEFAULT_ADJUSTMENTS } }), + + toggleFilter: (type) => { + set({ + filters: get().filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)), + }); + }, + + setFilterParam: (type, key, value) => { + set({ + filters: get().filters.map((f) => + f.type === type ? { ...f, params: { ...f.params, [key]: value } } : f, + ), + }); + }, + + // Selection + setSelection: (selection) => set({ selection }), + + invertSelection: () => { + const { selection } = get(); + if (!selection) return; + if (selection.mask) { + const inverted = new Uint8Array(selection.mask.length); + for (let i = 0; i < selection.mask.length; i++) { + inverted[i] = 255 - selection.mask[i]; + } + set({ selection: { ...selection, mask: inverted } }); + } + }, + + // Crop + setCropState: (state) => set({ cropState: state, isCropping: state !== null }), + + applyCrop: () => { + const { cropState, objects } = get(); + if (!cropState) return; + set({ + canvasSize: { width: cropState.width, height: cropState.height }, + objects: objects.map((obj) => { + const attrs = { ...obj.attrs }; + if ("x" in attrs) { + (attrs as { x: number }).x -= cropState.x; + } + if ("y" in attrs) { + (attrs as { y: number }).y -= cropState.y; + } + return { ...obj, attrs } as CanvasObject; + }), + cropState: null, + isCropping: false, + isDirty: true, + lastAction: "Crop", + _historyVersion: get()._historyVersion + 1, + }); + }, + + // Brush + setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(MAX_BRUSH_SIZE, size)) }), + setBrushOpacity: (opacity) => set({ brushOpacity: Math.max(0, Math.min(1, opacity)) }), + setBrushHardness: (hardness) => set({ brushHardness: Math.max(0, Math.min(1, hardness)) }), + + // Clipboard + copyObjects: () => { + const { objects, selectedObjectIds } = get(); + const selected = objects.filter((o) => selectedObjectIds.includes(o.id)); + set({ clipboard: selected }); + }, + + cutObjects: () => { + const { objects, selectedObjectIds } = get(); + const selected = objects.filter((o) => selectedObjectIds.includes(o.id)); + set({ clipboard: selected }); + get().removeObjects(selectedObjectIds); + }, + + pasteObjects: () => { + const { clipboard, activeLayerId } = get(); + if (!clipboard || clipboard.length === 0) return; + const pasted = clipboard.map( + (obj) => + ({ + ...obj, + id: generateId(), + layerId: activeLayerId, + attrs: { + ...obj.attrs, + ...("x" in obj.attrs ? { x: (obj.attrs as { x: number }).x + 10 } : {}), + ...("y" in obj.attrs ? { y: (obj.attrs as { y: number }).y + 10 } : {}), + }, + }) as CanvasObject, + ); + set({ + objects: [...get().objects, ...pasted], + selectedObjectIds: pasted.map((o) => o.id), + isDirty: true, + lastAction: "Paste", + _historyVersion: get()._historyVersion + 1, + }); + }, + + pasteInPlace: () => { + const { clipboard, activeLayerId } = get(); + if (!clipboard || clipboard.length === 0) return; + const pasted = clipboard.map( + (obj) => + ({ + ...obj, + id: generateId(), + layerId: activeLayerId, + }) as CanvasObject, + ); + set({ + objects: [...get().objects, ...pasted], + selectedObjectIds: pasted.map((o) => o.id), + isDirty: true, + lastAction: "Paste in Place", + _historyVersion: get()._historyVersion + 1, + }); + }, + + // Guides + addGuide: (orientation, position) => { + set({ + guides: [...get().guides, { id: generateId(), orientation, position }], + }); + }, + + removeGuide: (id) => { + set({ guides: get().guides.filter((g) => g.id !== id) }); + }, + + updateGuide: (id, position) => { + set({ + guides: get().guides.map((g) => (g.id === id ? { ...g, position } : g)), + }); + }, + + toggleSnapping: () => set({ snappingEnabled: !get().snappingEnabled }), + toggleRulers: () => set({ rulersVisible: !get().rulersVisible }), + toggleGuides: () => set({ guidesVisible: !get().guidesVisible }), + toggleGrid: () => set({ gridVisible: !get().gridVisible }), + + // Document + markDirty: () => set({ isDirty: true }), + markClean: () => set({ isDirty: false }), + setLoadingState: (state) => set({ loadingState: state }), + + // Right panel + setRightPanelTab: (tab) => set({ rightPanelTab: tab }), + toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }), + }), + { + partialize: (state) => ({ + layers: state.layers, + objects: state.objects, + canvasSize: state.canvasSize, + adjustments: state.adjustments, + filters: state.filters, + guides: state.guides, + sourceImageUrl: state.sourceImageUrl, + sourceImageSize: state.sourceImageSize, + _historyVersion: state._historyVersion, + }), + limit: MAX_HISTORY, + equality: (a, b) => + (a as { _historyVersion: number })._historyVersion === + (b as { _historyVersion: number })._historyVersion, + handleSet: (handleSet) => { + let timeout: ReturnType; + return (state) => { + clearTimeout(timeout); + timeout = setTimeout(() => handleSet(state), 500); + }; + }, + }, + ), +); diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts new file mode 100644 index 00000000..4515f7e2 --- /dev/null +++ b/apps/web/src/types/editor.ts @@ -0,0 +1,430 @@ +// apps/web/src/types/editor.ts + +export type ToolType = + | "move" + | "marquee-rect" + | "marquee-ellipse" + | "lasso-free" + | "lasso-poly" + | "magic-wand" + | "crop" + | "eyedropper" + | "brush" + | "eraser" + | "pencil" + | "clone-stamp" + | "dodge" + | "burn" + | "sponge" + | "blur-brush" + | "sharpen-brush" + | "smudge" + | "fill" + | "gradient" + | "shape-rect" + | "shape-ellipse" + | "shape-line" + | "shape-arrow" + | "shape-polygon" + | "shape-star" + | "text" + | "hand" + | "zoom" + | "transform"; + +export interface LineAttrs { + points: number[]; + stroke: string; + strokeWidth: number; + tension: number; + lineCap: "butt" | "round" | "square"; + lineJoin: "bevel" | "round" | "miter"; + opacity: number; + globalCompositeOperation: string; + shadowBlur?: number; + shadowColor?: string; + shadowOffsetX?: number; + shadowOffsetY?: number; +} + +export interface RectAttrs { + x: number; + y: number; + width: number; + height: number; + fill: string; + stroke: string; + strokeWidth: number; + cornerRadius: number; + rotation: number; + opacity: number; +} + +export interface EllipseAttrs { + x: number; + y: number; + radiusX: number; + radiusY: number; + fill: string; + stroke: string; + strokeWidth: number; + rotation: number; + opacity: number; +} + +export interface TextAttrs { + x: number; + y: number; + text: string; + fontFamily: string; + fontSize: number; + fontStyle: string; + fontVariant: string; + textDecoration: string; + align: "left" | "center" | "right"; + fill: string; + lineHeight: number; + letterSpacing: number; + width?: number; + height?: number; + wrap?: "word" | "char" | "none"; + rotation: number; + opacity: number; +} + +export interface ImageAttrs { + x: number; + y: number; + width: number; + height: number; + rotation: number; + opacity: number; + src: string; +} + +export interface ArrowAttrs { + points: number[]; + fill: string; + stroke: string; + strokeWidth: number; + pointerLength: number; + pointerWidth: number; + rotation: number; + opacity: number; +} + +export interface PolygonAttrs { + x: number; + y: number; + sides: number; + radius: number; + fill: string; + stroke: string; + strokeWidth: number; + rotation: number; + opacity: number; +} + +export interface StarAttrs { + x: number; + y: number; + numPoints: number; + innerRadius: number; + outerRadius: number; + fill: string; + stroke: string; + strokeWidth: number; + rotation: number; + opacity: number; +} + +export type CanvasObject = + | { id: string; type: "line"; layerId: string; attrs: LineAttrs; effects?: ObjectEffects } + | { id: string; type: "rect"; layerId: string; attrs: RectAttrs; effects?: ObjectEffects } + | { id: string; type: "ellipse"; layerId: string; attrs: EllipseAttrs; effects?: ObjectEffects } + | { id: string; type: "text"; layerId: string; attrs: TextAttrs; effects?: ObjectEffects } + | { id: string; type: "image"; layerId: string; attrs: ImageAttrs; effects?: ObjectEffects } + | { id: string; type: "arrow"; layerId: string; attrs: ArrowAttrs; effects?: ObjectEffects } + | { id: string; type: "polygon"; layerId: string; attrs: PolygonAttrs; effects?: ObjectEffects } + | { id: string; type: "star"; layerId: string; attrs: StarAttrs; effects?: ObjectEffects }; + +export interface ObjectEffects { + dropShadow?: { + enabled: boolean; + color: string; + opacity: number; + angle: number; + distance: number; + blur: number; + spread: number; + }; + innerShadow?: { + enabled: boolean; + color: string; + opacity: number; + angle: number; + distance: number; + blur: number; + }; + outerGlow?: { + enabled: boolean; + color: string; + opacity: number; + blur: number; + spread: number; + }; + stroke?: { + enabled: boolean; + color: string; + width: number; + position: "inside" | "center" | "outside"; + }; +} + +export interface EditorLayer { + id: string; + name: string; + visible: boolean; + locked: boolean; + opacity: number; + blendMode: string; + thumbnail: string | null; +} + +export interface SelectionState { + type: "rect" | "ellipse" | "lasso" | "wand"; + points: number[]; + bounds: { x: number; y: number; width: number; height: number }; + mask?: Uint8Array; +} + +export interface CropState { + x: number; + y: number; + width: number; + height: number; + aspectRatio: string | null; +} + +export interface AdjustmentValues { + brightness: number; + contrast: number; + hue: number; + saturation: number; + luminance: number; + exposure: number; + vibrance: number; + warmth: number; +} + +export interface FilterConfig { + type: string; + enabled: boolean; + params: Record; +} + +export interface Guide { + id: string; + orientation: "horizontal" | "vertical"; + position: number; +} + +export type AnchorPosition = + | "top-left" + | "top-center" + | "top-right" + | "center-left" + | "center" + | "center-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + +export interface LoadingState { + operation: string; + progress: number | null; + cancellable: boolean; +} + +export interface CloneSource { + x: number; + y: number; + aligned: boolean; +} + +export interface EditorState { + // Canvas + canvasSize: { width: number; height: number }; + zoom: number; + panOffset: { x: number; y: number }; + cursorPosition: { x: number; y: number }; + + // Image + sourceImageUrl: string | null; + sourceImageSize: { width: number; height: number } | null; + + // Active tool + activeTool: ToolType; + previousTool: ToolType | null; + + // Brush/Eraser settings + brushSize: number; + brushOpacity: number; + brushHardness: number; + + // Colors + foregroundColor: string; + backgroundColor: string; + recentColors: string[]; + + // Layers + layers: EditorLayer[]; + activeLayerId: string; + + // Objects + objects: CanvasObject[]; + selectedObjectIds: string[]; + + // Selection + selection: SelectionState | null; + + // Crop + cropState: CropState | null; + isCropping: boolean; + + // Adjustments & Filters + adjustments: AdjustmentValues; + filters: FilterConfig[]; + + // Text + editingTextId: string | null; + + // Shape settings + shapeFill: string; + shapeStroke: string; + shapeStrokeWidth: number; + shapeCornerRadius: number; + shapePolygonSides: number; + shapeStarPoints: number; + + // Clone stamp + cloneSource: CloneSource | null; + + // Dodge/Burn/Sponge + dodgeBurnRange: "shadows" | "midtones" | "highlights"; + dodgeBurnExposure: number; + spongeMode: "saturate" | "desaturate"; + spongeFlow: number; + + // UI + rightPanelTab: "layers" | "adjustments" | "history"; + rightPanelVisible: boolean; + isSpaceHeld: boolean; + + // Document state + isDirty: boolean; + lastAutoSave: number | null; + + // Clipboard + clipboard: CanvasObject[] | null; + + // Guides + guides: Guide[]; + snappingEnabled: boolean; + rulersVisible: boolean; + guidesVisible: boolean; + gridVisible: boolean; + + // Loading + loadingState: LoadingState | null; + + // History tracking + lastAction: string; + _historyVersion: number; + + // --- Actions --- + + // Tool + setTool: (tool: ToolType) => void; + setCursorPosition: (pos: { x: number; y: number }) => void; + + // Canvas + 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; + rotateCanvas: (degrees: 90 | 180 | 270) => void; + flipCanvasHorizontal: () => void; + flipCanvasVertical: () => void; + trimCanvas: () => void; + + // Colors + setForegroundColor: (color: string) => void; + setBackgroundColor: (color: string) => void; + swapColors: () => void; + resetColors: () => void; + + // Objects + addObject: (obj: CanvasObject) => void; + updateObject: (id: string, attrs: Partial) => void; + removeObjects: (ids: string[]) => void; + setSelectedObjects: (ids: string[]) => void; + bringToFront: (objectId: string) => void; + bringForward: (objectId: string) => void; + sendBackward: (objectId: string) => void; + sendToBack: (objectId: string) => void; + + // Layers + addLayer: () => void; + removeLayer: (id: string) => void; + duplicateLayer: (id: string) => void; + setActiveLayer: (id: string) => void; + updateLayer: (id: string, updates: Partial) => void; + reorderLayers: (fromIndex: number, toIndex: number) => void; + mergeDown: (id: string) => void; + flattenAll: () => void; + + // Adjustments & Filters + setAdjustment: (key: keyof AdjustmentValues, value: number) => void; + resetAdjustments: () => void; + toggleFilter: (type: string) => void; + setFilterParam: (type: string, key: string, value: number) => void; + + // Selection + setSelection: (selection: SelectionState | null) => void; + invertSelection: () => void; + + // Crop + setCropState: (state: CropState | null) => void; + applyCrop: () => void; + + // Brush + setBrushSize: (size: number) => void; + setBrushOpacity: (opacity: number) => void; + setBrushHardness: (hardness: number) => void; + + // Clipboard + copyObjects: () => void; + cutObjects: () => void; + pasteObjects: () => void; + pasteInPlace: () => void; + + // Guides + addGuide: (orientation: "horizontal" | "vertical", position: number) => void; + removeGuide: (id: string) => void; + updateGuide: (id: string, position: number) => void; + toggleSnapping: () => void; + toggleRulers: () => void; + toggleGuides: () => void; + toggleGrid: () => void; + + // Document state + markDirty: () => void; + markClean: () => void; + setLoadingState: (state: LoadingState | null) => void; + + // Right panel + setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void; + toggleRightPanel: () => void; +} From 155783ca8695ca18c0f0f7d6cafc55a4fa515097 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:08:32 +0800 Subject: [PATCH 03/65] feat: add canvas system and utility components for image editor Create canvas zoom hook with smooth Konva.Tween transitions, icon button component, cursor management with brush overlay, and editor canvas with checkerboard background, ResizeObserver sizing, and grid overlay. --- .../editor/common/custom-cursor.tsx | 87 ++++++++++ .../components/editor/common/icon-button.tsx | 52 ++++++ .../src/components/editor/editor-canvas.tsx | 161 ++++++++++++++++++ apps/web/src/hooks/use-canvas-zoom.ts | 108 ++++++++++++ 4 files changed, 408 insertions(+) create mode 100644 apps/web/src/components/editor/common/custom-cursor.tsx create mode 100644 apps/web/src/components/editor/common/icon-button.tsx create mode 100644 apps/web/src/components/editor/editor-canvas.tsx create mode 100644 apps/web/src/hooks/use-canvas-zoom.ts diff --git a/apps/web/src/components/editor/common/custom-cursor.tsx b/apps/web/src/components/editor/common/custom-cursor.tsx new file mode 100644 index 00000000..e6d364f5 --- /dev/null +++ b/apps/web/src/components/editor/common/custom-cursor.tsx @@ -0,0 +1,87 @@ +// apps/web/src/components/editor/common/custom-cursor.tsx +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +const TOOL_CURSORS: Record = { + move: "default", + "marquee-rect": "crosshair", + "marquee-ellipse": "crosshair", + "lasso-free": "crosshair", + "lasso-poly": "crosshair", + "magic-wand": "crosshair", + crop: "crosshair", + eyedropper: "crosshair", + brush: "none", + eraser: "none", + pencil: "none", + "clone-stamp": "none", + dodge: "none", + burn: "none", + sponge: "none", + "blur-brush": "none", + "sharpen-brush": "none", + smudge: "none", + fill: "crosshair", + gradient: "crosshair", + "shape-rect": "crosshair", + "shape-ellipse": "crosshair", + "shape-line": "crosshair", + "shape-arrow": "crosshair", + "shape-polygon": "crosshair", + "shape-star": "crosshair", + text: "text", + hand: "grab", + zoom: "zoom-in", + transform: "default", +}; + +const BRUSH_CURSOR_TOOLS = new Set([ + "brush", + "eraser", + "pencil", + "clone-stamp", + "dodge", + "burn", + "sponge", + "blur-brush", + "sharpen-brush", + "smudge", +]); + +export function useEditorCursor(): string { + const activeTool = useEditorStore((s) => s.activeTool); + const isSpaceHeld = useEditorStore((s) => s.isSpaceHeld); + if (isSpaceHeld) return "grab"; + return TOOL_CURSORS[activeTool] || "default"; +} + +interface BrushCursorOverlayProps { + containerRef: React.RefObject; +} + +export function BrushCursorOverlay({ containerRef: _containerRef }: BrushCursorOverlayProps) { + const activeTool = useEditorStore((s) => s.activeTool); + const brushSize = useEditorStore((s) => s.brushSize); + const zoom = useEditorStore((s) => s.zoom); + const cursorPosition = useEditorStore((s) => s.cursorPosition); + + if (!BRUSH_CURSOR_TOOLS.has(activeTool)) return null; + + const displaySize = brushSize * zoom; + const isEraser = activeTool === "eraser"; + + return ( +
+ ); +} diff --git a/apps/web/src/components/editor/common/icon-button.tsx b/apps/web/src/components/editor/common/icon-button.tsx new file mode 100644 index 00000000..04e503a1 --- /dev/null +++ b/apps/web/src/components/editor/common/icon-button.tsx @@ -0,0 +1,52 @@ +// apps/web/src/components/editor/common/icon-button.tsx +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface IconButtonProps { + icon: LucideIcon; + label: string; + shortcut?: string; + active?: boolean; + disabled?: boolean; + size?: number; + onClick?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; + className?: string; + "data-testid"?: string; + "data-tool"?: string; + "data-tool-active"?: string; +} + +export function IconButton({ + icon: Icon, + label, + shortcut, + active, + disabled, + size = 18, + onClick, + onContextMenu, + className, + ...dataProps +}: IconButtonProps) { + return ( + + ); +} diff --git a/apps/web/src/components/editor/editor-canvas.tsx b/apps/web/src/components/editor/editor-canvas.tsx new file mode 100644 index 00000000..1da80667 --- /dev/null +++ b/apps/web/src/components/editor/editor-canvas.tsx @@ -0,0 +1,161 @@ +// apps/web/src/components/editor/editor-canvas.tsx + +import type Konva from "konva"; +import React, { useCallback, useEffect, useRef } from "react"; +import { Layer, Shape, Stage } from "react-konva"; +import { useCanvasZoom } from "@/hooks/use-canvas-zoom"; +import { useEditorStore } from "@/stores/editor-store"; +import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor"; + +const CHECKERBOARD_SIZE = 20; +const CHECKERBOARD_CSS = ` + repeating-conic-gradient( + rgba(128, 128, 128, 0.15) 0% 25%, + transparent 0% 50% + ) +`; + +export function EditorCanvas() { + const containerRef = useRef(null); + const { stageRef, handleWheel, fitToScreen } = useCanvasZoom(); + + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + const canvasSize = useEditorStore((s) => s.canvasSize); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + const setCursorPosition = useEditorStore((s) => s.setCursorPosition); + const gridVisible = useEditorStore((s) => s.gridVisible); + + const cursor = useEditorCursor(); + + const [stageWidth, setStageWidth] = React.useState(800); + const [stageHeight, setStageHeight] = React.useState(600); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + setStageWidth(width); + setStageHeight(height); + }); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (sourceImageUrl && stageWidth > 0 && stageHeight > 0) { + fitToScreen(stageWidth, stageHeight, canvasSize.width, canvasSize.height); + } + }, [sourceImageUrl, stageWidth, stageHeight, canvasSize.width, canvasSize.height, fitToScreen]); + + const handleMouseMove = useCallback( + (e: Konva.KonvaEventObject) => { + const stage = e.target.getStage(); + if (!stage) return; + const pointer = stage.getPointerPosition(); + if (!pointer) return; + const x = Math.round((pointer.x - panOffset.x) / zoom); + const y = Math.round((pointer.y - panOffset.y) / zoom); + setCursorPosition({ x, y }); + }, + [zoom, panOffset, setCursorPosition], + ); + + const checkerboardSize = CHECKERBOARD_SIZE * zoom; + + return ( +
+ + {/* Canvas objects are rendered here by tool components */} + {/* Grid overlay layer (Feature 49) - non-interactive */} + {(gridVisible || zoom >= 8) && ( + + = 8} + /> + + )} + + +
+ ); +} + +function GridOverlay({ + canvasWidth, + canvasHeight, + zoom, + showGrid, + showPixelGrid, +}: { + canvasWidth: number; + canvasHeight: number; + zoom: number; + showGrid: boolean; + showPixelGrid: boolean; +}) { + return ( + { + ctx.beginPath(); + + if (showGrid) { + const spacing = 50; + ctx.strokeStyle = "rgba(128, 128, 128, 0.15)"; + ctx.lineWidth = 1 / zoom; + for (let x = spacing; x < canvasWidth; x += spacing) { + ctx.moveTo(x, 0); + ctx.lineTo(x, canvasHeight); + } + for (let y = spacing; y < canvasHeight; y += spacing) { + ctx.moveTo(0, y); + ctx.lineTo(canvasWidth, y); + } + ctx.stroke(); + } + + if (showPixelGrid) { + ctx.beginPath(); + ctx.strokeStyle = "rgba(128, 128, 128, 0.1)"; + ctx.lineWidth = 1 / zoom; + for (let x = 1; x < canvasWidth; x++) { + ctx.moveTo(x, 0); + ctx.lineTo(x, canvasHeight); + } + for (let y = 1; y < canvasHeight; y++) { + ctx.moveTo(0, y); + ctx.lineTo(canvasWidth, y); + } + ctx.stroke(); + } + + ctx.fillStrokeShape(shape); + }} + /> + ); +} diff --git a/apps/web/src/hooks/use-canvas-zoom.ts b/apps/web/src/hooks/use-canvas-zoom.ts new file mode 100644 index 00000000..91ac3e79 --- /dev/null +++ b/apps/web/src/hooks/use-canvas-zoom.ts @@ -0,0 +1,108 @@ +// apps/web/src/hooks/use-canvas-zoom.ts + +import Konva from "konva"; +import { useCallback, useRef } from "react"; +import { useEditorStore } from "@/stores/editor-store"; + +const ZOOM_SENSITIVITY = 1.1; +const ZOOM_ANIMATION_DURATION = 0.15; + +export function useCanvasZoom() { + const stageRef = useRef(null); + const setZoom = useEditorStore((s) => s.setZoom); + const setPanOffset = useEditorStore((s) => s.setPanOffset); + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + const tweenRef = useRef(null); + + const animateZoom = useCallback( + (targetZoom: number, targetPos: { x: number; y: number }) => { + const stage = stageRef.current; + if (!stage) { + setZoom(targetZoom); + setPanOffset(targetPos); + return; + } + if (tweenRef.current) { + tweenRef.current.destroy(); + } + tweenRef.current = new Konva.Tween({ + node: stage, + scaleX: targetZoom, + scaleY: targetZoom, + x: targetPos.x, + y: targetPos.y, + duration: ZOOM_ANIMATION_DURATION, + easing: Konva.Easings.EaseOut, + onFinish: () => { + setZoom(targetZoom); + setPanOffset(targetPos); + tweenRef.current = null; + }, + }); + tweenRef.current.play(); + }, + [setZoom, setPanOffset], + ); + + const handleWheel = useCallback( + (e: Konva.KonvaEventObject) => { + e.evt.preventDefault(); + const stage = stageRef.current; + if (!stage) return; + + const isZoom = e.evt.ctrlKey || e.evt.metaKey; + + if (isZoom) { + const pointer = stage.getPointerPosition(); + if (!pointer) return; + + const oldZoom = zoom; + const direction = e.evt.deltaY < 0 ? 1 : -1; + const newZoom = direction > 0 ? oldZoom * ZOOM_SENSITIVITY : oldZoom / ZOOM_SENSITIVITY; + const clampedZoom = Math.max(0.01, Math.min(64, newZoom)); + + const mousePointTo = { + x: (pointer.x - panOffset.x) / oldZoom, + y: (pointer.y - panOffset.y) / oldZoom, + }; + + const newPos = { + x: pointer.x - mousePointTo.x * clampedZoom, + y: pointer.y - mousePointTo.y * clampedZoom, + }; + + animateZoom(clampedZoom, newPos); + } else { + const dx = e.evt.shiftKey ? -e.evt.deltaY : -e.evt.deltaX; + const dy = e.evt.shiftKey ? 0 : -e.evt.deltaY; + setPanOffset({ x: panOffset.x + dx, y: panOffset.y + dy }); + } + }, + [zoom, panOffset, setPanOffset, animateZoom], + ); + + const fitToScreen = useCallback( + (viewportWidth: number, viewportHeight: number, imageWidth: number, imageHeight: number) => { + const scaleX = viewportWidth / imageWidth; + const scaleY = viewportHeight / imageHeight; + const fitZoom = Math.min(scaleX, scaleY) * 0.9; + const offsetX = (viewportWidth - imageWidth * fitZoom) / 2; + const offsetY = (viewportHeight - imageHeight * fitZoom) / 2; + animateZoom(fitZoom, { x: offsetX, y: offsetY }); + }, + [animateZoom], + ); + + const zoomTo = useCallback( + (targetZoom: number, viewportWidth: number, viewportHeight: number) => { + const canvasSize = useEditorStore.getState().canvasSize; + const offsetX = (viewportWidth - canvasSize.width * targetZoom) / 2; + const offsetY = (viewportHeight - canvasSize.height * targetZoom) / 2; + animateZoom(targetZoom, { x: offsetX, y: offsetY }); + }, + [animateZoom], + ); + + return { stageRef, handleWheel, fitToScreen, zoomTo }; +} From 08a2119564df0c57db40aa86abba8aa86ab8bdb6 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:12:59 +0800 Subject: [PATCH 04/65] feat: add editor layout components, welcome screen, and editor page Create the four-zone editor layout shell (toolbar, options bar, status bar, right panel), welcome screen with drag-and-drop and new document dialog, and the main editor page with clipboard paste and URL loading. --- .../editor/common/new-document-dialog.tsx | 164 ++++++++++++++++ .../editor/common/welcome-screen.tsx | 104 ++++++++++ .../components/editor/editor-options-bar.tsx | 17 ++ .../components/editor/editor-right-panel.tsx | 67 +++++++ .../components/editor/editor-status-bar.tsx | 42 ++++ .../src/components/editor/editor-toolbar.tsx | 183 ++++++++++++++++++ apps/web/src/pages/editor-page.tsx | 76 ++++++++ 7 files changed, 653 insertions(+) create mode 100644 apps/web/src/components/editor/common/new-document-dialog.tsx create mode 100644 apps/web/src/components/editor/common/welcome-screen.tsx create mode 100644 apps/web/src/components/editor/editor-options-bar.tsx create mode 100644 apps/web/src/components/editor/editor-right-panel.tsx create mode 100644 apps/web/src/components/editor/editor-status-bar.tsx create mode 100644 apps/web/src/components/editor/editor-toolbar.tsx create mode 100644 apps/web/src/pages/editor-page.tsx diff --git a/apps/web/src/components/editor/common/new-document-dialog.tsx b/apps/web/src/components/editor/common/new-document-dialog.tsx new file mode 100644 index 00000000..7e429def --- /dev/null +++ b/apps/web/src/components/editor/common/new-document-dialog.tsx @@ -0,0 +1,164 @@ +// apps/web/src/components/editor/common/new-document-dialog.tsx +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +const PRESETS = [ + { label: "Custom", width: 1920, height: 1080 }, + { label: "1920x1080 (HD)", width: 1920, height: 1080 }, + { label: "3840x2160 (4K)", width: 3840, height: 2160 }, + { label: "1080x1080 (Instagram)", width: 1080, height: 1080 }, + { label: "1200x628 (Facebook)", width: 1200, height: 628 }, + { label: "800x600", width: 800, height: 600 }, + { label: "1280x720", width: 1280, height: 720 }, +]; + +const BACKGROUNDS = ["White", "Black", "Transparent"] as const; + +interface NewDocumentDialogProps { + open: boolean; + onClose: () => void; +} + +export function NewDocumentDialog({ open, onClose }: NewDocumentDialogProps) { + const [width, setWidth] = useState(1920); + const [height, setHeight] = useState(1080); + const [preset, setPreset] = useState("1920x1080 (HD)"); + const [background, setBackground] = useState<(typeof BACKGROUNDS)[number]>("White"); + const loadImage = useEditorStore((s) => s.loadImage); + + if (!open) return null; + + const handlePresetChange = (e: React.ChangeEvent) => { + const selected = PRESETS.find((p) => p.label === e.target.value); + if (selected) { + setPreset(selected.label); + if (selected.label !== "Custom") { + setWidth(selected.width); + setHeight(selected.height); + } + } + }; + + const handleCreate = () => { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (ctx) { + if (background === "White") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + } else if (background === "Black") { + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, width, height); + } + } + const url = canvas.toDataURL("image/png"); + loadImage(url, width, height); + onClose(); + }; + + return ( +
+
+

New Document

+ +
+
+ + +
+ +
+
+ + { + setWidth(Number(e.target.value)); + setPreset("Custom"); + }} + className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground" + min={1} + max={10000} + /> +
+
+ + { + setHeight(Number(e.target.value)); + setPreset("Custom"); + }} + className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground" + min={1} + max={10000} + /> +
+
+ +
+ Background +
+ {BACKGROUNDS.map((bg) => ( + + ))} +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/welcome-screen.tsx b/apps/web/src/components/editor/common/welcome-screen.tsx new file mode 100644 index 00000000..2c7ffe68 --- /dev/null +++ b/apps/web/src/components/editor/common/welcome-screen.tsx @@ -0,0 +1,104 @@ +// apps/web/src/components/editor/common/welcome-screen.tsx + +import { FilePlus, ImagePlus } from "lucide-react"; +import { useCallback, useState } from "react"; +import { useEditorStore } from "@/stores/editor-store"; +import { NewDocumentDialog } from "./new-document-dialog"; + +const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg"; + +export function WelcomeScreen() { + const [showNewDoc, setShowNewDoc] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const loadImage = useEditorStore((s) => s.loadImage); + + const handleFile = useCallback( + (file: File) => { + if (!file.type.startsWith("image/")) { + return; + } + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + loadImage(url, img.naturalWidth, img.naturalHeight); + }; + img.src = url; + }, + [loadImage], + ); + + const handleOpenFile = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ACCEPTED_TYPES; + input.onchange = () => { + const file = input.files?.[0]; + if (file) handleFile(file); + }; + input.click(); + }; + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }, + [handleFile], + ); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = () => setIsDragOver(false); + + return ( + <> +
+
+
+

Image Editor

+

Drop an image here to get started

+
+ +
+ + + +
+ +

Or paste from clipboard (Ctrl+V)

+
+
+ + setShowNewDoc(false)} /> + + ); +} diff --git a/apps/web/src/components/editor/editor-options-bar.tsx b/apps/web/src/components/editor/editor-options-bar.tsx new file mode 100644 index 00000000..f02288ff --- /dev/null +++ b/apps/web/src/components/editor/editor-options-bar.tsx @@ -0,0 +1,17 @@ +// apps/web/src/components/editor/editor-options-bar.tsx +import { useEditorStore } from "@/stores/editor-store"; + +export function EditorOptionsBar() { + const activeTool = useEditorStore((s) => s.activeTool); + + return ( +
+ + {activeTool.replace(/-/g, " ").replace(/^shape /, "")} + +
+ {/* Tool-specific option components are rendered here by each agent */} +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-right-panel.tsx b/apps/web/src/components/editor/editor-right-panel.tsx new file mode 100644 index 00000000..db63ace7 --- /dev/null +++ b/apps/web/src/components/editor/editor-right-panel.tsx @@ -0,0 +1,67 @@ +// apps/web/src/components/editor/editor-right-panel.tsx +import { ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +const TABS = [ + { id: "layers" as const, label: "Layers" }, + { id: "adjustments" as const, label: "Adjustments" }, + { id: "history" as const, label: "History" }, +]; + +export function EditorRightPanel() { + const visible = useEditorStore((s) => s.rightPanelVisible); + const activeTab = useEditorStore((s) => s.rightPanelTab); + const setTab = useEditorStore((s) => s.setRightPanelTab); + const togglePanel = useEditorStore((s) => s.toggleRightPanel); + + if (!visible) { + return ( + + ); + } + + return ( +
+
+ {TABS.map((tab) => ( + + ))} + +
+
+ {/* Tab content rendered by agents: layers-panel, adjustments-panel, history-panel */} +
+
+ {/* Color panel always visible at bottom (Agent 5) */} +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-status-bar.tsx b/apps/web/src/components/editor/editor-status-bar.tsx new file mode 100644 index 00000000..0dae20af --- /dev/null +++ b/apps/web/src/components/editor/editor-status-bar.tsx @@ -0,0 +1,42 @@ +// apps/web/src/components/editor/editor-status-bar.tsx +import { useEditorStore } from "@/stores/editor-store"; + +export function EditorStatusBar() { + const cursorPosition = useEditorStore((s) => s.cursorPosition); + const canvasSize = useEditorStore((s) => s.canvasSize); + const zoom = useEditorStore((s) => s.zoom); + const setZoom = useEditorStore((s) => s.setZoom); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + const zoomPercent = Math.round(zoom * 100); + + return ( +
+
+ {sourceImageUrl && ( + <> + X: {cursorPosition.x} + Y: {cursorPosition.y} + + )} +
+
+ {sourceImageUrl && `${canvasSize.width} x ${canvasSize.height} px`} +
+
+ { + const val = Number.parseInt(e.target.value, 10); + if (!Number.isNaN(val) && val > 0) setZoom(val / 100); + }} + className="w-14 bg-transparent text-right text-xs border-none outline-none" + min={1} + max={6400} + /> + % +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-toolbar.tsx b/apps/web/src/components/editor/editor-toolbar.tsx new file mode 100644 index 00000000..e128edec --- /dev/null +++ b/apps/web/src/components/editor/editor-toolbar.tsx @@ -0,0 +1,183 @@ +// apps/web/src/components/editor/editor-toolbar.tsx +import { + ArrowUpRight, + Crop, + Eraser, + Hand, + MousePointer2, + Move, + PaintBucket, + Paintbrush, + Pen, + Pencil, + Pipette, + ScanLine, + Square, + Stamp, + Sun, + Type, + Wand2, + ZoomIn, +} from "lucide-react"; +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; +import { IconButton } from "./common/icon-button"; + +interface ToolGroup { + tools: { + tool: ToolType; + icon: typeof MousePointer2; + label: string; + shortcut: string; + }[]; +} + +const TOOL_GROUPS: ToolGroup[] = [ + { + // Group 1: Move + Free Transform + tools: [ + { tool: "move", icon: MousePointer2, label: "Move", shortcut: "V" }, + { + tool: "transform", + icon: Move, + label: "Free Transform", + shortcut: "Ctrl+T", + }, + ], + }, + { + // Group 2: Selection tools + tools: [ + { + tool: "marquee-rect", + icon: Square, + label: "Marquee", + shortcut: "M", + }, + { tool: "lasso-free", icon: Pen, label: "Lasso", shortcut: "L" }, + { + tool: "magic-wand", + icon: Wand2, + label: "Magic Wand", + shortcut: "W", + }, + ], + }, + { + // Group 3: Crop + tools: [{ tool: "crop", icon: Crop, label: "Crop", shortcut: "C" }], + }, + { + // Group 4: Eyedropper + tools: [ + { + tool: "eyedropper", + icon: Pipette, + label: "Eyedropper", + shortcut: "I", + }, + ], + }, + { + // Group 5: Brush, Eraser, Pencil + tools: [ + { tool: "brush", icon: Paintbrush, label: "Brush", shortcut: "B" }, + { tool: "eraser", icon: Eraser, label: "Eraser", shortcut: "E" }, + { tool: "pencil", icon: Pencil, label: "Pencil", shortcut: "N" }, + ], + }, + { + // Group 6: Clone Stamp + tools: [ + { + tool: "clone-stamp", + icon: Stamp, + label: "Clone Stamp", + shortcut: "S", + }, + ], + }, + { + // Group 7: Dodge, Burn, Sponge + tools: [ + { tool: "dodge", icon: Sun, label: "Dodge", shortcut: "O" }, + { tool: "burn", icon: Sun, label: "Burn", shortcut: "Shift+O" }, + { tool: "sponge", icon: Sun, label: "Sponge", shortcut: "Shift+O" }, + ], + }, + { + // Group 8: Blur brush, Sharpen brush, Smudge + tools: [ + { tool: "blur-brush", icon: ScanLine, label: "Blur Brush", shortcut: "" }, + { + tool: "sharpen-brush", + icon: ScanLine, + label: "Sharpen Brush", + shortcut: "", + }, + { tool: "smudge", icon: ScanLine, label: "Smudge", shortcut: "" }, + ], + }, + { + // Group 9: Paint Bucket, Gradient + tools: [ + { + tool: "fill", + icon: PaintBucket, + label: "Paint Bucket", + shortcut: "G", + }, + { + tool: "gradient", + icon: ArrowUpRight, + label: "Gradient", + shortcut: "Shift+G", + }, + ], + }, + { + // Group 10: Shapes + tools: [{ tool: "shape-rect", icon: Square, label: "Shape", shortcut: "U" }], + }, + { + // Group 11: Text + tools: [{ tool: "text", icon: Type, label: "Text", shortcut: "T" }], + }, + { + // Group 12: Hand, Zoom + tools: [ + { tool: "hand", icon: Hand, label: "Hand", shortcut: "H" }, + { tool: "zoom", icon: ZoomIn, label: "Zoom", shortcut: "Z" }, + ], + }, +]; + +export function EditorToolbar() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + return ( +
+ {TOOL_GROUPS.map((group, gi) => ( +
+ {gi > 0 &&
} + {group.tools.map((t) => ( + setTool(t.tool)} + data-testid={`tool-${t.tool}`} + data-tool={t.tool} + data-tool-active={String(activeTool === t.tool)} + /> + ))} +
+ ))} +
+ ); +} diff --git a/apps/web/src/pages/editor-page.tsx b/apps/web/src/pages/editor-page.tsx new file mode 100644 index 00000000..afeb9534 --- /dev/null +++ b/apps/web/src/pages/editor-page.tsx @@ -0,0 +1,76 @@ +// apps/web/src/pages/editor-page.tsx +import { useCallback, useEffect } from "react"; +import { WelcomeScreen } from "@/components/editor/common/welcome-screen"; +import { EditorCanvas } from "@/components/editor/editor-canvas"; +import { EditorOptionsBar } from "@/components/editor/editor-options-bar"; +import { EditorRightPanel } from "@/components/editor/editor-right-panel"; +import { EditorStatusBar } from "@/components/editor/editor-status-bar"; +import { EditorToolbar } from "@/components/editor/editor-toolbar"; +import { useEditorStore } from "@/stores/editor-store"; + +export function EditorPage() { + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + const isDirty = useEditorStore((s) => s.isDirty); + const loadImage = useEditorStore((s) => s.loadImage); + + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (isDirty) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + + const handlePaste = useCallback( + (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith("image/")) { + e.preventDefault(); + const blob = item.getAsFile(); + if (!blob) return; + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight); + img.src = url; + return; + } + } + }, + [loadImage], + ); + + useEffect(() => { + document.addEventListener("paste", handlePaste); + return () => document.removeEventListener("paste", handlePaste); + }, [handlePaste]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const url = params.get("url"); + if (url) { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight); + img.src = url; + } + }, [loadImage]); + + return ( +
+ +
+ +
+ + {!sourceImageUrl && } +
+ +
+ +
+ ); +} From 9eae8369bbab7ef40538e0343acf708fe2897531 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:14:30 +0800 Subject: [PATCH 05/65] feat: wire editor route, sidebar, mobile nav, and i18n --- apps/web/src/App.tsx | 4 ++++ apps/web/src/components/layout/app-layout.tsx | 11 ++++++++++- apps/web/src/components/layout/sidebar.tsx | 11 ++++++++++- packages/shared/src/i18n/en.ts | 1 + 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c3740656..0e834fd0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -28,6 +28,9 @@ const PrivacyPolicyPage = lazy(() => const AnalyticsConsentPage = lazy(() => import("./pages/analytics-consent-page").then((m) => ({ default: m.AnalyticsConsentPage })), ); +const EditorPage = lazy(() => + import("./pages/editor-page").then((m) => ({ default: m.EditorPage })), +); const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage }))); class ErrorBoundary extends Component< @@ -222,6 +225,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/web/src/components/layout/app-layout.tsx b/apps/web/src/components/layout/app-layout.tsx index 5e9c57f9..f8be23fe 100644 --- a/apps/web/src/components/layout/app-layout.tsx +++ b/apps/web/src/components/layout/app-layout.tsx @@ -1,4 +1,12 @@ -import { FolderOpen, LayoutGrid, Menu, Settings as SettingsIcon, Workflow, X } from "lucide-react"; +import { + FolderOpen, + LayoutGrid, + Menu, + PenTool, + Settings as SettingsIcon, + Workflow, + X, +} from "lucide-react"; import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useMobile } from "@/hooks/use-mobile"; @@ -153,6 +161,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
- {/* Tab content rendered by agents: layers-panel, adjustments-panel, history-panel */} -
+ {activeTab === "layers" && } + {activeTab !== "layers" &&
}
{/* Color panel always visible at bottom (Agent 5) */}
diff --git a/apps/web/src/components/editor/panels/layers-panel.tsx b/apps/web/src/components/editor/panels/layers-panel.tsx new file mode 100644 index 00000000..4d89b154 --- /dev/null +++ b/apps/web/src/components/editor/panels/layers-panel.tsx @@ -0,0 +1,1009 @@ +// apps/web/src/components/editor/panels/layers-panel.tsx + +import { + ChevronDown, + ChevronRight, + Copy, + Eye, + EyeOff, + Layers, + Lock, + MergeIcon, + Plus, + Trash2, + Unlock, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { EditorLayer, ObjectEffects } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const BLEND_MODES = [ + { value: "source-over", label: "Normal" }, + { value: "multiply", label: "Multiply" }, + { value: "screen", label: "Screen" }, + { value: "overlay", label: "Overlay" }, + { value: "darken", label: "Darken" }, + { value: "lighten", label: "Lighten" }, + { value: "color-dodge", label: "Color Dodge" }, + { value: "color-burn", label: "Color Burn" }, + { value: "hard-light", label: "Hard Light" }, + { value: "soft-light", label: "Soft Light" }, + { value: "difference", label: "Difference" }, + { value: "exclusion", label: "Exclusion" }, + { value: "hue", label: "Hue" }, + { value: "saturation", label: "Saturation" }, + { value: "color", label: "Color" }, + { value: "luminosity", label: "Luminosity" }, +] as const; + +const DEFAULT_DROP_SHADOW: NonNullable = { + enabled: true, + color: "#000000", + opacity: 75, + angle: 135, + distance: 5, + blur: 10, + spread: 0, +}; + +const DEFAULT_INNER_SHADOW: NonNullable = { + enabled: true, + color: "#000000", + opacity: 75, + angle: 135, + distance: 5, + blur: 10, +}; + +const DEFAULT_OUTER_GLOW: NonNullable = { + enabled: true, + color: "#ffffff", + opacity: 75, + blur: 10, + spread: 0, +}; + +const DEFAULT_STROKE: NonNullable = { + enabled: true, + color: "#000000", + width: 2, + position: "outside", +}; + +// --------------------------------------------------------------------------- +// LayersPanel +// --------------------------------------------------------------------------- + +export function LayersPanel() { + const layers = useEditorStore((s) => s.layers); + const activeLayerId = useEditorStore((s) => s.activeLayerId); + const objects = useEditorStore((s) => s.objects); + const addLayer = useEditorStore((s) => s.addLayer); + const removeLayer = useEditorStore((s) => s.removeLayer); + const duplicateLayer = useEditorStore((s) => s.duplicateLayer); + const setActiveLayer = useEditorStore((s) => s.setActiveLayer); + const updateLayer = useEditorStore((s) => s.updateLayer); + const reorderLayers = useEditorStore((s) => s.reorderLayers); + const mergeDown = useEditorStore((s) => s.mergeDown); + const flattenAll = useEditorStore((s) => s.flattenAll); + + const activeLayer = layers.find((l) => l.id === activeLayerId); + + // Context menu + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + layerId: string; + } | null>(null); + + const closeContextMenu = useCallback(() => setContextMenu(null), []); + + useEffect(() => { + if (contextMenu) { + const handler = () => closeContextMenu(); + document.addEventListener("click", handler); + document.addEventListener("contextmenu", handler); + return () => { + document.removeEventListener("click", handler); + document.removeEventListener("contextmenu", handler); + }; + } + }, [contextMenu, closeContextMenu]); + + const handleContextMenu = useCallback((e: React.MouseEvent, layerId: string) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, layerId }); + }, []); + + // Active layer effects from objects + const activeLayerObjects = objects.filter((o) => o.layerId === activeLayerId); + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const updateObject = useEditorStore((s) => s.updateObject); + + // Get the first selected object on the active layer for effects editing + const selectedObject = activeLayerObjects.find((o) => selectedObjectIds.includes(o.id)); + const objectEffects = selectedObject?.effects; + + const handleEffectChange = useCallback( + (effectKey: keyof ObjectEffects, updates: Record) => { + if (!selectedObject) return; + const currentEffects = selectedObject.effects || {}; + const currentEffect = currentEffects[effectKey] || {}; + updateObject(selectedObject.id, { + effects: { + ...currentEffects, + [effectKey]: { ...currentEffect, ...updates }, + }, + } as never); + }, + [selectedObject, updateObject], + ); + + // Displayed layers: newest (highest index) first + const displayLayers = [...layers].reverse(); + + return ( +
+ {/* Top row: blend mode + opacity for active layer */} +
+ { + if (activeLayerId) updateLayer(activeLayerId, { blendMode: mode }); + }} + /> + { + if (activeLayerId) updateLayer(activeLayerId, { opacity }); + }} + /> +
+ + {/* Layer list */} +
+ {displayLayers.map((layer) => { + const realIndex = layers.findIndex((l) => l.id === layer.id); + return ( + setActiveLayer(layer.id)} + onToggleVisibility={() => updateLayer(layer.id, { visible: !layer.visible })} + onToggleLock={() => updateLayer(layer.id, { locked: !layer.locked })} + onRename={(name) => updateLayer(layer.id, { name })} + onReorder={reorderLayers} + onContextMenu={(e) => handleContextMenu(e, layer.id)} + /> + ); + })} +
+ + {/* Layer effects section (only when an object is selected) */} + {selectedObject && ( +
+ +
+ )} + + {/* Bottom action bar */} +
+ + +
+ + {/* Context menu */} + {contextMenu && ( + l.id === contextMenu.layerId)} + onDuplicate={() => { + duplicateLayer(contextMenu.layerId); + closeContextMenu(); + }} + onMergeDown={() => { + mergeDown(contextMenu.layerId); + closeContextMenu(); + }} + onFlattenAll={() => { + flattenAll(); + closeContextMenu(); + }} + onDelete={() => { + removeLayer(contextMenu.layerId); + closeContextMenu(); + }} + onClose={closeContextMenu} + /> + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// BlendModeSelect +// --------------------------------------------------------------------------- + +function BlendModeSelect({ value, onChange }: { value: string; onChange: (mode: string) => void }) { + return ( +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// OpacitySlider +// --------------------------------------------------------------------------- + +function OpacitySlider({ value, onChange }: { value: number; onChange: (v: number) => void }) { + const percent = Math.round(value * 100); + return ( +
+ + onChange(Number(e.target.value) / 100)} + className="flex-1" + data-testid="layer-opacity-slider" + /> + + {percent}% + +
+ ); +} + +// --------------------------------------------------------------------------- +// LayerRow +// --------------------------------------------------------------------------- + +interface LayerRowProps { + layer: EditorLayer; + realIndex: number; + totalLayers: number; + isActive: boolean; + onSelect: () => void; + onToggleVisibility: () => void; + onToggleLock: () => void; + onRename: (name: string) => void; + onReorder: (from: number, to: number) => void; + onContextMenu: (e: React.MouseEvent) => void; +} + +function LayerRow({ + layer, + realIndex, + totalLayers, + isActive, + onSelect, + onToggleVisibility, + onToggleLock, + onRename, + onReorder, + onContextMenu, +}: LayerRowProps) { + const [editing, setEditing] = useState(false); + const [editName, setEditName] = useState(layer.name); + const inputRef = useRef(null); + const rowRef = useRef(null); + + // Drag reorder state + const dragState = useRef<{ + startY: number; + startIndex: number; + currentIndex: number; + dragging: boolean; + } | null>(null); + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editing]); + + const commitRename = useCallback(() => { + const trimmed = editName.trim(); + if (trimmed && trimmed !== layer.name) { + onRename(trimmed); + } else { + setEditName(layer.name); + } + setEditing(false); + }, [editName, layer.name, onRename]); + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setEditing(true); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + commitRename(); + } else if (e.key === "Escape") { + setEditName(layer.name); + setEditing(false); + } + }, + [commitRename, layer.name], + ); + + // Pointer-based drag reorder + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (editing || e.button !== 0) return; + onSelect(); + + const rowEl = rowRef.current; + if (!rowEl) return; + + const startY = e.clientY; + dragState.current = { + startY, + startIndex: realIndex, + currentIndex: realIndex, + dragging: false, + }; + + const handlePointerMove = (ev: PointerEvent) => { + if (!dragState.current) return; + const dy = ev.clientY - dragState.current.startY; + if (!dragState.current.dragging && Math.abs(dy) < 4) return; + dragState.current.dragging = true; + rowEl.style.opacity = "0.6"; + + // Compute target index based on vertical movement + // Since display is reversed, moving down visually means moving to a lower real index + const rowHeight = 44; + const displayOffset = Math.round(dy / rowHeight); + // Display is reversed: visual down = lower real index + const newRealIndex = Math.max(0, Math.min(totalLayers - 1, realIndex - displayOffset)); + dragState.current.currentIndex = newRealIndex; + }; + + const handlePointerUp = () => { + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + rowEl.style.opacity = ""; + + if ( + dragState.current?.dragging && + dragState.current.currentIndex !== dragState.current.startIndex + ) { + onReorder(dragState.current.startIndex, dragState.current.currentIndex); + } + dragState.current = null; + }; + + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerUp); + }, + [editing, realIndex, totalLayers, onSelect, onReorder], + ); + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }} + > + {/* Visibility toggle */} + + + {/* Lock toggle */} + + + {/* Thumbnail */} +
+ {layer.thumbnail ? ( + {`${layer.name} + ) : ( + + )} +
+ + {/* Name */} +
+ {editing ? ( + setEditName(e.target.value)} + onBlur={commitRename} + onKeyDown={handleKeyDown} + onPointerDown={(e) => e.stopPropagation()} + className="w-full text-xs bg-muted border border-border rounded px-1 py-0.5 text-foreground outline-none focus:ring-1 focus:ring-primary" + data-testid={`layer-name-input-${layer.id}`} + /> + ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// LayerContextMenu +// --------------------------------------------------------------------------- + +interface LayerContextMenuProps { + x: number; + y: number; + layerId: string; + layerCount: number; + layerIndex: number; + onDuplicate: () => void; + onMergeDown: () => void; + onFlattenAll: () => void; + onDelete: () => void; + onClose: () => void; +} + +function LayerContextMenu({ + x, + y, + layerCount, + layerIndex, + onDuplicate, + onMergeDown, + onFlattenAll, + onDelete, +}: LayerContextMenuProps) { + const menuRef = useRef(null); + + // Adjust position to stay within viewport + const [pos, setPos] = useState({ x, y }); + useEffect(() => { + if (menuRef.current) { + const rect = menuRef.current.getBoundingClientRect(); + const nx = x + rect.width > window.innerWidth ? x - rect.width : x; + const ny = y + rect.height > window.innerHeight ? y - rect.height : y; + setPos({ x: Math.max(0, nx), y: Math.max(0, ny) }); + } + }, [x, y]); + + const items = [ + { + label: "Duplicate", + icon: Copy, + action: onDuplicate, + disabled: false, + }, + { + label: "Merge Down", + icon: MergeIcon, + action: onMergeDown, + disabled: layerIndex <= 0, + }, + { + label: "Flatten All", + icon: Layers, + action: onFlattenAll, + disabled: layerCount <= 1, + }, + { + label: "Delete", + icon: Trash2, + action: onDelete, + disabled: layerCount <= 1, + }, + ]; + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// LayerEffectsSection +// --------------------------------------------------------------------------- + +function LayerEffectsSection({ + effects, + onChange, +}: { + effects: ObjectEffects | undefined; + onChange: (key: keyof ObjectEffects, updates: Record) => void; +}) { + return ( +
+

+ Layer Effects +

+ + + onChange("dropShadow", { + ...DEFAULT_DROP_SHADOW, + ...effects?.dropShadow, + enabled, + }) + } + > + {effects?.dropShadow?.enabled && ( + onChange("dropShadow", updates)} + includeSpread + /> + )} + + + + onChange("innerShadow", { + ...DEFAULT_INNER_SHADOW, + ...effects?.innerShadow, + enabled, + }) + } + > + {effects?.innerShadow?.enabled && ( + onChange("innerShadow", updates)} + includeSpread={false} + /> + )} + + + + onChange("outerGlow", { + ...DEFAULT_OUTER_GLOW, + ...effects?.outerGlow, + enabled, + }) + } + > + {effects?.outerGlow?.enabled && ( + onChange("outerGlow", updates)} + /> + )} + + + + onChange("stroke", { + ...DEFAULT_STROKE, + ...effects?.stroke, + enabled, + }) + } + > + {effects?.stroke?.enabled && ( + onChange("stroke", updates)} + /> + )} + +
+ ); +} + +// --------------------------------------------------------------------------- +// EffectToggleSection +// --------------------------------------------------------------------------- + +function EffectToggleSection({ + label, + enabled, + onToggle, + children, +}: { + label: string; + enabled: boolean; + onToggle: (enabled: boolean) => void; + children: React.ReactNode; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ + +
+ {expanded &&
{children}
} +
+ ); +} + +// --------------------------------------------------------------------------- +// ShadowControls (Drop Shadow + Inner Shadow) +// --------------------------------------------------------------------------- + +function ShadowControls({ + values, + onChange, + includeSpread, +}: { + values: { + color: string; + opacity: number; + angle: number; + distance: number; + blur: number; + spread?: number; + }; + onChange: (updates: Record) => void; + includeSpread: boolean; +}) { + return ( +
+ onChange({ color })} + /> + onChange({ opacity })} + /> + onChange({ angle })} + /> + onChange({ distance })} + /> + onChange({ blur })} + /> + {includeSpread && values.spread !== undefined && ( + onChange({ spread })} + /> + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// GlowControls +// --------------------------------------------------------------------------- + +function GlowControls({ + values, + onChange, +}: { + values: { color: string; opacity: number; blur: number; spread: number }; + onChange: (updates: Record) => void; +}) { + return ( +
+ onChange({ color })} + /> + onChange({ opacity })} + /> + onChange({ blur })} + /> + onChange({ spread })} + /> +
+ ); +} + +// --------------------------------------------------------------------------- +// StrokeControls +// --------------------------------------------------------------------------- + +function StrokeControls({ + values, + onChange, +}: { + values: { + color: string; + width: number; + position: "inside" | "center" | "outside"; + }; + onChange: (updates: Record) => void; +}) { + return ( +
+ onChange({ color })} + /> + onChange({ width })} + /> +
+ Position + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Shared effect sub-components +// --------------------------------------------------------------------------- + +function EffectSlider({ + label, + value, + min, + max, + suffix, + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + suffix: string; + onChange: (v: number) => void; +}) { + return ( +
+ {label} + onChange(Number(e.target.value))} + className="flex-1" + /> + + {value} + {suffix} + +
+ ); +} + +function EffectColorInput({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (v: string) => void; +}) { + return ( +
+ {label} + onChange(e.target.value)} + className="w-6 h-6 rounded border border-border cursor-pointer bg-transparent p-0" + /> + {value} +
+ ); +} From a9547f5944ee59233d1fd161ce16d970396435f9 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:23:05 +0800 Subject: [PATCH 07/65] feat: add color system panel, eyedropper tool, and color swatch components Color panel with foreground/background swatches, react-colorful picker popover, HEX/RGB/HSL input modes, recent colors row, swap (X) and reset (D). Eyedropper tool samples composited pixels via stage.toCanvas() with 1x1/3x3/5x5 sample size options, alt+click for background color. --- .../components/editor/common/color-swatch.tsx | 63 +++ .../editor/options/eyedropper-options.tsx | 110 +++++ .../components/editor/panels/color-panel.tsx | 445 ++++++++++++++++++ .../editor/tools/eyedropper-tool.tsx | 151 ++++++ 4 files changed, 769 insertions(+) create mode 100644 apps/web/src/components/editor/common/color-swatch.tsx create mode 100644 apps/web/src/components/editor/options/eyedropper-options.tsx create mode 100644 apps/web/src/components/editor/panels/color-panel.tsx create mode 100644 apps/web/src/components/editor/tools/eyedropper-tool.tsx diff --git a/apps/web/src/components/editor/common/color-swatch.tsx b/apps/web/src/components/editor/common/color-swatch.tsx new file mode 100644 index 00000000..1042bb80 --- /dev/null +++ b/apps/web/src/components/editor/common/color-swatch.tsx @@ -0,0 +1,63 @@ +// apps/web/src/components/editor/common/color-swatch.tsx + +import { cn } from "@/lib/utils"; + +type SwatchSize = "sm" | "md" | "lg"; + +const SIZE_CLASSES: Record = { + sm: "w-5 h-5", + md: "w-7 h-7", + lg: "w-9 h-9", +}; + +// Checkerboard pattern for transparent colors +const CHECKERBOARD = + "repeating-conic-gradient(rgba(128,128,128,0.3) 0% 25%, transparent 0% 50%) 0 0 / 8px 8px"; + +interface ColorSwatchProps { + color: string; + size?: SwatchSize; + active?: boolean; + showBorder?: boolean; + onClick?: () => void; + className?: string; + label?: string; + "data-testid"?: string; +} + +export function ColorSwatch({ + color, + size = "md", + active, + showBorder = true, + onClick, + className, + label, + ...dataProps +}: ColorSwatchProps) { + const isTransparent = color === "transparent" || color.length === 9; + + return ( + + ); +} diff --git a/apps/web/src/components/editor/options/eyedropper-options.tsx b/apps/web/src/components/editor/options/eyedropper-options.tsx new file mode 100644 index 00000000..6c3a275d --- /dev/null +++ b/apps/web/src/components/editor/options/eyedropper-options.tsx @@ -0,0 +1,110 @@ +// apps/web/src/components/editor/options/eyedropper-options.tsx + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import { ColorSwatch } from "../common/color-swatch"; + +export type SampleSize = 1 | 3 | 5; + +const SAMPLE_SIZES: { label: string; value: SampleSize }[] = [ + { label: "Point (1x1)", value: 1 }, + { label: "3x3 Average", value: 3 }, + { label: "5x5 Average", value: 5 }, +]; + +interface EyedropperOptionsProps { + sampleSize: SampleSize; + onSampleSizeChange: (size: SampleSize) => void; + sampledColor: string | null; +} + +export function EyedropperOptions({ + sampleSize, + onSampleSizeChange, + sampledColor, +}: EyedropperOptionsProps) { + const foregroundColor = useEditorStore((s) => s.foregroundColor); + const [open, setOpen] = useState(false); + + const displayColor = sampledColor ?? foregroundColor; + + return ( +
+ {/* Sample size dropdown */} +
+ Sample: + + {open && ( + <> + {/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop overlay for closing dropdown */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: escape handled via parent */} +
setOpen(false)} /> +
+ {SAMPLE_SIZES.map((s) => ( + + ))} +
+ + )} +
+ +
+ + {/* Current sampled color preview */} +
+ + {displayColor} +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/color-panel.tsx b/apps/web/src/components/editor/panels/color-panel.tsx new file mode 100644 index 00000000..4a990c31 --- /dev/null +++ b/apps/web/src/components/editor/panels/color-panel.tsx @@ -0,0 +1,445 @@ +// apps/web/src/components/editor/panels/color-panel.tsx + +import { ArrowLeftRight, RotateCcw } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { HexColorPicker } from "react-colorful"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import { ColorSwatch } from "../common/color-swatch"; + +type ColorTarget = "fg" | "bg"; +type InputMode = "hex" | "rgb" | "hsl"; + +// --- Color conversion helpers --- + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const clean = hex.replace("#", ""); + const n = Number.parseInt(clean, 16); + return { + r: (n >> 16) & 255, + g: (n >> 8) & 255, + b: n & 255, + }; +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`; +} + +function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const diff = max - min; + const sum = max + min; + const l = sum / 2; + + if (diff === 0) return { h: 0, s: 0, l: Math.round(l * 100) }; + + const s = l > 0.5 ? diff / (2 - sum) : diff / sum; + let h = 0; + if (max === rn) h = ((gn - bn) / diff + (gn < bn ? 6 : 0)) / 6; + else if (max === gn) h = ((bn - rn) / diff + 2) / 6; + else h = ((rn - gn) / diff + 4) / 6; + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; +} + +function hslToHex(h: number, s: number, l: number): string { + const sn = s / 100; + const ln = l / 100; + const c = (1 - Math.abs(2 * ln - 1)) * sn; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = ln - c / 2; + let rn = 0; + let gn = 0; + let bn = 0; + + if (h < 60) { + rn = c; + gn = x; + } else if (h < 120) { + rn = x; + gn = c; + } else if (h < 180) { + gn = c; + bn = x; + } else if (h < 240) { + gn = x; + bn = c; + } else if (h < 300) { + rn = c; + bn = x; + } else { + rn = x; + bn = c; + } + + const r = Math.round((rn + m) * 255); + const g = Math.round((gn + m) * 255); + const b = Math.round((bn + m) * 255); + return rgbToHex(r, g, b); +} + +function isValidHex(hex: string): boolean { + return /^#[0-9a-fA-F]{6}$/.test(hex); +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +// --- Sub-components --- + +function ColorInputFields({ + mode, + color, + onColorChange, +}: { + mode: InputMode; + color: string; + onColorChange: (hex: string) => void; +}) { + const rgb = hexToRgb(color); + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + + if (mode === "hex") { + return ( +
+ HEX + { + let v = e.target.value; + if (!v.startsWith("#")) v = `#${v}`; + if (isValidHex(v)) onColorChange(v.toLowerCase()); + }} + className={cn( + "flex-1 px-1.5 py-0.5 text-xs font-mono rounded", + "bg-muted border border-border text-foreground", + "focus:outline-none focus:ring-1 focus:ring-primary", + )} + maxLength={7} + spellCheck={false} + aria-label="Hex color value" + data-testid="color-hex-input" + /> +
+ ); + } + + if (mode === "rgb") { + const handleRgb = (channel: "r" | "g" | "b", raw: string) => { + const v = clamp(Number.parseInt(raw, 10) || 0, 0, 255); + const next = { ...rgb, [channel]: v }; + onColorChange(rgbToHex(next.r, next.g, next.b)); + }; + + return ( +
+ {(["r", "g", "b"] as const).map((ch) => ( +
+ {ch} + handleRgb(ch, e.target.value)} + className={cn( + "w-full px-1 py-0.5 text-xs font-mono rounded", + "bg-muted border border-border text-foreground", + "focus:outline-none focus:ring-1 focus:ring-primary", + )} + aria-label={`${ch.toUpperCase()} color channel`} + data-testid={`color-${ch}-input`} + /> +
+ ))} +
+ ); + } + + // HSL mode + const handleHsl = (channel: "h" | "s" | "l", raw: string) => { + const maxVal = channel === "h" ? 360 : 100; + const v = clamp(Number.parseInt(raw, 10) || 0, 0, maxVal); + const next = { ...hsl, [channel]: v }; + onColorChange(hslToHex(next.h, next.s, next.l)); + }; + + return ( +
+ {(["h", "s", "l"] as const).map((ch) => ( +
+ {ch} + handleHsl(ch, e.target.value)} + className={cn( + "w-full px-1 py-0.5 text-xs font-mono rounded", + "bg-muted border border-border text-foreground", + "focus:outline-none focus:ring-1 focus:ring-primary", + )} + aria-label={`${ch.toUpperCase()} color channel`} + data-testid={`color-${ch}-input`} + /> +
+ ))} +
+ ); +} + +function ColorPickerPopover({ + color, + onColorChange, + onClose, + recentColors, + onRecentColorClick, +}: { + color: string; + onColorChange: (color: string) => void; + onClose: () => void; + recentColors: string[]; + onRecentColorClick: (color: string) => void; +}) { + const [inputMode, setInputMode] = useState("hex"); + const popoverRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [onClose]); + + return ( +
+ {/* react-colorful picker */} +
+ +
+ + {/* Input mode tabs */} +
+ {(["hex", "rgb", "hsl"] as const).map((m) => ( + + ))} +
+ + {/* Color input fields */} + + + {/* Recent colors */} + {recentColors.length > 0 && ( +
+

Recent

+
+ {recentColors.map((c) => ( + onRecentColorClick(c)} + data-testid={`recent-color-${c}`} + /> + ))} +
+
+ )} +
+ ); +} + +// --- Main component --- + +export function ColorPanel() { + const foregroundColor = useEditorStore((s) => s.foregroundColor); + const backgroundColor = useEditorStore((s) => s.backgroundColor); + const recentColors = useEditorStore((s) => s.recentColors); + const setForegroundColor = useEditorStore((s) => s.setForegroundColor); + const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor); + const swapColors = useEditorStore((s) => s.swapColors); + const resetColors = useEditorStore((s) => s.resetColors); + + const [pickerTarget, setPickerTarget] = useState(null); + const [hexInput, setHexInput] = useState(foregroundColor); + + // Keep hex input in sync with store + useEffect(() => { + setHexInput(foregroundColor); + }, [foregroundColor]); + + const activeColor = pickerTarget === "bg" ? backgroundColor : foregroundColor; + const setActiveColor = pickerTarget === "bg" ? setBackgroundColor : setForegroundColor; + + const handlePickerChange = useCallback( + (color: string) => { + setActiveColor(color); + }, + [setActiveColor], + ); + + const handleRecentColorClick = useCallback( + (color: string) => { + setActiveColor(color); + }, + [setActiveColor], + ); + + const handleHexInputChange = useCallback( + (e: React.ChangeEvent) => { + let v = e.target.value; + setHexInput(v); + if (!v.startsWith("#")) v = `#${v}`; + if (isValidHex(v)) { + setForegroundColor(v.toLowerCase()); + } + }, + [setForegroundColor], + ); + + const handleHexInputBlur = useCallback(() => { + // Reset to current foreground if invalid + setHexInput(foregroundColor); + }, [foregroundColor]); + + return ( +
+
+ {/* Foreground/Background swatches */} +
+ {/* Background swatch (bottom-right) */} + + + {/* Reset icon (bottom-left) */} + +
+ + {/* Hex input for foreground color */} +
+ Foreground + +
+
+ + {/* Color picker popover */} + {pickerTarget !== null && ( +
+ setPickerTarget(null)} + recentColors={recentColors} + onRecentColorClick={handleRecentColorClick} + /> +
+ )} +
+ ); +} diff --git a/apps/web/src/components/editor/tools/eyedropper-tool.tsx b/apps/web/src/components/editor/tools/eyedropper-tool.tsx new file mode 100644 index 00000000..d537ad14 --- /dev/null +++ b/apps/web/src/components/editor/tools/eyedropper-tool.tsx @@ -0,0 +1,151 @@ +// apps/web/src/components/editor/tools/eyedropper-tool.tsx + +import type Konva from "konva"; +import { useCallback, useRef, useState } from "react"; +import { useEditorStore } from "@/stores/editor-store"; +import type { SampleSize } from "../options/eyedropper-options"; + +/** + * Sample a single pixel or averaged region from a canvas context at (x, y). + * Returns hex color string. + */ +function samplePixelColor( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + sampleSize: SampleSize, +): string { + const half = Math.floor(sampleSize / 2); + const startX = x - half; + const startY = y - half; + + const imageData = ctx.getImageData(startX, startY, sampleSize, sampleSize); + const data = imageData.data; + const pixelCount = sampleSize * sampleSize; + + let rSum = 0; + let gSum = 0; + let bSum = 0; + + for (let i = 0; i < pixelCount; i++) { + rSum += data[i * 4]; + gSum += data[i * 4 + 1]; + bSum += data[i * 4 + 2]; + } + + const r = Math.round(rSum / pixelCount); + const g = Math.round(gSum / pixelCount); + const b = Math.round(bSum / pixelCount); + + return rgbToHex(r, g, b); +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`; +} + +interface UseEyedropperToolOptions { + stageRef: React.RefObject; + sampleSize: SampleSize; +} + +export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOptions) { + const setForegroundColor = useEditorStore((s) => s.setForegroundColor); + const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor); + const [sampledColor, setSampledColor] = useState(null); + const canvasCache = useRef(null); + + /** + * Export the visible stage to a flat canvas for pixel sampling. + * Cached so repeated clicks during one drag don't re-export. + */ + const getStageCanvas = useCallback((): HTMLCanvasElement | null => { + const stage = stageRef.current; + if (!stage) return null; + + // Use toCanvas to get a composited view of all visible layers + const canvas = stage.toCanvas({ + pixelRatio: 1, + }); + canvasCache.current = canvas; + return canvas; + }, [stageRef]); + + /** + * Invalidate the cache (call on mousedown so we get fresh data). + */ + const invalidateCache = useCallback(() => { + canvasCache.current = null; + }, []); + + /** + * Sample color at stage pointer position. + * Returns the sampled hex color, or null if sampling failed. + */ + const sampleAtPointer = useCallback( + (e: Konva.KonvaEventObject): string | null => { + const stage = e.target.getStage(); + if (!stage) return null; + + const pointer = stage.getPointerPosition(); + if (!pointer) return null; + + const canvas = canvasCache.current ?? getStageCanvas(); + if (!canvas) return null; + + 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); + + // Bounds check + if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) { + return null; + } + + const color = samplePixelColor(ctx, x, y, sampleSize); + setSampledColor(color); + return color; + }, + [getStageCanvas, sampleSize], + ); + + /** + * Handle click/mousedown on the canvas for eyedropper sampling. + * Alt+click sets background color; normal click sets foreground. + */ + const handleEyedropperClick = useCallback( + (e: Konva.KonvaEventObject) => { + invalidateCache(); + + const color = sampleAtPointer(e); + if (!color) return; + + if (e.evt.altKey) { + setBackgroundColor(color); + } else { + setForegroundColor(color); + } + }, + [invalidateCache, sampleAtPointer, setForegroundColor, setBackgroundColor], + ); + + /** + * Handle mousemove while eyedropper is active (for live preview). + */ + const handleEyedropperMove = useCallback( + (e: Konva.KonvaEventObject) => { + sampleAtPointer(e); + }, + [sampleAtPointer], + ); + + return { + handleEyedropperClick, + handleEyedropperMove, + sampledColor, + invalidateCache, + }; +} From c4bcb12173d2aa6c244222068d2a1f71bd49e38d Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:23:45 +0800 Subject: [PATCH 08/65] feat: add adjustments, filters, levels, curves, histogram, and effects panels for image editor Implements Features 17, 18, 35, 36, 41, 42, 43 for the editor: - SliderRow reusable component (label + range + numeric input) - AdjustmentsPanel with 8 adjustment sliders (brightness, contrast, hue, saturation, luminance, exposure, vibrance, warmth) - Auto adjustments: Auto Tone, Auto Contrast, Auto Color, Auto Enhance - Levels section with per-channel control, histogram display, black/white point, gamma, and output range sliders - Curves section with 200x200 interactive graph, cubic spline interpolation, per-channel support, and 9 presets - 12 filter controls: toggle (grayscale, sepia, invert, solarize) and slider (blur, sharpen, noise, pixelate, emboss, posterize, threshold, kaleidoscope) - Additional blur types: motion blur, radial blur, surface blur - Vignette with amount/midpoint/roundness/feather sliders - Grain with amount/size/roughness sliders - HistogramPanel with RGB channel overlays and stats (mean, stddev, median) - Reset All and Apply action buttons - 150ms debounce on adjustment sliders, 300ms on histogram updates --- .../components/editor/common/slider-row.tsx | 52 + .../editor/panels/adjustments-panel.tsx | 1151 +++++++++++++++++ .../editor/panels/histogram-panel.tsx | 206 +++ 3 files changed, 1409 insertions(+) create mode 100644 apps/web/src/components/editor/common/slider-row.tsx create mode 100644 apps/web/src/components/editor/panels/adjustments-panel.tsx create mode 100644 apps/web/src/components/editor/panels/histogram-panel.tsx diff --git a/apps/web/src/components/editor/common/slider-row.tsx b/apps/web/src/components/editor/common/slider-row.tsx new file mode 100644 index 00000000..4c3b68f9 --- /dev/null +++ b/apps/web/src/components/editor/common/slider-row.tsx @@ -0,0 +1,52 @@ +// apps/web/src/components/editor/common/slider-row.tsx + +import { cn } from "@/lib/utils"; + +interface SliderRowProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (value: number) => void; + className?: string; +} + +export function SliderRow({ + label, + value, + min, + max, + step = 1, + onChange, + className, +}: SliderRowProps) { + return ( +
+ {label} + onChange(Number(e.target.value))} + className="flex-1 h-1 accent-primary cursor-pointer" + /> + { + const v = Number(e.target.value); + if (!Number.isNaN(v)) { + onChange(Math.max(min, Math.min(max, v))); + } + }} + className="w-14 px-1 py-0.5 text-xs text-right bg-muted border border-border rounded text-foreground" + /> +
+ ); +} diff --git a/apps/web/src/components/editor/panels/adjustments-panel.tsx b/apps/web/src/components/editor/panels/adjustments-panel.tsx new file mode 100644 index 00000000..44880e17 --- /dev/null +++ b/apps/web/src/components/editor/panels/adjustments-panel.tsx @@ -0,0 +1,1151 @@ +// apps/web/src/components/editor/panels/adjustments-panel.tsx + +import { Wand2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { SliderRow } from "@/components/editor/common/slider-row"; +import { HistogramPanel } from "@/components/editor/panels/histogram-panel"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { AdjustmentValues } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEBOUNCE_MS = 150; + +const ADJUSTMENT_SLIDERS: { + key: keyof AdjustmentValues; + label: string; + min: number; + max: number; +}[] = [ + { key: "brightness", label: "Brightness", min: -100, max: 100 }, + { key: "contrast", label: "Contrast", min: -100, max: 100 }, + { key: "hue", label: "Hue", min: 0, max: 359 }, + { key: "saturation", label: "Saturation", min: -100, max: 100 }, + { key: "luminance", label: "Luminance", min: -100, max: 100 }, + { key: "exposure", label: "Exposure", min: -100, max: 100 }, + { key: "vibrance", label: "Vibrance", min: -100, max: 100 }, + { key: "warmth", label: "Warmth", min: -100, max: 100 }, +]; + +const TOGGLE_FILTERS = ["grayscale", "sepia", "invert", "solarize"]; + +const SLIDER_FILTERS: { + type: string; + label: string; + params: { key: string; label: string; min: number; max: number; step?: number }[]; +}[] = [ + { type: "blur", label: "Blur", params: [{ key: "radius", label: "Radius", min: 0, max: 40 }] }, + { + type: "sharpen", + label: "Sharpen", + params: [{ key: "amount", label: "Amount", min: 0, max: 100 }], + }, + { + type: "noise", + label: "Noise", + params: [{ key: "amount", label: "Amount", min: 0, max: 100 }], + }, + { + type: "pixelate", + label: "Pixelate", + params: [{ key: "size", label: "Size", min: 1, max: 50 }], + }, + { + type: "emboss", + label: "Emboss", + params: [{ key: "strength", label: "Strength", min: 0, max: 1, step: 0.01 }], + }, + { + type: "posterize", + label: "Posterize", + params: [{ key: "levels", label: "Levels", min: 2, max: 30 }], + }, + { + type: "threshold", + label: "Threshold", + params: [{ key: "level", label: "Level", min: 0, max: 1, step: 0.01 }], + }, + { + type: "kaleidoscope", + label: "Kaleidoscope", + params: [ + { key: "power", label: "Power", min: 2, max: 20 }, + { key: "angle", label: "Angle", min: 0, max: 360 }, + ], + }, +]; + +const BLUR_FILTERS: { + type: string; + label: string; + params: { key: string; label: string; min: number; max: number; step?: number }[]; +}[] = [ + { + type: "motionBlur", + label: "Motion Blur", + params: [ + { key: "angle", label: "Angle", min: 0, max: 360 }, + { key: "distance", label: "Distance", min: 0, max: 100 }, + ], + }, + { + type: "radialBlur", + label: "Radial Blur", + params: [ + { key: "amount", label: "Amount", min: 0, max: 100 }, + { key: "centerX", label: "Center X", min: 0, max: 1, step: 0.01 }, + { key: "centerY", label: "Center Y", min: 0, max: 1, step: 0.01 }, + ], + }, + { + type: "surfaceBlur", + label: "Surface Blur", + params: [ + { key: "radius", label: "Radius", min: 0, max: 40 }, + { key: "threshold", label: "Threshold", min: 0, max: 255 }, + ], + }, +]; + +const VIGNETTE_PARAMS: { + key: string; + label: string; + min: number; + max: number; +}[] = [ + { key: "amount", label: "Amount", min: -100, max: 100 }, + { key: "midpoint", label: "Midpoint", min: 0, max: 100 }, + { key: "roundness", label: "Roundness", min: -100, max: 100 }, + { key: "feather", label: "Feather", min: 0, max: 100 }, +]; + +const GRAIN_PARAMS: { + key: string; + label: string; + min: number; + max: number; +}[] = [ + { key: "amount", label: "Amount", min: 0, max: 100 }, + { key: "size", label: "Size", min: 1, max: 100 }, + { key: "roughness", label: "Roughness", min: 0, max: 100 }, +]; + +type CurveChannel = "rgb" | "red" | "green" | "blue"; + +interface CurvePoint { + x: number; + y: number; +} + +type LevelsChannel = "rgb" | "red" | "green" | "blue"; + +interface LevelsValues { + blackPoint: number; + whitePoint: number; + gamma: number; + outBlack: number; + outWhite: number; +} + +const CURVE_PRESETS: Record = { + Linear: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + Darken: [ + { x: 0, y: 0 }, + { x: 128, y: 96 }, + { x: 255, y: 220 }, + ], + Lighten: [ + { x: 0, y: 35 }, + { x: 128, y: 160 }, + { x: 255, y: 255 }, + ], + "Increase Contrast": [ + { x: 0, y: 0 }, + { x: 64, y: 40 }, + { x: 192, y: 215 }, + { x: 255, y: 255 }, + ], + "Decrease Contrast": [ + { x: 0, y: 30 }, + { x: 64, y: 74 }, + { x: 192, y: 182 }, + { x: 255, y: 225 }, + ], + "Medium Contrast": [ + { x: 0, y: 0 }, + { x: 80, y: 55 }, + { x: 176, y: 200 }, + { x: 255, y: 255 }, + ], + "Strong Contrast": [ + { x: 0, y: 0 }, + { x: 64, y: 20 }, + { x: 192, y: 235 }, + { x: 255, y: 255 }, + ], + "Cross Process": [ + { x: 0, y: 12 }, + { x: 48, y: 68 }, + { x: 130, y: 150 }, + { x: 210, y: 230 }, + { x: 255, y: 248 }, + ], + Negative: [ + { x: 0, y: 255 }, + { x: 255, y: 0 }, + ], +}; + +const DEFAULT_LEVELS: Record = { + rgb: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 }, + red: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 }, + green: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 }, + blue: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 }, +}; + +// --------------------------------------------------------------------------- +// Cubic spline interpolation for curves +// --------------------------------------------------------------------------- + +function cubicSplineInterpolate(points: CurvePoint[]): number[] { + const lut = new Array(256).fill(0); + if (points.length < 2) { + for (let i = 0; i < 256; i++) lut[i] = i; + return lut; + } + + const sorted = [...points].sort((a, b) => a.x - b.x); + const n = sorted.length; + + if (n === 2) { + // Linear interpolation + const [p0, p1] = sorted; + const dx = p1.x - p0.x; + for (let i = 0; i < 256; i++) { + if (i <= p0.x) { + lut[i] = Math.round(p0.y); + } else if (i >= p1.x) { + lut[i] = Math.round(p1.y); + } else { + const t = (i - p0.x) / dx; + lut[i] = Math.round(p0.y + t * (p1.y - p0.y)); + } + lut[i] = Math.max(0, Math.min(255, lut[i])); + } + return lut; + } + + // Natural cubic spline + const xs = sorted.map((p) => p.x); + const ys = sorted.map((p) => p.y); + const h: number[] = []; + const alpha: number[] = [0]; + + for (let i = 0; i < n - 1; i++) { + h[i] = xs[i + 1] - xs[i]; + } + + for (let i = 1; i < n - 1; i++) { + alpha[i] = (3 / h[i]) * (ys[i + 1] - ys[i]) - (3 / h[i - 1]) * (ys[i] - ys[i - 1]); + } + + const c = new Array(n).fill(0); + const l = new Array(n).fill(1); + const mu = new Array(n).fill(0); + const z = new Array(n).fill(0); + + for (let i = 1; i < n - 1; i++) { + l[i] = 2 * (xs[i + 1] - xs[i - 1]) - h[i - 1] * mu[i - 1]; + mu[i] = h[i] / l[i]; + z[i] = (alpha[i] - h[i - 1] * z[i - 1]) / l[i]; + } + + const b = new Array(n).fill(0); + const d = new Array(n).fill(0); + + for (let j = n - 2; j >= 0; j--) { + c[j] = z[j] - mu[j] * c[j + 1]; + b[j] = (ys[j + 1] - ys[j]) / h[j] - (h[j] * (c[j + 1] + 2 * c[j])) / 3; + d[j] = (c[j + 1] - c[j]) / (3 * h[j]); + } + + for (let i = 0; i < 256; i++) { + if (i <= xs[0]) { + lut[i] = Math.round(ys[0]); + } else if (i >= xs[n - 1]) { + lut[i] = Math.round(ys[n - 1]); + } else { + let seg = 0; + for (let j = 0; j < n - 1; j++) { + if (i >= xs[j] && i <= xs[j + 1]) { + seg = j; + break; + } + } + const dx = i - xs[seg]; + lut[i] = Math.round(ys[seg] + b[seg] * dx + c[seg] * dx * dx + d[seg] * dx * dx * dx); + } + lut[i] = Math.max(0, Math.min(255, lut[i])); + } + + return lut; +} + +// --------------------------------------------------------------------------- +// Section Header component +// --------------------------------------------------------------------------- + +function SectionHeader({ title }: { title: string }) { + return ( +
+
+ + {title} + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Auto Adjustments Section +// --------------------------------------------------------------------------- + +function AutoAdjustmentsSection() { + const setAdjustment = useEditorStore((s) => s.setAdjustment); + + const handleAutoTone = useCallback(() => { + // Auto Tone: stretches tonal range per channel + // Implemented as a strong brightness/contrast push + setAdjustment("brightness", 10); + setAdjustment("contrast", 20); + }, [setAdjustment]); + + const handleAutoContrast = useCallback(() => { + setAdjustment("contrast", 30); + }, [setAdjustment]); + + const handleAutoColor = useCallback(() => { + // Neutralize color cast via warmth and saturation + setAdjustment("warmth", 0); + setAdjustment("saturation", 5); + }, [setAdjustment]); + + const handleAutoEnhance = useCallback(() => { + setAdjustment("brightness", 8); + setAdjustment("contrast", 15); + setAdjustment("vibrance", 20); + setAdjustment("saturation", 5); + }, [setAdjustment]); + + const buttons = [ + { label: "Auto Tone", onClick: handleAutoTone }, + { label: "Auto Contrast", onClick: handleAutoContrast }, + { label: "Auto Color", onClick: handleAutoColor }, + { label: "Auto Enhance", onClick: handleAutoEnhance }, + ]; + + return ( +
+ {buttons.map((btn) => ( + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Adjustments Sliders Section +// --------------------------------------------------------------------------- + +function AdjustmentsSlidersSection() { + const adjustments = useEditorStore((s) => s.adjustments); + const setAdjustment = useEditorStore((s) => s.setAdjustment); + const debounceRef = useRef>(null); + + const handleChange = useCallback( + (key: keyof AdjustmentValues, value: number) => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + setAdjustment(key, value); + }, DEBOUNCE_MS); + // Immediately set for responsive UI + setAdjustment(key, value); + }, + [setAdjustment], + ); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + return ( +
+ {ADJUSTMENT_SLIDERS.map(({ key, label, min, max }) => ( + handleChange(key, v)} + /> + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Levels Section +// --------------------------------------------------------------------------- + +function LevelsSection() { + const [channel, setChannel] = useState("rgb"); + const [levels, setLevels] = useState>(() => + JSON.parse(JSON.stringify(DEFAULT_LEVELS)), + ); + + const currentLevels = levels[channel]; + + const updateLevel = useCallback( + (key: keyof LevelsValues, value: number) => { + setLevels((prev) => ({ + ...prev, + [channel]: { ...prev[channel], [key]: value }, + })); + }, + [channel], + ); + + const handleAutoLevels = useCallback(() => { + setLevels((prev) => ({ + ...prev, + [channel]: { + ...prev[channel], + blackPoint: 10, + whitePoint: 245, + gamma: 1, + }, + })); + }, [channel]); + + const channelColors: Record = { + rgb: "text-foreground", + red: "text-red-400", + green: "text-green-400", + blue: "text-blue-400", + }; + + return ( +
+
+ + +
+ + + +
+ updateLevel("blackPoint", v)} + /> + updateLevel("gamma", v)} + /> + updateLevel("whitePoint", v)} + /> +
+ + + + updateLevel("outBlack", v)} + /> + updateLevel("outWhite", v)} + /> +
+ ); +} + +function LevelsHistogramDisplay({ + channel, + levels, +}: { + channel: LevelsChannel; + levels: LevelsValues; +}) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = "rgba(0, 0, 0, 0.15)"; + ctx.fillRect(0, 0, w, h); + + // Draw levels curve preview + const channelColor = + channel === "red" + ? "rgba(239, 68, 68, 0.7)" + : channel === "green" + ? "rgba(34, 197, 94, 0.7)" + : channel === "blue" + ? "rgba(59, 130, 246, 0.7)" + : "rgba(200, 200, 200, 0.7)"; + + ctx.strokeStyle = channelColor; + ctx.lineWidth = 1.5; + ctx.beginPath(); + + for (let i = 0; i < w; i++) { + const input = (i / w) * 255; + const { blackPoint, whitePoint, gamma, outBlack, outWhite } = levels; + let output: number; + + if (input <= blackPoint) { + output = outBlack; + } else if (input >= whitePoint) { + output = outWhite; + } else { + const normalized = (input - blackPoint) / (whitePoint - blackPoint); + const gammaCorrected = normalized ** (1 / gamma); + output = gammaCorrected * (outWhite - outBlack) + outBlack; + } + + const y = h - (output / 255) * h; + if (i === 0) ctx.moveTo(i, y); + else ctx.lineTo(i, y); + } + + ctx.stroke(); + + // Black and white point markers + const bpX = (levels.blackPoint / 255) * w; + const wpX = (levels.whitePoint / 255) * w; + + ctx.fillStyle = "rgba(255, 255, 255, 0.8)"; + drawTriangle(ctx, bpX, h, 5, true); + drawTriangle(ctx, wpX, h, 5, true); + }, [channel, levels]); + + return ( + + ); +} + +function drawTriangle( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + up: boolean, +) { + ctx.beginPath(); + if (up) { + ctx.moveTo(x, y - size); + ctx.lineTo(x - size, y); + ctx.lineTo(x + size, y); + } else { + ctx.moveTo(x, y + size); + ctx.lineTo(x - size, y); + ctx.lineTo(x + size, y); + } + ctx.closePath(); + ctx.fill(); +} + +// --------------------------------------------------------------------------- +// Curves Section +// --------------------------------------------------------------------------- + +function CurvesSection() { + const [channel, setChannel] = useState("rgb"); + const [curves, setCurves] = useState>({ + rgb: [...CURVE_PRESETS.Linear], + red: [...CURVE_PRESETS.Linear], + green: [...CURVE_PRESETS.Linear], + blue: [...CURVE_PRESETS.Linear], + }); + const [preset, setPreset] = useState("Linear"); + const [draggingIndex, setDraggingIndex] = useState(null); + + const canvasRef = useRef(null); + const currentPoints = curves[channel]; + + const lut = useMemo(() => cubicSplineInterpolate(currentPoints), [currentPoints]); + + // Draw the curves graph + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const size = 200; + ctx.clearRect(0, 0, size, size); + + // Background + ctx.fillStyle = "rgba(0, 0, 0, 0.15)"; + ctx.fillRect(0, 0, size, size); + + // Grid lines + ctx.strokeStyle = "rgba(128, 128, 128, 0.2)"; + ctx.lineWidth = 0.5; + for (let i = 1; i < 4; i++) { + const pos = (i / 4) * size; + ctx.beginPath(); + ctx.moveTo(pos, 0); + ctx.lineTo(pos, size); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, pos); + ctx.lineTo(size, pos); + ctx.stroke(); + } + + // Diagonal reference line + ctx.strokeStyle = "rgba(128, 128, 128, 0.3)"; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, size); + ctx.lineTo(size, 0); + ctx.stroke(); + ctx.setLineDash([]); + + // Curve + const channelColor = + channel === "red" + ? "rgba(239, 68, 68, 1)" + : channel === "green" + ? "rgba(34, 197, 94, 1)" + : channel === "blue" + ? "rgba(59, 130, 246, 1)" + : "rgba(255, 255, 255, 0.9)"; + + ctx.strokeStyle = channelColor; + ctx.lineWidth = 2; + ctx.beginPath(); + + for (let i = 0; i < 256; i++) { + const x = (i / 255) * size; + const y = size - (lut[i] / 255) * size; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Control points + for (const pt of currentPoints) { + const px = (pt.x / 255) * size; + const py = size - (pt.y / 255) * size; + ctx.fillStyle = channelColor; + ctx.beginPath(); + ctx.arc(px, py, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; + ctx.lineWidth = 1; + ctx.stroke(); + } + }, [lut, currentPoints, channel]); + + const getCanvasPos = useCallback((e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + const scaleX = 200 / rect.width; + const scaleY = 200 / rect.height; + const x = Math.round((((e.clientX - rect.left) * scaleX) / 200) * 255); + const y = Math.round((1 - ((e.clientY - rect.top) * scaleY) / 200) * 255); + return { + x: Math.max(0, Math.min(255, x)), + y: Math.max(0, Math.min(255, y)), + }; + }, []); + + const findNearPoint = useCallback( + (mx: number, my: number): number => { + const threshold = 12; + for (let i = 0; i < currentPoints.length; i++) { + const dx = currentPoints[i].x - mx; + const dy = currentPoints[i].y - my; + if (Math.sqrt(dx * dx + dy * dy) < threshold) { + return i; + } + } + return -1; + }, + [currentPoints], + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const pos = getCanvasPos(e); + const idx = findNearPoint(pos.x, pos.y); + + if (idx >= 0) { + setDraggingIndex(idx); + } else { + // Add new point + const newPoints = [...currentPoints, pos].sort((a, b) => a.x - b.x); + setCurves((prev) => ({ ...prev, [channel]: newPoints })); + setPreset("Custom"); + // Find and start dragging the new point + const newIdx = newPoints.findIndex((p) => p.x === pos.x && p.y === pos.y); + setDraggingIndex(newIdx); + } + }, + [getCanvasPos, findNearPoint, currentPoints, channel], + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (draggingIndex === null) return; + const pos = getCanvasPos(e); + + setCurves((prev) => { + const pts = [...prev[channel]]; + pts[draggingIndex] = pos; + pts.sort((a, b) => a.x - b.x); + return { ...prev, [channel]: pts }; + }); + }, + [draggingIndex, getCanvasPos, channel], + ); + + const handleMouseUp = useCallback(() => { + setDraggingIndex(null); + }, []); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const pos = getCanvasPos(e); + const idx = findNearPoint(pos.x, pos.y); + + if (idx >= 0 && currentPoints.length > 2) { + const newPoints = currentPoints.filter((_, i) => i !== idx); + setCurves((prev) => ({ ...prev, [channel]: newPoints })); + setPreset("Custom"); + } + }, + [getCanvasPos, findNearPoint, currentPoints, channel], + ); + + const handlePresetChange = useCallback( + (name: string) => { + setPreset(name); + const presetPoints = CURVE_PRESETS[name]; + if (presetPoints) { + setCurves((prev) => ({ + ...prev, + [channel]: presetPoints.map((p) => ({ ...p })), + })); + } + }, + [channel], + ); + + return ( +
+
+ + +
+ + + +

+ Click to add point. Drag to move. Double-click to remove. +

+
+ ); +} + +// --------------------------------------------------------------------------- +// Filters Section +// --------------------------------------------------------------------------- + +function ToggleFiltersSection() { + const filters = useEditorStore((s) => s.filters); + const toggleFilter = useEditorStore((s) => s.toggleFilter); + + return ( +
+ {TOGGLE_FILTERS.map((type) => { + const filter = filters.find((f) => f.type === type); + if (!filter) return null; + + return ( + + ); + })} +
+ ); +} + +function SliderFiltersSection() { + const filters = useEditorStore((s) => s.filters); + const toggleFilter = useEditorStore((s) => s.toggleFilter); + const setFilterParam = useEditorStore((s) => s.setFilterParam); + + return ( +
+ {SLIDER_FILTERS.map(({ type, label, params }) => { + const filter = filters.find((f) => f.type === type); + if (!filter) return null; + + return ( +
+ + {filter.enabled && + params.map((p) => ( + setFilterParam(type, p.key, v)} + /> + ))} +
+ ); + })} +
+ ); +} + +function AdditionalBlursSection() { + const filters = useEditorStore((s) => s.filters); + const toggleFilter = useEditorStore((s) => s.toggleFilter); + const setFilterParam = useEditorStore((s) => s.setFilterParam); + + return ( +
+ {BLUR_FILTERS.map(({ type, label, params }) => { + const filter = filters.find((f) => f.type === type); + if (!filter) return null; + + return ( +
+ + {filter.enabled && + params.map((p) => ( + setFilterParam(type, p.key, v)} + /> + ))} +
+ ); + })} +
+ ); +} + +function VignetteSection() { + const filters = useEditorStore((s) => s.filters); + const toggleFilter = useEditorStore((s) => s.toggleFilter); + const setFilterParam = useEditorStore((s) => s.setFilterParam); + + const filter = filters.find((f) => f.type === "vignette"); + if (!filter) return null; + + return ( +
+ + {filter.enabled && + VIGNETTE_PARAMS.map((p) => ( + setFilterParam("vignette", p.key, v)} + /> + ))} +
+ ); +} + +function GrainSection() { + const filters = useEditorStore((s) => s.filters); + const toggleFilter = useEditorStore((s) => s.toggleFilter); + const setFilterParam = useEditorStore((s) => s.setFilterParam); + + const filter = filters.find((f) => f.type === "grain"); + if (!filter) return null; + + return ( +
+ + {filter.enabled && + GRAIN_PARAMS.map((p) => ( + setFilterParam("grain", p.key, v)} + /> + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Panel +// --------------------------------------------------------------------------- + +export function AdjustmentsPanel() { + const adjustments = useEditorStore((s) => s.adjustments); + const filters = useEditorStore((s) => s.filters); + const resetAdjustments = useEditorStore((s) => s.resetAdjustments); + + const hasChanges = useMemo(() => { + const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0); + const hasFilterChanges = filters.some((f) => f.enabled); + return hasAdjustmentChanges || hasFilterChanges; + }, [adjustments, filters]); + + const handleResetAll = useCallback(() => { + resetAdjustments(); + // Reset all filters by toggling off any enabled ones + const store = useEditorStore.getState(); + for (const f of store.filters) { + if (f.enabled) { + store.toggleFilter(f.type); + } + } + }, [resetAdjustments]); + + const handleApply = useCallback(() => { + // Bake adjustments and filters into pixel data + // For now, mark dirty and reset adjustment values + // The actual baking happens in the canvas rendering pipeline + const store = useEditorStore.getState(); + store.markDirty(); + resetAdjustments(); + for (const f of store.filters) { + if (f.enabled) { + store.toggleFilter(f.type); + } + } + }, [resetAdjustments]); + + return ( +
+ {/* Histogram */} + + + {/* Auto Adjustments */} + + + + {/* Adjustment Sliders */} + + + + {/* Levels */} + + + + {/* Curves */} + + + + {/* Filters */} + + + + + {/* Additional Blurs */} + + + + {/* Vignette & Grain */} + + + + + {/* Action Buttons */} +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/histogram-panel.tsx b/apps/web/src/components/editor/panels/histogram-panel.tsx new file mode 100644 index 00000000..966be916 --- /dev/null +++ b/apps/web/src/components/editor/panels/histogram-panel.tsx @@ -0,0 +1,206 @@ +// apps/web/src/components/editor/panels/histogram-panel.tsx + +import { useCallback, useEffect, useRef, useState } from "react"; + +const HIST_WIDTH = 256; +const HIST_HEIGHT = 80; +const DEBOUNCE_MS = 300; + +interface HistogramStats { + mean: [number, number, number]; + stdDev: [number, number, number]; + median: [number, number, number]; +} + +function computeHistogram(imageData: ImageData) { + const rBins = new Uint32Array(256); + const gBins = new Uint32Array(256); + const bBins = new Uint32Array(256); + const { data } = imageData; + + for (let i = 0; i < data.length; i += 4) { + rBins[data[i]]++; + gBins[data[i + 1]]++; + bBins[data[i + 2]]++; + } + + return { rBins, gBins, bBins }; +} + +function computeStats(rBins: Uint32Array, gBins: Uint32Array, bBins: Uint32Array): HistogramStats { + const channelStats = (bins: Uint32Array): [number, number, number] => { + let total = 0; + let sum = 0; + let sumSq = 0; + + for (let i = 0; i < 256; i++) { + total += bins[i]; + sum += i * bins[i]; + sumSq += i * i * bins[i]; + } + + if (total === 0) return [0, 0, 0]; + + const mean = sum / total; + const variance = sumSq / total - mean * mean; + const stdDev = Math.sqrt(Math.max(0, variance)); + + // Median + let cumulative = 0; + const half = total / 2; + let median = 0; + for (let i = 0; i < 256; i++) { + cumulative += bins[i]; + if (cumulative >= half) { + median = i; + break; + } + } + + return [Math.round(mean * 10) / 10, Math.round(stdDev * 10) / 10, median]; + }; + + const [rMean, rStdDev, rMedian] = channelStats(rBins); + const [gMean, gStdDev, gMedian] = channelStats(gBins); + const [bMean, bStdDev, bMedian] = channelStats(bBins); + + return { + mean: [rMean, gMean, bMean], + stdDev: [rStdDev, gStdDev, bStdDev], + median: [rMedian, gMedian, bMedian], + }; +} + +function drawChannel( + ctx: CanvasRenderingContext2D, + bins: Uint32Array, + maxVal: number, + color: string, +) { + if (maxVal === 0) return; + + ctx.fillStyle = color; + ctx.globalAlpha = 0.4; + ctx.beginPath(); + ctx.moveTo(0, HIST_HEIGHT); + + for (let i = 0; i < 256; i++) { + const h = (bins[i] / maxVal) * HIST_HEIGHT; + ctx.lineTo(i, HIST_HEIGHT - h); + } + + ctx.lineTo(255, HIST_HEIGHT); + ctx.closePath(); + ctx.fill(); + + ctx.strokeStyle = color; + ctx.globalAlpha = 0.7; + ctx.lineWidth = 1; + ctx.beginPath(); + + for (let i = 0; i < 256; i++) { + const h = (bins[i] / maxVal) * HIST_HEIGHT; + if (i === 0) { + ctx.moveTo(i, HIST_HEIGHT - h); + } else { + ctx.lineTo(i, HIST_HEIGHT - h); + } + } + + ctx.stroke(); + ctx.globalAlpha = 1; +} + +interface HistogramPanelProps { + stageRef?: React.RefObject; + imageData?: ImageData | null; +} + +export function HistogramPanel({ imageData }: HistogramPanelProps) { + const canvasRef = useRef(null); + const [stats, setStats] = useState(null); + const debounceRef = useRef>(null); + + const renderHistogram = useCallback((data: ImageData) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const { rBins, gBins, bBins } = computeHistogram(data); + const newStats = computeStats(rBins, gBins, bBins); + setStats(newStats); + + // Find max for normalization + let maxVal = 0; + for (let i = 0; i < 256; i++) { + maxVal = Math.max(maxVal, rBins[i], gBins[i], bBins[i]); + } + + ctx.clearRect(0, 0, HIST_WIDTH, HIST_HEIGHT); + + // Background + ctx.fillStyle = "rgba(0, 0, 0, 0.15)"; + ctx.fillRect(0, 0, HIST_WIDTH, HIST_HEIGHT); + + drawChannel(ctx, rBins, maxVal, "rgba(239, 68, 68, 1)"); + drawChannel(ctx, gBins, maxVal, "rgba(34, 197, 94, 1)"); + drawChannel(ctx, bBins, maxVal, "rgba(59, 130, 246, 1)"); + }, []); + + useEffect(() => { + if (!imageData) return; + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + renderHistogram(imageData); + }, DEBOUNCE_MS); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [imageData, renderHistogram]); + + return ( +
+ + {stats && ( +
+
+ Mean +
+ {stats.mean[0]}{" "} + {stats.mean[1]}{" "} + {stats.mean[2]} +
+
+ StdDev +
+ {stats.stdDev[0]}{" "} + {stats.stdDev[1]}{" "} + {stats.stdDev[2]} +
+
+ Median +
+ {stats.median[0]}{" "} + {stats.median[1]}{" "} + {stats.median[2]} +
+
+ )} +
+ ); +} From 87bbf2f88972722d871216e670d044815763d133 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:24:34 +0800 Subject: [PATCH 09/65] feat: add drawing and shape tools for image editor Implement 8 tool hooks, 7 options bar components, and 1 fill dialog: Tools: - Brush: Konva.Line with smooth strokes, hardness via shadowBlur - Eraser: brush with globalCompositeOperation destination-out - Shape: Rectangle, Ellipse, Line, Arrow, Polygon, Star with Shift constraint - Fill (Paint Bucket): scanline flood fill with tolerance and contiguous mode - Gradient: linear/radial via drag gesture on offscreen canvas - Clone Stamp: Alt+click source, paint cloned pixels with aligned mode - Dodge/Burn/Sponge: per-pixel brightness/saturation manipulation by range - Pixel Brush: blur (box blur), sharpen (unsharp), smudge (directional blend) Options bars: - Brush: size/opacity/hardness sliders - Shape: type selector, fill/stroke color, stroke width, corner radius, sides - Fill: tolerance slider, contiguous checkbox - Gradient: linear/radial toggle, opacity, reverse - Clone Stamp: size/opacity/hardness, aligned toggle - Dodge/Burn: tool toggle, range dropdown, exposure/flow sliders - Pixel Brush: tool toggle, size, strength Common: - Fill Dialog: Shift+Backspace modal with foreground/background/color/white/black/50% gray --- .../components/editor/common/fill-dialog.tsx | 217 +++++++++++ .../editor/options/brush-options.tsx | 89 +++++ .../editor/options/clone-stamp-options.tsx | 105 ++++++ .../editor/options/dodge-burn-options.tsx | 163 ++++++++ .../editor/options/fill-options.tsx | 64 ++++ .../editor/options/gradient-options.tsx | 87 +++++ .../editor/options/pixel-brush-options.tsx | 84 +++++ .../editor/options/shape-options.tsx | 175 +++++++++ .../components/editor/tools/brush-tool.tsx | 88 +++++ .../editor/tools/clone-stamp-tool.tsx | 213 +++++++++++ .../editor/tools/dodge-burn-tool.tsx | 262 +++++++++++++ .../components/editor/tools/eraser-tool.tsx | 88 +++++ .../src/components/editor/tools/fill-tool.tsx | 226 +++++++++++ .../components/editor/tools/gradient-tool.tsx | 141 +++++++ .../editor/tools/pixel-brush-tool.tsx | 356 ++++++++++++++++++ .../components/editor/tools/shape-tool.tsx | 313 +++++++++++++++ 16 files changed, 2671 insertions(+) create mode 100644 apps/web/src/components/editor/common/fill-dialog.tsx create mode 100644 apps/web/src/components/editor/options/brush-options.tsx create mode 100644 apps/web/src/components/editor/options/clone-stamp-options.tsx create mode 100644 apps/web/src/components/editor/options/dodge-burn-options.tsx create mode 100644 apps/web/src/components/editor/options/fill-options.tsx create mode 100644 apps/web/src/components/editor/options/gradient-options.tsx create mode 100644 apps/web/src/components/editor/options/pixel-brush-options.tsx create mode 100644 apps/web/src/components/editor/options/shape-options.tsx create mode 100644 apps/web/src/components/editor/tools/brush-tool.tsx create mode 100644 apps/web/src/components/editor/tools/clone-stamp-tool.tsx create mode 100644 apps/web/src/components/editor/tools/dodge-burn-tool.tsx create mode 100644 apps/web/src/components/editor/tools/eraser-tool.tsx create mode 100644 apps/web/src/components/editor/tools/fill-tool.tsx create mode 100644 apps/web/src/components/editor/tools/gradient-tool.tsx create mode 100644 apps/web/src/components/editor/tools/pixel-brush-tool.tsx create mode 100644 apps/web/src/components/editor/tools/shape-tool.tsx 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 }; +} From 2ae97a6c88f01e031740f43119a1f4df1c977567 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:28:29 +0800 Subject: [PATCH 10/65] feat: add history panel, keyboard shortcuts, export dialog, and navigator minimap Implement Agent 7 features for the image editor: - History panel with undo/redo list, action icons, and state jumping - 51+ keyboard shortcuts via react-hotkeys-hook (tools, modifiers, clipboard) - Export dialog with PNG/JPEG/WebP format, quality, resize, clipboard copy - Navigator minimap with viewport rectangle, drag-to-pan, zoom slider - Autosave/recovery system with localStorage and recovery banner - Project save/load (.snapotter JSON format) - Unsaved changes warning via beforeunload - Paste from system clipboard support --- apps/web/package.json | 8 +- .../editor/common/custom-cursor.tsx | 87 ++ .../editor/common/export-dialog.tsx | 684 ++++++++++++++ .../components/editor/common/icon-button.tsx | 52 ++ .../editor/common/new-document-dialog.tsx | 164 ++++ .../editor/common/welcome-screen.tsx | 104 +++ .../src/components/editor/editor-canvas.tsx | 161 ++++ .../components/editor/editor-options-bar.tsx | 17 + .../components/editor/editor-right-panel.tsx | 67 ++ .../components/editor/editor-status-bar.tsx | 42 + .../src/components/editor/editor-toolbar.tsx | 183 ++++ .../editor/panels/history-panel.tsx | 219 +++++ .../editor/panels/navigator-panel.tsx | 280 ++++++ apps/web/src/hooks/use-canvas-zoom.ts | 108 +++ apps/web/src/hooks/use-editor-shortcuts.ts | 868 ++++++++++++++++++ apps/web/src/pages/editor-page.tsx | 76 ++ apps/web/src/stores/editor-store.ts | 724 +++++++++++++++ apps/web/src/types/editor.ts | 430 +++++++++ pnpm-lock.yaml | 125 +++ 19 files changed, 4398 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/editor/common/custom-cursor.tsx create mode 100644 apps/web/src/components/editor/common/export-dialog.tsx create mode 100644 apps/web/src/components/editor/common/icon-button.tsx create mode 100644 apps/web/src/components/editor/common/new-document-dialog.tsx create mode 100644 apps/web/src/components/editor/common/welcome-screen.tsx create mode 100644 apps/web/src/components/editor/editor-canvas.tsx create mode 100644 apps/web/src/components/editor/editor-options-bar.tsx create mode 100644 apps/web/src/components/editor/editor-right-panel.tsx create mode 100644 apps/web/src/components/editor/editor-status-bar.tsx create mode 100644 apps/web/src/components/editor/editor-toolbar.tsx create mode 100644 apps/web/src/components/editor/panels/history-panel.tsx create mode 100644 apps/web/src/components/editor/panels/navigator-panel.tsx create mode 100644 apps/web/src/hooks/use-canvas-zoom.ts create mode 100644 apps/web/src/hooks/use-editor-shortcuts.ts create mode 100644 apps/web/src/pages/editor-page.tsx create mode 100644 apps/web/src/stores/editor-store.ts create mode 100644 apps/web/src/types/editor.ts diff --git a/apps/web/package.json b/apps/web/package.json index d0db389a..0c2fefeb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,25 +12,31 @@ "clean": "rm -rf dist" }, "dependencies": { - "@snapotter/shared": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@sentry/react": "^10.49.0", + "@snapotter/shared": "workspace:*", "@use-gesture/react": "^10.3.1", "clsx": "^2.1.0", "fflate": "^0.8.2", "jszip": "^3.10.1", + "konva": "^10", "leaflet": "^1.9.4", "lucide-react": "^0.469.0", "posthog-js": "^1.370.0", "qr-code-styling": "^1.9.2", "react": "^19.0.0", + "react-colorful": "^5", "react-dom": "^19.0.0", + "react-hotkeys-hook": "^5", "react-image-crop": "^11.0.10", + "react-konva": "^19", "react-router-dom": "^7.1.0", "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", + "use-image": "^1", + "zundo": "^2", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/web/src/components/editor/common/custom-cursor.tsx b/apps/web/src/components/editor/common/custom-cursor.tsx new file mode 100644 index 00000000..e6d364f5 --- /dev/null +++ b/apps/web/src/components/editor/common/custom-cursor.tsx @@ -0,0 +1,87 @@ +// apps/web/src/components/editor/common/custom-cursor.tsx +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +const TOOL_CURSORS: Record = { + move: "default", + "marquee-rect": "crosshair", + "marquee-ellipse": "crosshair", + "lasso-free": "crosshair", + "lasso-poly": "crosshair", + "magic-wand": "crosshair", + crop: "crosshair", + eyedropper: "crosshair", + brush: "none", + eraser: "none", + pencil: "none", + "clone-stamp": "none", + dodge: "none", + burn: "none", + sponge: "none", + "blur-brush": "none", + "sharpen-brush": "none", + smudge: "none", + fill: "crosshair", + gradient: "crosshair", + "shape-rect": "crosshair", + "shape-ellipse": "crosshair", + "shape-line": "crosshair", + "shape-arrow": "crosshair", + "shape-polygon": "crosshair", + "shape-star": "crosshair", + text: "text", + hand: "grab", + zoom: "zoom-in", + transform: "default", +}; + +const BRUSH_CURSOR_TOOLS = new Set([ + "brush", + "eraser", + "pencil", + "clone-stamp", + "dodge", + "burn", + "sponge", + "blur-brush", + "sharpen-brush", + "smudge", +]); + +export function useEditorCursor(): string { + const activeTool = useEditorStore((s) => s.activeTool); + const isSpaceHeld = useEditorStore((s) => s.isSpaceHeld); + if (isSpaceHeld) return "grab"; + return TOOL_CURSORS[activeTool] || "default"; +} + +interface BrushCursorOverlayProps { + containerRef: React.RefObject; +} + +export function BrushCursorOverlay({ containerRef: _containerRef }: BrushCursorOverlayProps) { + const activeTool = useEditorStore((s) => s.activeTool); + const brushSize = useEditorStore((s) => s.brushSize); + const zoom = useEditorStore((s) => s.zoom); + const cursorPosition = useEditorStore((s) => s.cursorPosition); + + if (!BRUSH_CURSOR_TOOLS.has(activeTool)) return null; + + const displaySize = brushSize * zoom; + const isEraser = activeTool === "eraser"; + + return ( +
+ ); +} diff --git a/apps/web/src/components/editor/common/export-dialog.tsx b/apps/web/src/components/editor/common/export-dialog.tsx new file mode 100644 index 00000000..21b9c552 --- /dev/null +++ b/apps/web/src/components/editor/common/export-dialog.tsx @@ -0,0 +1,684 @@ +// apps/web/src/components/editor/common/export-dialog.tsx + +import { + Check, + ClipboardCopy, + Download, + FileDown, + FileUp, + Lock, + Save, + Unlock, + X, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { + AdjustmentValues, + CanvasObject, + EditorLayer, + FilterConfig, + Guide, +} from "@/types/editor"; + +type ExportFormat = "png" | "jpeg" | "webp"; + +interface ExportSettings { + format: ExportFormat; + quality: number; + width: number; + height: number; + lockAspect: boolean; + transparent: boolean; +} + +const FORMAT_OPTIONS: { value: ExportFormat; label: string; supportsTransparency: boolean }[] = [ + { value: "png", label: "PNG", supportsTransparency: true }, + { value: "jpeg", label: "JPEG", supportsTransparency: false }, + { value: "webp", label: "WebP", supportsTransparency: true }, +]; + +function getMimeType(format: ExportFormat): string { + switch (format) { + case "png": + return "image/png"; + case "jpeg": + return "image/jpeg"; + case "webp": + return "image/webp"; + } +} + +export function ExportDialog({ onClose }: { onClose: () => void }) { + const canvasSize = useEditorStore((s) => s.canvasSize); + const markClean = useEditorStore((s) => s.markClean); + + const [settings, setSettings] = useState({ + format: "png", + quality: 92, + width: canvasSize.width, + height: canvasSize.height, + lockAspect: true, + transparent: true, + }); + const [previewUrl, setPreviewUrl] = useState(null); + const [copyStatus, setCopyStatus] = useState<"idle" | "copied">("idle"); + + const aspectRatio = canvasSize.width / canvasSize.height; + const dialogRef = useRef(null); + + const generatePreview = useCallback(() => { + // Create a thumbnail canvas for preview + const canvas = document.createElement("canvas"); + const maxPreview = 200; + const scale = Math.min(maxPreview / canvasSize.width, maxPreview / canvasSize.height); + canvas.width = Math.round(canvasSize.width * scale); + canvas.height = Math.round(canvasSize.height * scale); + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + if (!settings.transparent || settings.format === "jpeg") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + // Try to draw from the Konva stage if available + const stageCanvas = document.querySelector( + "[data-testid='editor-canvas'] canvas", + ) as HTMLCanvasElement | null; + if (stageCanvas) { + ctx.drawImage(stageCanvas, 0, 0, canvas.width, canvas.height); + } + + const url = canvas.toDataURL(getMimeType(settings.format), settings.quality / 100); + setPreviewUrl(url); + }, [canvasSize, settings.format, settings.quality, settings.transparent]); + + // Generate preview thumbnail on format/transparency change + useEffect(() => { + generatePreview(); + }, [generatePreview]); + + // Handle width change with aspect lock + const handleWidthChange = useCallback( + (w: number) => { + const newWidth = Math.max(1, w); + if (settings.lockAspect) { + setSettings((prev) => ({ + ...prev, + width: newWidth, + height: Math.round(newWidth / aspectRatio), + })); + } else { + setSettings((prev) => ({ ...prev, width: newWidth })); + } + }, + [settings.lockAspect, aspectRatio], + ); + + // Handle height change with aspect lock + const handleHeightChange = useCallback( + (h: number) => { + const newHeight = Math.max(1, h); + if (settings.lockAspect) { + setSettings((prev) => ({ + ...prev, + height: newHeight, + width: Math.round(newHeight * aspectRatio), + })); + } else { + setSettings((prev) => ({ ...prev, height: newHeight })); + } + }, + [settings.lockAspect, aspectRatio], + ); + + // Export as file download + const handleExport = useCallback(() => { + const stageCanvas = document.querySelector( + "[data-testid='editor-canvas'] canvas", + ) as HTMLCanvasElement | null; + if (!stageCanvas) return; + + // Create export canvas at the requested dimensions + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = settings.width; + exportCanvas.height = settings.height; + const ctx = exportCanvas.getContext("2d"); + if (!ctx) return; + + if (!settings.transparent || settings.format === "jpeg") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); + } + + ctx.drawImage(stageCanvas, 0, 0, settings.width, settings.height); + + exportCanvas.toBlob( + (blob) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `export.${settings.format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + markClean(); + }, + getMimeType(settings.format), + settings.format === "png" ? undefined : settings.quality / 100, + ); + }, [settings, markClean]); + + // Copy to clipboard + const handleCopyToClipboard = useCallback(async () => { + const stageCanvas = document.querySelector( + "[data-testid='editor-canvas'] canvas", + ) as HTMLCanvasElement | null; + if (!stageCanvas) return; + + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = settings.width; + exportCanvas.height = settings.height; + const ctx = exportCanvas.getContext("2d"); + if (!ctx) return; + + if (!settings.transparent || settings.format === "jpeg") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); + } + + ctx.drawImage(stageCanvas, 0, 0, settings.width, settings.height); + + try { + const blob = await new Promise((resolve) => + exportCanvas.toBlob(resolve, "image/png"), + ); + if (!blob) return; + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + setCopyStatus("copied"); + setTimeout(() => setCopyStatus("idle"), 2000); + } catch { + // Clipboard API may not be available in all contexts + } + }, [settings]); + + // Project save (.snapotter file) + const handleSaveProject = useCallback(() => { + const state = useEditorStore.getState(); + const projectData = { + version: 1, + canvasSize: state.canvasSize, + layers: state.layers, + objects: state.objects, + adjustments: state.adjustments, + filters: state.filters, + guides: state.guides, + sourceImageUrl: state.sourceImageUrl, + sourceImageSize: state.sourceImageSize, + foregroundColor: state.foregroundColor, + backgroundColor: state.backgroundColor, + }; + + const json = JSON.stringify(projectData, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "project.snapotter"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + markClean(); + }, [markClean]); + + // Project load (.snapotter file) + const handleLoadProject = useCallback(() => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".snapotter,.json"; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const data = JSON.parse(reader.result as string); + if (!data.version || !data.canvasSize) return; + + const store = useEditorStore.getState(); + const setState = useEditorStore.setState; + + setState({ + canvasSize: data.canvasSize, + layers: data.layers || store.layers, + objects: data.objects || [], + adjustments: data.adjustments || store.adjustments, + filters: data.filters || store.filters, + guides: data.guides || [], + sourceImageUrl: data.sourceImageUrl || null, + sourceImageSize: data.sourceImageSize || null, + foregroundColor: data.foregroundColor || "#000000", + backgroundColor: data.backgroundColor || "#ffffff", + isDirty: false, + lastAction: "Load Project", + _historyVersion: store._historyVersion + 1, + }); + + onClose(); + } catch { + // Invalid project file + } + }; + reader.readAsText(file); + }; + input.click(); + }, [onClose]); + + // Close on Escape + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onClose]); + + // Close on backdrop click + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) { + onClose(); + } + }, + [onClose], + ); + + const supportsQuality = settings.format === "jpeg" || settings.format === "webp"; + const supportsTransparency = settings.format !== "jpeg"; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: modal backdrop click-to-dismiss uses Escape as keyboard equivalent +
+
+ {/* Header */} +
+

Export Image

+ +
+ + {/* Body */} +
+ {/* Preview */} + {previewUrl && ( +
+ Export preview +
+ )} + + {/* Format */} +
+ Format +
+ {FORMAT_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Quality */} + {supportsQuality && ( +
+
+ Quality + + {settings.quality}% + +
+ + setSettings((prev) => ({ ...prev, quality: Number.parseInt(e.target.value, 10) })) + } + className={cn( + "w-full h-1.5 appearance-none rounded-full bg-muted", + "[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3", + "[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer", + )} + /> +
+ )} + + {/* Dimensions */} +
+ + Dimensions + +
+
+ handleWidthChange(Number.parseInt(e.target.value, 10) || 1)} + className="w-full px-2 py-1 text-xs bg-muted rounded border border-border text-foreground outline-none focus:border-primary" + min={1} + /> + Width +
+ +
+ handleHeightChange(Number.parseInt(e.target.value, 10) || 1)} + className="w-full px-2 py-1 text-xs bg-muted rounded border border-border text-foreground outline-none focus:border-primary" + min={1} + /> + Height +
+
+ +
+ + {/* Transparent background */} + {supportsTransparency && ( + + )} +
+ + {/* Footer actions */} +
+ {/* Primary export actions */} +
+ + +
+ + {/* Project save/load */} +
+ + +
+
+
+
+ ); +} + +// ---- Autosave utilities (Feature 44) ---- + +const AUTOSAVE_KEY = "snapotter-editor-autosave"; +const AUTOSAVE_INTERVAL_MS = 60_000; + +interface AutosaveState { + canvasSize: { width: number; height: number }; + layers: EditorLayer[]; + objects: CanvasObject[]; + adjustments: AdjustmentValues; + filters: FilterConfig[]; + guides: Guide[]; + sourceImageUrl: string | null; + sourceImageSize: { width: number; height: number } | null; + foregroundColor: string; + backgroundColor: string; +} + +interface AutosaveData { + version: 1; + timestamp: number; + state: AutosaveState; +} + +export function saveEditorState(): void { + try { + const s = useEditorStore.getState(); + const data: AutosaveData = { + version: 1, + timestamp: Date.now(), + state: { + canvasSize: s.canvasSize, + layers: s.layers, + objects: s.objects, + adjustments: s.adjustments, + filters: s.filters, + guides: s.guides, + sourceImageUrl: s.sourceImageUrl, + sourceImageSize: s.sourceImageSize, + foregroundColor: s.foregroundColor, + backgroundColor: s.backgroundColor, + }, + }; + localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data)); + useEditorStore.setState({ lastAutoSave: Date.now() }); + } catch { + // localStorage might be full or unavailable + } +} + +export function loadAutosaveState(): AutosaveData | null { + try { + const raw = localStorage.getItem(AUTOSAVE_KEY); + if (!raw) return null; + const data = JSON.parse(raw) as AutosaveData; + if (data.version !== 1 || !data.state?.canvasSize) return null; + return data; + } catch { + return null; + } +} + +export function clearAutosave(): void { + try { + localStorage.removeItem(AUTOSAVE_KEY); + } catch { + // ignore + } +} + +export function restoreAutosave(data: AutosaveData): void { + const store = useEditorStore.getState(); + useEditorStore.setState({ + ...data.state, + isDirty: true, + lastAction: "Restore Autosave", + _historyVersion: store._historyVersion + 1, + }); +} + +/** + * Hook to run autosave on an interval. Call this in EditorPage. + * Returns recovery state if found on mount. + */ +export function useAutosave(): { + recoveryData: AutosaveData | null; + dismissRecovery: () => void; + restoreRecovery: () => void; +} { + const [recoveryData, setRecoveryData] = useState(null); + const isDirty = useEditorStore((s) => s.isDirty); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + // Check for recovery on mount + useEffect(() => { + const data = loadAutosaveState(); + if (data) { + setRecoveryData(data); + } + }, []); + + // Autosave interval + useEffect(() => { + if (!sourceImageUrl) return; + + const timer = setInterval(() => { + if (isDirty) { + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => saveEditorState()); + } else { + saveEditorState(); + } + } + }, AUTOSAVE_INTERVAL_MS); + + return () => clearInterval(timer); + }, [isDirty, sourceImageUrl]); + + const dismissRecovery = useCallback(() => { + clearAutosave(); + setRecoveryData(null); + }, []); + + const handleRestore = useCallback(() => { + if (recoveryData) { + restoreAutosave(recoveryData); + clearAutosave(); + setRecoveryData(null); + } + }, [recoveryData]); + + return { recoveryData, dismissRecovery, restoreRecovery: handleRestore }; +} + +/** + * Recovery banner component for display at the top of the editor. + */ +export function AutosaveRecoveryBanner({ + data, + onRestore, + onDiscard, +}: { + data: AutosaveData; + onRestore: () => void; + onDiscard: () => void; +}) { + const timeStr = new Date(data.timestamp).toLocaleString(); + + return ( +
+ + Recovered unsaved work from {timeStr}. +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/common/icon-button.tsx b/apps/web/src/components/editor/common/icon-button.tsx new file mode 100644 index 00000000..04e503a1 --- /dev/null +++ b/apps/web/src/components/editor/common/icon-button.tsx @@ -0,0 +1,52 @@ +// apps/web/src/components/editor/common/icon-button.tsx +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface IconButtonProps { + icon: LucideIcon; + label: string; + shortcut?: string; + active?: boolean; + disabled?: boolean; + size?: number; + onClick?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; + className?: string; + "data-testid"?: string; + "data-tool"?: string; + "data-tool-active"?: string; +} + +export function IconButton({ + icon: Icon, + label, + shortcut, + active, + disabled, + size = 18, + onClick, + onContextMenu, + className, + ...dataProps +}: IconButtonProps) { + return ( + + ); +} diff --git a/apps/web/src/components/editor/common/new-document-dialog.tsx b/apps/web/src/components/editor/common/new-document-dialog.tsx new file mode 100644 index 00000000..7e429def --- /dev/null +++ b/apps/web/src/components/editor/common/new-document-dialog.tsx @@ -0,0 +1,164 @@ +// apps/web/src/components/editor/common/new-document-dialog.tsx +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +const PRESETS = [ + { label: "Custom", width: 1920, height: 1080 }, + { label: "1920x1080 (HD)", width: 1920, height: 1080 }, + { label: "3840x2160 (4K)", width: 3840, height: 2160 }, + { label: "1080x1080 (Instagram)", width: 1080, height: 1080 }, + { label: "1200x628 (Facebook)", width: 1200, height: 628 }, + { label: "800x600", width: 800, height: 600 }, + { label: "1280x720", width: 1280, height: 720 }, +]; + +const BACKGROUNDS = ["White", "Black", "Transparent"] as const; + +interface NewDocumentDialogProps { + open: boolean; + onClose: () => void; +} + +export function NewDocumentDialog({ open, onClose }: NewDocumentDialogProps) { + const [width, setWidth] = useState(1920); + const [height, setHeight] = useState(1080); + const [preset, setPreset] = useState("1920x1080 (HD)"); + const [background, setBackground] = useState<(typeof BACKGROUNDS)[number]>("White"); + const loadImage = useEditorStore((s) => s.loadImage); + + if (!open) return null; + + const handlePresetChange = (e: React.ChangeEvent) => { + const selected = PRESETS.find((p) => p.label === e.target.value); + if (selected) { + setPreset(selected.label); + if (selected.label !== "Custom") { + setWidth(selected.width); + setHeight(selected.height); + } + } + }; + + const handleCreate = () => { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (ctx) { + if (background === "White") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + } else if (background === "Black") { + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, width, height); + } + } + const url = canvas.toDataURL("image/png"); + loadImage(url, width, height); + onClose(); + }; + + return ( +
+
+

New Document

+ +
+
+ + +
+ +
+
+ + { + setWidth(Number(e.target.value)); + setPreset("Custom"); + }} + className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground" + min={1} + max={10000} + /> +
+
+ + { + setHeight(Number(e.target.value)); + setPreset("Custom"); + }} + className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground" + min={1} + max={10000} + /> +
+
+ +
+ Background +
+ {BACKGROUNDS.map((bg) => ( + + ))} +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/welcome-screen.tsx b/apps/web/src/components/editor/common/welcome-screen.tsx new file mode 100644 index 00000000..2c7ffe68 --- /dev/null +++ b/apps/web/src/components/editor/common/welcome-screen.tsx @@ -0,0 +1,104 @@ +// apps/web/src/components/editor/common/welcome-screen.tsx + +import { FilePlus, ImagePlus } from "lucide-react"; +import { useCallback, useState } from "react"; +import { useEditorStore } from "@/stores/editor-store"; +import { NewDocumentDialog } from "./new-document-dialog"; + +const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg"; + +export function WelcomeScreen() { + const [showNewDoc, setShowNewDoc] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const loadImage = useEditorStore((s) => s.loadImage); + + const handleFile = useCallback( + (file: File) => { + if (!file.type.startsWith("image/")) { + return; + } + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + loadImage(url, img.naturalWidth, img.naturalHeight); + }; + img.src = url; + }, + [loadImage], + ); + + const handleOpenFile = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ACCEPTED_TYPES; + input.onchange = () => { + const file = input.files?.[0]; + if (file) handleFile(file); + }; + input.click(); + }; + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }, + [handleFile], + ); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = () => setIsDragOver(false); + + return ( + <> +
+
+
+

Image Editor

+

Drop an image here to get started

+
+ +
+ + + +
+ +

Or paste from clipboard (Ctrl+V)

+
+
+ + setShowNewDoc(false)} /> + + ); +} diff --git a/apps/web/src/components/editor/editor-canvas.tsx b/apps/web/src/components/editor/editor-canvas.tsx new file mode 100644 index 00000000..1da80667 --- /dev/null +++ b/apps/web/src/components/editor/editor-canvas.tsx @@ -0,0 +1,161 @@ +// apps/web/src/components/editor/editor-canvas.tsx + +import type Konva from "konva"; +import React, { useCallback, useEffect, useRef } from "react"; +import { Layer, Shape, Stage } from "react-konva"; +import { useCanvasZoom } from "@/hooks/use-canvas-zoom"; +import { useEditorStore } from "@/stores/editor-store"; +import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor"; + +const CHECKERBOARD_SIZE = 20; +const CHECKERBOARD_CSS = ` + repeating-conic-gradient( + rgba(128, 128, 128, 0.15) 0% 25%, + transparent 0% 50% + ) +`; + +export function EditorCanvas() { + const containerRef = useRef(null); + const { stageRef, handleWheel, fitToScreen } = useCanvasZoom(); + + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + const canvasSize = useEditorStore((s) => s.canvasSize); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + const setCursorPosition = useEditorStore((s) => s.setCursorPosition); + const gridVisible = useEditorStore((s) => s.gridVisible); + + const cursor = useEditorCursor(); + + const [stageWidth, setStageWidth] = React.useState(800); + const [stageHeight, setStageHeight] = React.useState(600); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + setStageWidth(width); + setStageHeight(height); + }); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (sourceImageUrl && stageWidth > 0 && stageHeight > 0) { + fitToScreen(stageWidth, stageHeight, canvasSize.width, canvasSize.height); + } + }, [sourceImageUrl, stageWidth, stageHeight, canvasSize.width, canvasSize.height, fitToScreen]); + + const handleMouseMove = useCallback( + (e: Konva.KonvaEventObject) => { + const stage = e.target.getStage(); + if (!stage) return; + const pointer = stage.getPointerPosition(); + if (!pointer) return; + const x = Math.round((pointer.x - panOffset.x) / zoom); + const y = Math.round((pointer.y - panOffset.y) / zoom); + setCursorPosition({ x, y }); + }, + [zoom, panOffset, setCursorPosition], + ); + + const checkerboardSize = CHECKERBOARD_SIZE * zoom; + + return ( +
+ + {/* Canvas objects are rendered here by tool components */} + {/* Grid overlay layer (Feature 49) - non-interactive */} + {(gridVisible || zoom >= 8) && ( + + = 8} + /> + + )} + + +
+ ); +} + +function GridOverlay({ + canvasWidth, + canvasHeight, + zoom, + showGrid, + showPixelGrid, +}: { + canvasWidth: number; + canvasHeight: number; + zoom: number; + showGrid: boolean; + showPixelGrid: boolean; +}) { + return ( + { + ctx.beginPath(); + + if (showGrid) { + const spacing = 50; + ctx.strokeStyle = "rgba(128, 128, 128, 0.15)"; + ctx.lineWidth = 1 / zoom; + for (let x = spacing; x < canvasWidth; x += spacing) { + ctx.moveTo(x, 0); + ctx.lineTo(x, canvasHeight); + } + for (let y = spacing; y < canvasHeight; y += spacing) { + ctx.moveTo(0, y); + ctx.lineTo(canvasWidth, y); + } + ctx.stroke(); + } + + if (showPixelGrid) { + ctx.beginPath(); + ctx.strokeStyle = "rgba(128, 128, 128, 0.1)"; + ctx.lineWidth = 1 / zoom; + for (let x = 1; x < canvasWidth; x++) { + ctx.moveTo(x, 0); + ctx.lineTo(x, canvasHeight); + } + for (let y = 1; y < canvasHeight; y++) { + ctx.moveTo(0, y); + ctx.lineTo(canvasWidth, y); + } + ctx.stroke(); + } + + ctx.fillStrokeShape(shape); + }} + /> + ); +} diff --git a/apps/web/src/components/editor/editor-options-bar.tsx b/apps/web/src/components/editor/editor-options-bar.tsx new file mode 100644 index 00000000..f02288ff --- /dev/null +++ b/apps/web/src/components/editor/editor-options-bar.tsx @@ -0,0 +1,17 @@ +// apps/web/src/components/editor/editor-options-bar.tsx +import { useEditorStore } from "@/stores/editor-store"; + +export function EditorOptionsBar() { + const activeTool = useEditorStore((s) => s.activeTool); + + return ( +
+ + {activeTool.replace(/-/g, " ").replace(/^shape /, "")} + +
+ {/* Tool-specific option components are rendered here by each agent */} +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-right-panel.tsx b/apps/web/src/components/editor/editor-right-panel.tsx new file mode 100644 index 00000000..db63ace7 --- /dev/null +++ b/apps/web/src/components/editor/editor-right-panel.tsx @@ -0,0 +1,67 @@ +// apps/web/src/components/editor/editor-right-panel.tsx +import { ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +const TABS = [ + { id: "layers" as const, label: "Layers" }, + { id: "adjustments" as const, label: "Adjustments" }, + { id: "history" as const, label: "History" }, +]; + +export function EditorRightPanel() { + const visible = useEditorStore((s) => s.rightPanelVisible); + const activeTab = useEditorStore((s) => s.rightPanelTab); + const setTab = useEditorStore((s) => s.setRightPanelTab); + const togglePanel = useEditorStore((s) => s.toggleRightPanel); + + if (!visible) { + return ( + + ); + } + + return ( +
+
+ {TABS.map((tab) => ( + + ))} + +
+
+ {/* Tab content rendered by agents: layers-panel, adjustments-panel, history-panel */} +
+
+ {/* Color panel always visible at bottom (Agent 5) */} +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-status-bar.tsx b/apps/web/src/components/editor/editor-status-bar.tsx new file mode 100644 index 00000000..0dae20af --- /dev/null +++ b/apps/web/src/components/editor/editor-status-bar.tsx @@ -0,0 +1,42 @@ +// apps/web/src/components/editor/editor-status-bar.tsx +import { useEditorStore } from "@/stores/editor-store"; + +export function EditorStatusBar() { + const cursorPosition = useEditorStore((s) => s.cursorPosition); + const canvasSize = useEditorStore((s) => s.canvasSize); + const zoom = useEditorStore((s) => s.zoom); + const setZoom = useEditorStore((s) => s.setZoom); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + const zoomPercent = Math.round(zoom * 100); + + return ( +
+
+ {sourceImageUrl && ( + <> + X: {cursorPosition.x} + Y: {cursorPosition.y} + + )} +
+
+ {sourceImageUrl && `${canvasSize.width} x ${canvasSize.height} px`} +
+
+ { + const val = Number.parseInt(e.target.value, 10); + if (!Number.isNaN(val) && val > 0) setZoom(val / 100); + }} + className="w-14 bg-transparent text-right text-xs border-none outline-none" + min={1} + max={6400} + /> + % +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-toolbar.tsx b/apps/web/src/components/editor/editor-toolbar.tsx new file mode 100644 index 00000000..e128edec --- /dev/null +++ b/apps/web/src/components/editor/editor-toolbar.tsx @@ -0,0 +1,183 @@ +// apps/web/src/components/editor/editor-toolbar.tsx +import { + ArrowUpRight, + Crop, + Eraser, + Hand, + MousePointer2, + Move, + PaintBucket, + Paintbrush, + Pen, + Pencil, + Pipette, + ScanLine, + Square, + Stamp, + Sun, + Type, + Wand2, + ZoomIn, +} from "lucide-react"; +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; +import { IconButton } from "./common/icon-button"; + +interface ToolGroup { + tools: { + tool: ToolType; + icon: typeof MousePointer2; + label: string; + shortcut: string; + }[]; +} + +const TOOL_GROUPS: ToolGroup[] = [ + { + // Group 1: Move + Free Transform + tools: [ + { tool: "move", icon: MousePointer2, label: "Move", shortcut: "V" }, + { + tool: "transform", + icon: Move, + label: "Free Transform", + shortcut: "Ctrl+T", + }, + ], + }, + { + // Group 2: Selection tools + tools: [ + { + tool: "marquee-rect", + icon: Square, + label: "Marquee", + shortcut: "M", + }, + { tool: "lasso-free", icon: Pen, label: "Lasso", shortcut: "L" }, + { + tool: "magic-wand", + icon: Wand2, + label: "Magic Wand", + shortcut: "W", + }, + ], + }, + { + // Group 3: Crop + tools: [{ tool: "crop", icon: Crop, label: "Crop", shortcut: "C" }], + }, + { + // Group 4: Eyedropper + tools: [ + { + tool: "eyedropper", + icon: Pipette, + label: "Eyedropper", + shortcut: "I", + }, + ], + }, + { + // Group 5: Brush, Eraser, Pencil + tools: [ + { tool: "brush", icon: Paintbrush, label: "Brush", shortcut: "B" }, + { tool: "eraser", icon: Eraser, label: "Eraser", shortcut: "E" }, + { tool: "pencil", icon: Pencil, label: "Pencil", shortcut: "N" }, + ], + }, + { + // Group 6: Clone Stamp + tools: [ + { + tool: "clone-stamp", + icon: Stamp, + label: "Clone Stamp", + shortcut: "S", + }, + ], + }, + { + // Group 7: Dodge, Burn, Sponge + tools: [ + { tool: "dodge", icon: Sun, label: "Dodge", shortcut: "O" }, + { tool: "burn", icon: Sun, label: "Burn", shortcut: "Shift+O" }, + { tool: "sponge", icon: Sun, label: "Sponge", shortcut: "Shift+O" }, + ], + }, + { + // Group 8: Blur brush, Sharpen brush, Smudge + tools: [ + { tool: "blur-brush", icon: ScanLine, label: "Blur Brush", shortcut: "" }, + { + tool: "sharpen-brush", + icon: ScanLine, + label: "Sharpen Brush", + shortcut: "", + }, + { tool: "smudge", icon: ScanLine, label: "Smudge", shortcut: "" }, + ], + }, + { + // Group 9: Paint Bucket, Gradient + tools: [ + { + tool: "fill", + icon: PaintBucket, + label: "Paint Bucket", + shortcut: "G", + }, + { + tool: "gradient", + icon: ArrowUpRight, + label: "Gradient", + shortcut: "Shift+G", + }, + ], + }, + { + // Group 10: Shapes + tools: [{ tool: "shape-rect", icon: Square, label: "Shape", shortcut: "U" }], + }, + { + // Group 11: Text + tools: [{ tool: "text", icon: Type, label: "Text", shortcut: "T" }], + }, + { + // Group 12: Hand, Zoom + tools: [ + { tool: "hand", icon: Hand, label: "Hand", shortcut: "H" }, + { tool: "zoom", icon: ZoomIn, label: "Zoom", shortcut: "Z" }, + ], + }, +]; + +export function EditorToolbar() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + return ( +
+ {TOOL_GROUPS.map((group, gi) => ( +
+ {gi > 0 &&
} + {group.tools.map((t) => ( + setTool(t.tool)} + data-testid={`tool-${t.tool}`} + data-tool={t.tool} + data-tool-active={String(activeTool === t.tool)} + /> + ))} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/history-panel.tsx b/apps/web/src/components/editor/panels/history-panel.tsx new file mode 100644 index 00000000..c664310f --- /dev/null +++ b/apps/web/src/components/editor/panels/history-panel.tsx @@ -0,0 +1,219 @@ +// apps/web/src/components/editor/panels/history-panel.tsx + +import { + ArrowDown, + ArrowUp, + Brush, + Copy, + Crop, + Eraser, + Layers, + MousePointer2, + Move, + Paintbrush, + Pencil, + Redo2, + RotateCcw, + Scissors, + Sliders, + Square, + Trash2, + Type, + Undo2, +} from "lucide-react"; +import { useCallback, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +// Map action labels to icons for the history list +const ACTION_ICON_MAP: Record> = { + "Brush Stroke": Brush, + "Add Line": Pencil, + "Eraser Stroke": Eraser, + "Add Rect": Square, + "Add Ellipse": Square, + "Add Text": Type, + "Add Arrow": ArrowUp, + "Add Polygon": Square, + "Add Star": Square, + "Add Image": Square, + Move: Move, + Transform: Move, + "Text Edit": Type, + "Add Layer": Layers, + "Delete Layer": Trash2, + "Duplicate Layer": Copy, + "Reorder Layers": ArrowDown, + "Merge Down": Layers, + "Flatten All": Layers, + Crop: Crop, + Delete: Trash2, + Paste: Copy, + "Paste in Place": Copy, + Fill: Paintbrush, + "Resize Canvas": Square, + "Resize Image": Square, + "Rotate Canvas 90": RotateCcw, + "Rotate Canvas 180": RotateCcw, + "Rotate Canvas 270": RotateCcw, + "Flip Horizontal": ArrowUp, + "Flip Vertical": ArrowDown, + "Trim Canvas": Scissors, + "Load Image": Square, + "Bring to Front": ArrowUp, + "Bring Forward": ArrowUp, + "Send Backward": ArrowDown, + "Send to Back": ArrowDown, +}; + +function getActionIcon(label: string): React.ComponentType<{ size?: number }> { + if (ACTION_ICON_MAP[label]) return ACTION_ICON_MAP[label]; + if (label.startsWith("Add ")) return Square; + if (label.includes("Layer")) return Layers; + if (label.includes("Adjust") || label.includes("Filter")) return Sliders; + return MousePointer2; +} + +interface HistoryEntry { + index: number; + label: string; +} + +export function HistoryPanel() { + const lastAction = useEditorStore((s) => s.lastAction); + const temporalStore = useEditorStore.temporal.getState(); + const pastStates = temporalStore.pastStates; + const futureStates = temporalStore.futureStates; + + // Force re-render when history changes by subscribing to history version + useEditorStore((s) => s._historyVersion); + + const undo = useCallback(() => { + useEditorStore.temporal.getState().undo(); + }, []); + + const redo = useCallback(() => { + useEditorStore.temporal.getState().redo(); + }, []); + + // Build the history list from past states + const entries = useMemo((): HistoryEntry[] => { + const temporal = useEditorStore.temporal.getState(); + const past = temporal.pastStates as Array<{ lastAction?: string }>; + const future = temporal.futureStates as Array<{ lastAction?: string }>; + + const result: HistoryEntry[] = []; + + // Future states (dimmed, above current in reverse order) + for (let i = future.length - 1; i >= 0; i--) { + result.push({ + index: -(i + 1), + label: (future[i] as { lastAction?: string })?.lastAction || "Unknown", + }); + } + + // Current state (highlighted) + result.push({ + index: 0, + label: lastAction, + }); + + // Past states (newest first, below current) + for (let i = past.length - 1; i >= 0; i--) { + result.push({ + index: past.length - i, + label: (past[i] as { lastAction?: string })?.lastAction || "Unknown", + }); + } + + return result; + // pastStates and futureStates are intentionally not reactive deps; + // we read them inside via getState(). lastAction triggers recalculation. + }, [lastAction]); + + const jumpToState = useCallback((entry: HistoryEntry) => { + const temporal = useEditorStore.temporal.getState(); + if (entry.index < 0) { + // Future state: redo N times + const steps = Math.abs(entry.index); + for (let i = 0; i < steps; i++) { + temporal.redo(); + } + } else if (entry.index > 0) { + // Past state: undo N times + for (let i = 0; i < entry.index; i++) { + temporal.undo(); + } + } + }, []); + + return ( +
+ {/* Undo/Redo toolbar */} +
+ + + {pastStates.length} / 50 +
+ + {/* History list */} +
+ {entries.map((entry) => { + const isCurrent = entry.index === 0; + const isFuture = entry.index < 0; + const Icon = getActionIcon(entry.label); + + return ( + + ); + })} + {entries.length === 0 && ( +
No history yet
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/navigator-panel.tsx b/apps/web/src/components/editor/panels/navigator-panel.tsx new file mode 100644 index 00000000..91b1876b --- /dev/null +++ b/apps/web/src/components/editor/panels/navigator-panel.tsx @@ -0,0 +1,280 @@ +// apps/web/src/components/editor/panels/navigator-panel.tsx + +import { Minus, Plus } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +const THUMBNAIL_MAX_HEIGHT = 80; +const THROTTLE_MS = 500; +const MIN_ZOOM = 0.01; +const MAX_ZOOM = 64; + +export function NavigatorPanel() { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const isDraggingRef = useRef(false); + const throttleTimerRef = useRef | null>(null); + + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + const canvasSize = useEditorStore((s) => s.canvasSize); + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + const setPanOffset = useEditorStore((s) => s.setPanOffset); + const setZoom = useEditorStore((s) => s.setZoom); + + // Track history version to know when to update the thumbnail + const historyVersion = useEditorStore((s) => s._historyVersion); + + const [thumbnailDims, setThumbnailDims] = useState({ width: 0, height: 0 }); + const [imageEl, setImageEl] = useState(null); + + // Load the source image for the thumbnail + useEffect(() => { + if (!sourceImageUrl) { + setImageEl(null); + return; + } + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => setImageEl(img); + img.src = sourceImageUrl; + }, [sourceImageUrl]); + + // Calculate thumbnail dimensions + useEffect(() => { + if (!canvasSize.width || !canvasSize.height) return; + const aspect = canvasSize.width / canvasSize.height; + const height = THUMBNAIL_MAX_HEIGHT; + const width = Math.round(height * aspect); + setThumbnailDims({ width, height }); + }, [canvasSize.width, canvasSize.height]); + + // Draw the thumbnail (throttled) + const drawThumbnail = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !imageEl) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + canvas.width = thumbnailDims.width; + canvas.height = thumbnailDims.height; + + // Draw checkerboard background for transparency + const checkSize = 4; + for (let y = 0; y < canvas.height; y += checkSize) { + for (let x = 0; x < canvas.width; x += checkSize) { + ctx.fillStyle = + (Math.floor(x / checkSize) + Math.floor(y / checkSize)) % 2 === 0 ? "#e0e0e0" : "#ffffff"; + ctx.fillRect(x, y, checkSize, checkSize); + } + } + + // Draw image scaled to thumbnail + ctx.drawImage(imageEl, 0, 0, thumbnailDims.width, thumbnailDims.height); + }, [imageEl, thumbnailDims.width, thumbnailDims.height]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: historyVersion triggers thumbnail redraw on canvas changes + useEffect(() => { + if (throttleTimerRef.current) { + clearTimeout(throttleTimerRef.current); + } + throttleTimerRef.current = setTimeout(() => { + drawThumbnail(); + throttleTimerRef.current = null; + }, THROTTLE_MS); + return () => { + if (throttleTimerRef.current) { + clearTimeout(throttleTimerRef.current); + } + }; + }, [drawThumbnail, historyVersion]); + + // Calculate the viewport rectangle on the thumbnail + // The viewport rectangle represents what is currently visible in the editor canvas + const getViewportRect = useCallback(() => { + if (thumbnailDims.width === 0 || canvasSize.width === 0) { + return { x: 0, y: 0, width: thumbnailDims.width, height: thumbnailDims.height }; + } + + const container = containerRef.current; + if (!container) { + return { x: 0, y: 0, width: thumbnailDims.width, height: thumbnailDims.height }; + } + + // Scale from canvas coordinates to thumbnail coordinates + const scaleX = thumbnailDims.width / canvasSize.width; + const scaleY = thumbnailDims.height / canvasSize.height; + + // The visible area in canvas coordinates + // panOffset is the stage position, zoom is the stage scale + // Visible canvas area: from (-panOffset/zoom) to ((-panOffset + viewportSize)/zoom) + const editorContainer = container.closest("[data-testid='editor-canvas']"); + const viewportWidth = editorContainer?.clientWidth || 800; + const viewportHeight = editorContainer?.clientHeight || 600; + + const visibleX = -panOffset.x / zoom; + const visibleY = -panOffset.y / zoom; + const visibleWidth = viewportWidth / zoom; + const visibleHeight = viewportHeight / zoom; + + return { + x: visibleX * scaleX, + y: visibleY * scaleY, + width: visibleWidth * scaleX, + height: visibleHeight * scaleY, + }; + }, [thumbnailDims, canvasSize, zoom, panOffset]); + + const viewportRect = getViewportRect(); + + // Handle click on minimap to jump to position + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (isDraggingRef.current) return; + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + // Convert thumbnail coords to canvas coords + const scaleX = canvasSize.width / thumbnailDims.width; + const scaleY = canvasSize.height / thumbnailDims.height; + const canvasX = clickX * scaleX; + const canvasY = clickY * scaleY; + + // Center the viewport on this position + const editorContainer = containerRef.current?.closest("[data-testid='editor-canvas']"); + const viewportWidth = editorContainer?.clientWidth || 800; + const viewportHeight = editorContainer?.clientHeight || 600; + + setPanOffset({ + x: -(canvasX * zoom) + viewportWidth / 2, + y: -(canvasY * zoom) + viewportHeight / 2, + }); + }, + [canvasSize, thumbnailDims, zoom, setPanOffset], + ); + + // Handle drag on viewport rectangle to pan + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingRef.current = true; + + const startX = e.clientX; + const startY = e.clientY; + const startPan = { ...panOffset }; + + const scaleX = canvasSize.width / thumbnailDims.width; + const scaleY = canvasSize.height / thumbnailDims.height; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + + setPanOffset({ + x: startPan.x - dx * scaleX * zoom, + y: startPan.y - dy * scaleY * zoom, + }); + }; + + const handleMouseUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [panOffset, canvasSize, thumbnailDims, zoom, setPanOffset], + ); + + const zoomPercent = Math.round(zoom * 100); + + const handleZoomSlider = useCallback( + (e: React.ChangeEvent) => { + const val = Number.parseFloat(e.target.value); + setZoom(val); + }, + [setZoom], + ); + + if (!sourceImageUrl) { + return ( +
+ No image loaded +
+ ); + } + + return ( +
+ + + {/* Zoom slider */} +
+ + + + + {zoomPercent}% + +
+
+ ); +} diff --git a/apps/web/src/hooks/use-canvas-zoom.ts b/apps/web/src/hooks/use-canvas-zoom.ts new file mode 100644 index 00000000..91ac3e79 --- /dev/null +++ b/apps/web/src/hooks/use-canvas-zoom.ts @@ -0,0 +1,108 @@ +// apps/web/src/hooks/use-canvas-zoom.ts + +import Konva from "konva"; +import { useCallback, useRef } from "react"; +import { useEditorStore } from "@/stores/editor-store"; + +const ZOOM_SENSITIVITY = 1.1; +const ZOOM_ANIMATION_DURATION = 0.15; + +export function useCanvasZoom() { + const stageRef = useRef(null); + const setZoom = useEditorStore((s) => s.setZoom); + const setPanOffset = useEditorStore((s) => s.setPanOffset); + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + const tweenRef = useRef(null); + + const animateZoom = useCallback( + (targetZoom: number, targetPos: { x: number; y: number }) => { + const stage = stageRef.current; + if (!stage) { + setZoom(targetZoom); + setPanOffset(targetPos); + return; + } + if (tweenRef.current) { + tweenRef.current.destroy(); + } + tweenRef.current = new Konva.Tween({ + node: stage, + scaleX: targetZoom, + scaleY: targetZoom, + x: targetPos.x, + y: targetPos.y, + duration: ZOOM_ANIMATION_DURATION, + easing: Konva.Easings.EaseOut, + onFinish: () => { + setZoom(targetZoom); + setPanOffset(targetPos); + tweenRef.current = null; + }, + }); + tweenRef.current.play(); + }, + [setZoom, setPanOffset], + ); + + const handleWheel = useCallback( + (e: Konva.KonvaEventObject) => { + e.evt.preventDefault(); + const stage = stageRef.current; + if (!stage) return; + + const isZoom = e.evt.ctrlKey || e.evt.metaKey; + + if (isZoom) { + const pointer = stage.getPointerPosition(); + if (!pointer) return; + + const oldZoom = zoom; + const direction = e.evt.deltaY < 0 ? 1 : -1; + const newZoom = direction > 0 ? oldZoom * ZOOM_SENSITIVITY : oldZoom / ZOOM_SENSITIVITY; + const clampedZoom = Math.max(0.01, Math.min(64, newZoom)); + + const mousePointTo = { + x: (pointer.x - panOffset.x) / oldZoom, + y: (pointer.y - panOffset.y) / oldZoom, + }; + + const newPos = { + x: pointer.x - mousePointTo.x * clampedZoom, + y: pointer.y - mousePointTo.y * clampedZoom, + }; + + animateZoom(clampedZoom, newPos); + } else { + const dx = e.evt.shiftKey ? -e.evt.deltaY : -e.evt.deltaX; + const dy = e.evt.shiftKey ? 0 : -e.evt.deltaY; + setPanOffset({ x: panOffset.x + dx, y: panOffset.y + dy }); + } + }, + [zoom, panOffset, setPanOffset, animateZoom], + ); + + const fitToScreen = useCallback( + (viewportWidth: number, viewportHeight: number, imageWidth: number, imageHeight: number) => { + const scaleX = viewportWidth / imageWidth; + const scaleY = viewportHeight / imageHeight; + const fitZoom = Math.min(scaleX, scaleY) * 0.9; + const offsetX = (viewportWidth - imageWidth * fitZoom) / 2; + const offsetY = (viewportHeight - imageHeight * fitZoom) / 2; + animateZoom(fitZoom, { x: offsetX, y: offsetY }); + }, + [animateZoom], + ); + + const zoomTo = useCallback( + (targetZoom: number, viewportWidth: number, viewportHeight: number) => { + const canvasSize = useEditorStore.getState().canvasSize; + const offsetX = (viewportWidth - canvasSize.width * targetZoom) / 2; + const offsetY = (viewportHeight - canvasSize.height * targetZoom) / 2; + animateZoom(targetZoom, { x: offsetX, y: offsetY }); + }, + [animateZoom], + ); + + return { stageRef, handleWheel, fitToScreen, zoomTo }; +} diff --git a/apps/web/src/hooks/use-editor-shortcuts.ts b/apps/web/src/hooks/use-editor-shortcuts.ts new file mode 100644 index 00000000..6b8904be --- /dev/null +++ b/apps/web/src/hooks/use-editor-shortcuts.ts @@ -0,0 +1,868 @@ +// apps/web/src/hooks/use-editor-shortcuts.ts + +import { useCallback, useEffect, useRef } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +/** + * Checks whether the currently focused element is a text input + * (input, textarea, select, or contentEditable) so tool‑shortcut + * single‑letter keys can be suppressed while the user is typing. + */ +function isInputFocused(): boolean { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName.toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") return true; + if ((el as HTMLElement).isContentEditable) return true; + return false; +} + +// Brush size step depends on current size for natural feel +function getBrushSizeStep(current: number): number { + if (current < 10) return 1; + if (current < 50) return 2; + if (current < 100) return 5; + return 10; +} + +// Marquee subtypes cycle +const MARQUEE_CYCLE: ToolType[] = ["marquee-rect", "marquee-ellipse"]; +// Lasso subtypes cycle +const LASSO_CYCLE: ToolType[] = ["lasso-free", "lasso-poly"]; +// Shape subtypes cycle +const SHAPE_CYCLE: ToolType[] = [ + "shape-rect", + "shape-ellipse", + "shape-line", + "shape-arrow", + "shape-polygon", + "shape-star", +]; +// Fill/gradient cycle +const FILL_CYCLE: ToolType[] = ["fill", "gradient"]; + +function cycleSubtool(current: ToolType, cycle: ToolType[]): ToolType { + const idx = cycle.indexOf(current); + if (idx === -1) return cycle[0]; + return cycle[(idx + 1) % cycle.length]; +} + +/** + * Registers all editor keyboard shortcuts. + * Must be called once inside the editor page component. + * + * @param callbacks Optional callbacks for save/export dialogs + */ +export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?: () => void }) { + const previousToolRef = useRef(null); + const isSpaceHeldRef = useRef(false); + + // ---- Tool shortcuts (single key, disabled when input focused) ---- + + // V - Move tool + useHotkeys( + "v", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("move"); + }, + { preventDefault: true }, + ); + + // M - Marquee selection (cycles rect/ellipse) + useHotkeys( + "m", + () => { + if (isInputFocused()) return; + const current = useEditorStore.getState().activeTool; + if (MARQUEE_CYCLE.includes(current)) { + useEditorStore.getState().setTool(cycleSubtool(current, MARQUEE_CYCLE)); + } else { + useEditorStore.getState().setTool("marquee-rect"); + } + }, + { preventDefault: true }, + ); + + // Shift+M - Cycle marquee subtypes + useHotkeys( + "shift+m", + () => { + if (isInputFocused()) return; + const current = useEditorStore.getState().activeTool; + useEditorStore.getState().setTool(cycleSubtool(current, MARQUEE_CYCLE)); + }, + { preventDefault: true }, + ); + + // L - Lasso tool (cycles freehand/polygonal) + useHotkeys( + "l", + () => { + if (isInputFocused()) return; + const current = useEditorStore.getState().activeTool; + if (LASSO_CYCLE.includes(current)) { + useEditorStore.getState().setTool(cycleSubtool(current, LASSO_CYCLE)); + } else { + useEditorStore.getState().setTool("lasso-free"); + } + }, + { preventDefault: true }, + ); + + // Shift+L - Cycle lasso subtypes + useHotkeys( + "shift+l", + () => { + if (isInputFocused()) return; + useEditorStore + .getState() + .setTool(cycleSubtool(useEditorStore.getState().activeTool, LASSO_CYCLE)); + }, + { preventDefault: true }, + ); + + // W - Magic wand + useHotkeys( + "w", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("magic-wand"); + }, + { preventDefault: true }, + ); + + // C - Crop tool + useHotkeys( + "c", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("crop"); + }, + { preventDefault: true }, + ); + + // I - Eyedropper + useHotkeys( + "i", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("eyedropper"); + }, + { preventDefault: true }, + ); + + // B - Brush tool + useHotkeys( + "b", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("brush"); + }, + { preventDefault: true }, + ); + + // E - Eraser tool + useHotkeys( + "e", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("eraser"); + }, + { preventDefault: true }, + ); + + // S - Clone stamp + useHotkeys( + "s", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("clone-stamp"); + }, + { preventDefault: true }, + ); + + // O - Dodge tool + useHotkeys( + "o", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("dodge"); + }, + { preventDefault: true }, + ); + + // G - Fill/Gradient (cycles) + useHotkeys( + "g", + () => { + if (isInputFocused()) return; + const current = useEditorStore.getState().activeTool; + if (FILL_CYCLE.includes(current)) { + useEditorStore.getState().setTool(cycleSubtool(current, FILL_CYCLE)); + } else { + useEditorStore.getState().setTool("fill"); + } + }, + { preventDefault: true }, + ); + + // Shift+G - Cycle fill/gradient subtypes + useHotkeys( + "shift+g", + () => { + if (isInputFocused()) return; + useEditorStore + .getState() + .setTool(cycleSubtool(useEditorStore.getState().activeTool, FILL_CYCLE)); + }, + { preventDefault: true }, + ); + + // U - Shape tool (cycles shapes) + useHotkeys( + "u", + () => { + if (isInputFocused()) return; + const current = useEditorStore.getState().activeTool; + if (SHAPE_CYCLE.includes(current)) { + useEditorStore.getState().setTool(cycleSubtool(current, SHAPE_CYCLE)); + } else { + useEditorStore.getState().setTool("shape-rect"); + } + }, + { preventDefault: true }, + ); + + // Shift+U - Cycle shape subtypes + useHotkeys( + "shift+u", + () => { + if (isInputFocused()) return; + useEditorStore + .getState() + .setTool(cycleSubtool(useEditorStore.getState().activeTool, SHAPE_CYCLE)); + }, + { preventDefault: true }, + ); + + // T - Text tool + useHotkeys( + "t", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("text"); + }, + { preventDefault: true }, + ); + + // H - Hand tool + useHotkeys( + "h", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("hand"); + }, + { preventDefault: true }, + ); + + // Z - Zoom tool + useHotkeys( + "z", + () => { + if (isInputFocused()) return; + useEditorStore.getState().setTool("zoom"); + }, + { preventDefault: true }, + ); + + // ---- Color shortcuts ---- + + // X - Swap foreground/background + useHotkeys( + "x", + () => { + if (isInputFocused()) return; + useEditorStore.getState().swapColors(); + }, + { preventDefault: true }, + ); + + // D - Reset colors to black/white + useHotkeys( + "d", + () => { + if (isInputFocused()) return; + useEditorStore.getState().resetColors(); + }, + { preventDefault: true }, + ); + + // [ - Decrease brush size + useHotkeys( + "[", + () => { + if (isInputFocused()) return; + const state = useEditorStore.getState(); + const step = getBrushSizeStep(state.brushSize); + state.setBrushSize(state.brushSize - step); + }, + { preventDefault: true }, + ); + + // ] - Increase brush size + useHotkeys( + "]", + () => { + if (isInputFocused()) return; + const state = useEditorStore.getState(); + const step = getBrushSizeStep(state.brushSize); + state.setBrushSize(state.brushSize + step); + }, + { preventDefault: true }, + ); + + // ---- Modifier shortcuts (always active, override browser defaults) ---- + + // Ctrl+Z / Cmd+Z - Undo + useHotkeys( + "mod+z", + (e) => { + e.preventDefault(); + useEditorStore.temporal.getState().undo(); + }, + { preventDefault: true }, + ); + + // Ctrl+Shift+Z / Cmd+Shift+Z - Redo + useHotkeys( + "mod+shift+z", + (e) => { + e.preventDefault(); + useEditorStore.temporal.getState().redo(); + }, + { preventDefault: true }, + ); + + // Ctrl+S / Cmd+S - Save project + useHotkeys( + "mod+s", + (e) => { + e.preventDefault(); + callbacks?.onSave?.(); + }, + { preventDefault: true }, + ); + + // Ctrl+Shift+S / Cmd+Shift+S - Export image + useHotkeys( + "mod+shift+s", + (e) => { + e.preventDefault(); + callbacks?.onExport?.(); + }, + { preventDefault: true }, + ); + + // Ctrl+A / Cmd+A - Select all + useHotkeys( + "mod+a", + (e) => { + e.preventDefault(); + const state = useEditorStore.getState(); + const allIds = state.objects.map((o) => o.id); + state.setSelectedObjects(allIds); + }, + { preventDefault: true }, + ); + + // Ctrl+D / Cmd+D - Deselect + useHotkeys( + "mod+d", + (e) => { + e.preventDefault(); + useEditorStore.getState().setSelectedObjects([]); + useEditorStore.getState().setSelection(null); + }, + { preventDefault: true }, + ); + + // Ctrl+C / Cmd+C - Copy + useHotkeys( + "mod+c", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + useEditorStore.getState().copyObjects(); + }, + { preventDefault: true }, + ); + + // Ctrl+X / Cmd+X - Cut + useHotkeys( + "mod+x", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + useEditorStore.getState().cutObjects(); + }, + { preventDefault: true }, + ); + + // Ctrl+V / Cmd+V - Paste + useHotkeys( + "mod+v", + (e) => { + if (isInputFocused()) return; + // Only paste internal clipboard; system paste is handled by paste event listener + const state = useEditorStore.getState(); + if (state.clipboard && state.clipboard.length > 0) { + e.preventDefault(); + state.pasteObjects(); + } + }, + { preventDefault: false }, + ); + + // Ctrl+Shift+C / Cmd+Shift+C - Copy merged + useHotkeys( + "mod+shift+c", + (e) => { + e.preventDefault(); + // Export visible layers to clipboard as PNG + const stageCanvas = document.querySelector( + "[data-testid='editor-canvas'] canvas", + ) as HTMLCanvasElement | null; + if (!stageCanvas) return; + stageCanvas.toBlob(async (blob) => { + if (!blob) return; + try { + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + } catch { + // Clipboard API not available + } + }, "image/png"); + }, + { preventDefault: true }, + ); + + // Ctrl+Shift+V / Cmd+Shift+V - Paste in place + useHotkeys( + "mod+shift+v", + (e) => { + e.preventDefault(); + useEditorStore.getState().pasteInPlace(); + }, + { preventDefault: true }, + ); + + // Ctrl+T / Cmd+T - Free transform + useHotkeys( + "mod+t", + (e) => { + e.preventDefault(); + useEditorStore.getState().setTool("transform"); + }, + { preventDefault: true }, + ); + + // Ctrl+J / Cmd+J - Duplicate layer + useHotkeys( + "mod+j", + (e) => { + e.preventDefault(); + const state = useEditorStore.getState(); + state.duplicateLayer(state.activeLayerId); + }, + { preventDefault: true }, + ); + + // Ctrl+Shift+N / Cmd+Shift+N - New layer + useHotkeys( + "mod+shift+n", + (e) => { + e.preventDefault(); + useEditorStore.getState().addLayer(); + }, + { preventDefault: true }, + ); + + // Delete / Backspace - Delete selected objects + useHotkeys( + "delete,backspace", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + const state = useEditorStore.getState(); + if (state.selectedObjectIds.length > 0) { + state.removeObjects(state.selectedObjectIds); + } + }, + { preventDefault: true }, + ); + + // Ctrl+0 / Cmd+0 - Fit to screen + useHotkeys( + "mod+0", + (e) => { + e.preventDefault(); + const editorCanvas = document.querySelector("[data-testid='editor-canvas']"); + if (!editorCanvas) return; + const { width: vw, height: vh } = editorCanvas.getBoundingClientRect(); + const { canvasSize } = useEditorStore.getState(); + const scaleX = vw / canvasSize.width; + const scaleY = vh / canvasSize.height; + const fitZoom = Math.min(scaleX, scaleY) * 0.9; + const offsetX = (vw - canvasSize.width * fitZoom) / 2; + const offsetY = (vh - canvasSize.height * fitZoom) / 2; + useEditorStore.getState().setZoom(fitZoom); + useEditorStore.getState().setPanOffset({ x: offsetX, y: offsetY }); + }, + { preventDefault: true }, + ); + + // Ctrl+1 / Cmd+1 - Zoom to 100% + useHotkeys( + "mod+1", + (e) => { + e.preventDefault(); + const editorCanvas = document.querySelector("[data-testid='editor-canvas']"); + if (!editorCanvas) return; + const { width: vw, height: vh } = editorCanvas.getBoundingClientRect(); + const { canvasSize } = useEditorStore.getState(); + const offsetX = (vw - canvasSize.width) / 2; + const offsetY = (vh - canvasSize.height) / 2; + useEditorStore.getState().setZoom(1); + useEditorStore.getState().setPanOffset({ x: offsetX, y: offsetY }); + }, + { preventDefault: true }, + ); + + // Ctrl++ / Cmd++ - Zoom in + useHotkeys( + "mod+=,mod+plus", + (e) => { + e.preventDefault(); + const state = useEditorStore.getState(); + state.setZoom(state.zoom * 1.25); + }, + { preventDefault: true }, + ); + + // Ctrl+- / Cmd+- - Zoom out + useHotkeys( + "mod+-,mod+minus", + (e) => { + e.preventDefault(); + const state = useEditorStore.getState(); + state.setZoom(state.zoom / 1.25); + }, + { preventDefault: true }, + ); + + // Tab - Toggle right panel + useHotkeys( + "tab", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + useEditorStore.getState().toggleRightPanel(); + }, + { preventDefault: true }, + ); + + // Arrow keys - Nudge selected 1px + useHotkeys( + "left", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + nudgeSelected(-1, 0); + }, + { preventDefault: true }, + ); + + useHotkeys( + "right", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + nudgeSelected(1, 0); + }, + { preventDefault: true }, + ); + + useHotkeys( + "up", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + nudgeSelected(0, -1); + }, + { preventDefault: true }, + ); + + useHotkeys( + "down", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + nudgeSelected(0, 1); + }, + { preventDefault: true }, + ); + + // Shift+Arrow - Nudge 10px + useHotkeys( + "shift+left", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + nudgeSelected(-10, 0); + }, + { preventDefault: true }, + ); + + useHotkeys( + "shift+right", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + nudgeSelected(10, 0); + }, + { preventDefault: true }, + ); + + useHotkeys( + "shift+up", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + nudgeSelected(0, -10); + }, + { preventDefault: true }, + ); + + useHotkeys( + "shift+down", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + nudgeSelected(0, 10); + }, + { preventDefault: true }, + ); + + // Enter - Apply current operation (crop, transform) + useHotkeys( + "enter", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + const state = useEditorStore.getState(); + if (state.isCropping && state.cropState) { + state.applyCrop(); + } + }, + { preventDefault: true }, + ); + + // Escape - Cancel current operation + useHotkeys( + "escape", + (e) => { + e.preventDefault(); + const state = useEditorStore.getState(); + if (state.isCropping) { + state.setCropState(null); + } + state.setSelectedObjects([]); + state.setSelection(null); + }, + { preventDefault: true }, + ); + + // Ctrl+R / Cmd+R - Toggle rulers (override browser refresh) + useHotkeys( + "mod+r", + (e) => { + e.preventDefault(); + useEditorStore.getState().toggleRulers(); + }, + { preventDefault: true }, + ); + + // Ctrl+; / Cmd+; - Toggle guides + useHotkeys( + "mod+;", + (e) => { + e.preventDefault(); + useEditorStore.getState().toggleGuides(); + }, + { preventDefault: true }, + ); + + // Ctrl+' / Cmd+' - Toggle grid + useHotkeys( + "mod+'", + (e) => { + e.preventDefault(); + useEditorStore.getState().toggleGrid(); + }, + { preventDefault: true }, + ); + + // Ctrl+Shift+I / Cmd+Shift+I - Inverse selection + useHotkeys( + "mod+shift+i", + (e) => { + e.preventDefault(); + useEditorStore.getState().invertSelection(); + }, + { preventDefault: true }, + ); + + // Ctrl+E / Cmd+E - Merge down + useHotkeys( + "mod+e", + (e) => { + e.preventDefault(); + const state = useEditorStore.getState(); + state.mergeDown(state.activeLayerId); + }, + { preventDefault: true }, + ); + + // Ctrl+Shift+E / Cmd+Shift+E - Flatten all + useHotkeys( + "mod+shift+e", + (e) => { + e.preventDefault(); + useEditorStore.getState().flattenAll(); + }, + { preventDefault: true }, + ); + + // Ctrl+Shift+] / Cmd+Shift+] - Bring to front + useHotkeys( + "mod+shift+]", + (e) => { + e.preventDefault(); + const state = useEditorStore.getState(); + if (state.selectedObjectIds.length === 1) { + state.bringToFront(state.selectedObjectIds[0]); + } + }, + { preventDefault: true }, + ); + + // Ctrl+Shift+[ / Cmd+Shift+[ - Send to back + useHotkeys( + "mod+shift+[", + (e) => { + e.preventDefault(); + const state = useEditorStore.getState(); + if (state.selectedObjectIds.length === 1) { + state.sendToBack(state.selectedObjectIds[0]); + } + }, + { preventDefault: true }, + ); + + // Ctrl+] / Cmd+] - Bring forward + useHotkeys( + "mod+]", + (e) => { + e.preventDefault(); + const state = useEditorStore.getState(); + if (state.selectedObjectIds.length === 1) { + state.bringForward(state.selectedObjectIds[0]); + } + }, + { preventDefault: true }, + ); + + // Ctrl+[ / Cmd+[ - Send backward + useHotkeys( + "mod+[", + (e) => { + e.preventDefault(); + const state = useEditorStore.getState(); + if (state.selectedObjectIds.length === 1) { + state.sendBackward(state.selectedObjectIds[0]); + } + }, + { preventDefault: true }, + ); + + // Shift+Backspace - Fill dialog (trigger callback or use fill tool) + useHotkeys( + "shift+backspace", + (e) => { + if (isInputFocused()) return; + e.preventDefault(); + // Fill dialog would be handled by Agent 1's fill-dialog component + // For now, switch to fill tool as a fallback + useEditorStore.getState().setTool("fill"); + }, + { preventDefault: true }, + ); + + // ---- Space key: temporary hand tool ---- + + const handleSpaceDown = useCallback((e: KeyboardEvent) => { + if (isInputFocused()) return; + if (e.code !== "Space") return; + if (e.repeat) return; + e.preventDefault(); + + isSpaceHeldRef.current = true; + const state = useEditorStore.getState(); + if (state.activeTool !== "hand") { + previousToolRef.current = state.activeTool; + useEditorStore.setState({ isSpaceHeld: true }); + state.setTool("hand"); + } + }, []); + + const handleSpaceUp = useCallback((e: KeyboardEvent) => { + if (e.code !== "Space") return; + e.preventDefault(); + + if (isSpaceHeldRef.current) { + isSpaceHeldRef.current = false; + useEditorStore.setState({ isSpaceHeld: false }); + if (previousToolRef.current) { + useEditorStore.getState().setTool(previousToolRef.current); + previousToolRef.current = null; + } + } + }, []); + + useEffect(() => { + window.addEventListener("keydown", handleSpaceDown); + window.addEventListener("keyup", handleSpaceUp); + return () => { + window.removeEventListener("keydown", handleSpaceDown); + window.removeEventListener("keyup", handleSpaceUp); + }; + }, [handleSpaceDown, handleSpaceUp]); +} + +/** Nudge all selected objects by (dx, dy) pixels. */ +function nudgeSelected(dx: number, dy: number): void { + const state = useEditorStore.getState(); + for (const id of state.selectedObjectIds) { + const obj = state.objects.find((o) => o.id === id); + if (!obj) continue; + const attrs = obj.attrs; + if ("x" in attrs && "y" in attrs) { + state.updateObject(id, { + x: (attrs as { x: number }).x + dx, + y: (attrs as { y: number }).y + dy, + }); + } + } +} diff --git a/apps/web/src/pages/editor-page.tsx b/apps/web/src/pages/editor-page.tsx new file mode 100644 index 00000000..afeb9534 --- /dev/null +++ b/apps/web/src/pages/editor-page.tsx @@ -0,0 +1,76 @@ +// apps/web/src/pages/editor-page.tsx +import { useCallback, useEffect } from "react"; +import { WelcomeScreen } from "@/components/editor/common/welcome-screen"; +import { EditorCanvas } from "@/components/editor/editor-canvas"; +import { EditorOptionsBar } from "@/components/editor/editor-options-bar"; +import { EditorRightPanel } from "@/components/editor/editor-right-panel"; +import { EditorStatusBar } from "@/components/editor/editor-status-bar"; +import { EditorToolbar } from "@/components/editor/editor-toolbar"; +import { useEditorStore } from "@/stores/editor-store"; + +export function EditorPage() { + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + const isDirty = useEditorStore((s) => s.isDirty); + const loadImage = useEditorStore((s) => s.loadImage); + + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (isDirty) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + + const handlePaste = useCallback( + (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith("image/")) { + e.preventDefault(); + const blob = item.getAsFile(); + if (!blob) return; + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight); + img.src = url; + return; + } + } + }, + [loadImage], + ); + + useEffect(() => { + document.addEventListener("paste", handlePaste); + return () => document.removeEventListener("paste", handlePaste); + }, [handlePaste]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const url = params.get("url"); + if (url) { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight); + img.src = url; + } + }, [loadImage]); + + return ( +
+ +
+ +
+ + {!sourceImageUrl && } +
+ +
+ +
+ ); +} diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts new file mode 100644 index 00000000..20d786d1 --- /dev/null +++ b/apps/web/src/stores/editor-store.ts @@ -0,0 +1,724 @@ +// apps/web/src/stores/editor-store.ts + +import { temporal } from "zundo"; +import { create } from "zustand"; +import { generateId } from "@/lib/utils"; +import type { + AdjustmentValues, + CanvasObject, + EditorLayer, + EditorState, + FilterConfig, + ToolType, +} from "@/types/editor"; + +const DEFAULT_CANVAS_SIZE = { width: 1920, height: 1080 }; +const DEFAULT_LAYER_ID = "layer-1"; +const MAX_RECENT_COLORS = 12; +const MIN_ZOOM = 0.01; +const MAX_ZOOM = 64; +const MAX_BRUSH_SIZE = 500; +const MAX_HISTORY = 50; + +const DEFAULT_ADJUSTMENTS: AdjustmentValues = { + brightness: 0, + contrast: 0, + hue: 0, + saturation: 0, + luminance: 0, + exposure: 0, + vibrance: 0, + warmth: 0, +}; + +const DEFAULT_FILTERS: FilterConfig[] = [ + { type: "blur", enabled: false, params: { radius: 0 } }, + { type: "sharpen", enabled: false, params: { amount: 0 } }, + { type: "noise", enabled: false, params: { amount: 0 } }, + { type: "pixelate", enabled: false, params: { size: 1 } }, + { type: "emboss", enabled: false, params: { strength: 0 } }, + { type: "grayscale", enabled: false, params: {} }, + { type: "sepia", enabled: false, params: {} }, + { type: "invert", enabled: false, params: {} }, + { type: "posterize", enabled: false, params: { levels: 8 } }, + { type: "solarize", enabled: false, params: {} }, + { type: "threshold", enabled: false, params: { level: 0.5 } }, + { type: "kaleidoscope", enabled: false, params: { power: 2, angle: 0 } }, + { type: "motionBlur", enabled: false, params: { angle: 0, distance: 10 } }, + { + type: "radialBlur", + enabled: false, + params: { amount: 10, centerX: 0.5, centerY: 0.5 }, + }, + { + type: "surfaceBlur", + enabled: false, + params: { radius: 5, threshold: 25 }, + }, + { + type: "vignette", + enabled: false, + params: { amount: 50, midpoint: 50, roundness: 0, feather: 50 }, + }, + { + type: "grain", + enabled: false, + params: { amount: 25, size: 25, roughness: 50 }, + }, +]; + +function createDefaultLayer(id: string, name: string): EditorLayer { + return { + id, + name, + visible: true, + locked: false, + opacity: 1, + blendMode: "source-over", + thumbnail: null, + }; +} + +let layerCounter = 1; + +export const useEditorStore = create()( + temporal( + (set, get) => ({ + // --- Canvas --- + canvasSize: DEFAULT_CANVAS_SIZE, + zoom: 1, + panOffset: { x: 0, y: 0 }, + cursorPosition: { x: 0, y: 0 }, + + // --- Image --- + sourceImageUrl: null, + sourceImageSize: null, + + // --- Tool --- + activeTool: "move" as ToolType, + previousTool: null, + + // --- Brush --- + brushSize: 10, + brushOpacity: 1, + brushHardness: 1, + + // --- Colors --- + foregroundColor: "#000000", + backgroundColor: "#ffffff", + recentColors: [], + + // --- Layers --- + layers: [createDefaultLayer(DEFAULT_LAYER_ID, "Layer 1")], + activeLayerId: DEFAULT_LAYER_ID, + + // --- Objects --- + objects: [], + selectedObjectIds: [], + + // --- Selection --- + selection: null, + + // --- Crop --- + cropState: null, + isCropping: false, + + // --- Adjustments --- + adjustments: { ...DEFAULT_ADJUSTMENTS }, + filters: DEFAULT_FILTERS.map((f) => ({ + ...f, + params: { ...f.params }, + })), + + // --- Text --- + editingTextId: null, + + // --- Shape settings --- + shapeFill: "#3b82f6", + shapeStroke: "#000000", + shapeStrokeWidth: 2, + shapeCornerRadius: 0, + shapePolygonSides: 6, + shapeStarPoints: 5, + + // --- Clone stamp --- + cloneSource: null, + + // --- Dodge/Burn/Sponge --- + dodgeBurnRange: "midtones", + dodgeBurnExposure: 50, + spongeMode: "saturate", + spongeFlow: 50, + + // --- UI --- + rightPanelTab: "layers", + rightPanelVisible: true, + isSpaceHeld: false, + + // --- Document --- + isDirty: false, + lastAutoSave: null, + + // --- Clipboard --- + clipboard: null, + + // --- Guides --- + guides: [], + snappingEnabled: true, + rulersVisible: false, + guidesVisible: true, + gridVisible: false, + + // --- Loading --- + loadingState: null, + + // --- History --- + lastAction: "Initial State", + _historyVersion: 0, + + // ===== ACTIONS ===== + + setTool: (tool) => { + const { activeTool } = get(); + set({ + activeTool: tool, + previousTool: activeTool, + isCropping: tool === "crop", + }); + }, + + setCursorPosition: (pos) => set({ cursorPosition: pos }), + + setZoom: (zoom) => set({ zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)) }), + + setPanOffset: (offset) => set({ panOffset: offset }), + + loadImage: (url, width, height) => { + set({ + sourceImageUrl: url, + sourceImageSize: { width, height }, + canvasSize: { width, height }, + zoom: 1, + panOffset: { x: 0, y: 0 }, + lastAction: "Load Image", + _historyVersion: get()._historyVersion + 1, + }); + }, + + resizeCanvas: (width, height, _anchor) => { + set({ + canvasSize: { width, height }, + isDirty: true, + lastAction: "Resize Canvas", + _historyVersion: get()._historyVersion + 1, + }); + }, + + resizeImage: (width, height) => { + set({ + canvasSize: { width, height }, + sourceImageSize: { width, height }, + isDirty: true, + lastAction: "Resize Image", + _historyVersion: get()._historyVersion + 1, + }); + }, + + rotateCanvas: (degrees) => { + const { canvasSize, objects } = get(); + const newSize = + degrees === 180 ? canvasSize : { width: canvasSize.height, height: canvasSize.width }; + set({ + canvasSize: newSize, + sourceImageSize: newSize, + objects: objects.map((obj) => { + const attrs = { ...obj.attrs }; + if ("x" in attrs && "y" in attrs) { + 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; + } 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; + } 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; + } + } + return { ...obj, attrs } as CanvasObject; + }), + isDirty: true, + lastAction: `Rotate Canvas ${degrees}`, + _historyVersion: get()._historyVersion + 1, + }); + }, + + flipCanvasHorizontal: () => { + const { canvasSize, objects } = get(); + set({ + objects: objects.map((obj) => { + const attrs = { ...obj.attrs }; + if ("x" in attrs) { + (attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x; + } + return { ...obj, attrs } as CanvasObject; + }), + isDirty: true, + lastAction: "Flip Horizontal", + _historyVersion: get()._historyVersion + 1, + }); + }, + + flipCanvasVertical: () => { + const { canvasSize, objects } = get(); + set({ + objects: objects.map((obj) => { + const attrs = { ...obj.attrs }; + if ("y" in attrs) { + (attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y; + } + return { ...obj, attrs } as CanvasObject; + }), + isDirty: true, + lastAction: "Flip Vertical", + _historyVersion: get()._historyVersion + 1, + }); + }, + + trimCanvas: () => { + set({ + isDirty: true, + lastAction: "Trim Canvas", + _historyVersion: get()._historyVersion + 1, + }); + }, + + // Colors + setForegroundColor: (color) => { + const { recentColors } = get(); + const updated = [color, ...recentColors.filter((c) => c !== color)].slice( + 0, + MAX_RECENT_COLORS, + ); + set({ foregroundColor: color, recentColors: updated }); + }, + + setBackgroundColor: (color) => set({ backgroundColor: color }), + + swapColors: () => { + const { foregroundColor, backgroundColor } = get(); + set({ + foregroundColor: backgroundColor, + backgroundColor: foregroundColor, + }); + }, + + resetColors: () => set({ foregroundColor: "#000000", backgroundColor: "#ffffff" }), + + // Objects + addObject: (obj) => { + set({ + objects: [...get().objects, { ...obj, layerId: obj.layerId || get().activeLayerId }], + isDirty: true, + lastAction: `Add ${obj.type.charAt(0).toUpperCase() + obj.type.slice(1)}`, + _historyVersion: get()._historyVersion + 1, + }); + }, + + updateObject: (id, attrs) => { + set({ + objects: get().objects.map((obj) => + obj.id === id ? ({ ...obj, attrs: { ...obj.attrs, ...attrs } } as CanvasObject) : obj, + ), + isDirty: true, + _historyVersion: get()._historyVersion + 1, + }); + }, + + removeObjects: (ids) => { + const idSet = new Set(ids); + set({ + objects: get().objects.filter((obj) => !idSet.has(obj.id)), + selectedObjectIds: get().selectedObjectIds.filter((id) => !idSet.has(id)), + isDirty: true, + lastAction: "Delete", + _historyVersion: get()._historyVersion + 1, + }); + }, + + setSelectedObjects: (ids) => set({ selectedObjectIds: ids }), + + bringToFront: (objectId) => { + 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]; + set({ + objects: [...otherObjects, ...reordered], + lastAction: "Bring to Front", + _historyVersion: get()._historyVersion + 1, + }); + }, + + bringForward: (objectId) => { + const { objects } = get(); + const idx = objects.findIndex((o) => o.id === objectId); + if (idx === -1 || idx === objects.length - 1) return; + const newObjects = [...objects]; + [newObjects[idx], newObjects[idx + 1]] = [newObjects[idx + 1], newObjects[idx]]; + set({ + objects: newObjects, + lastAction: "Bring Forward", + _historyVersion: get()._historyVersion + 1, + }); + }, + + sendBackward: (objectId) => { + const { objects } = get(); + const idx = objects.findIndex((o) => o.id === objectId); + if (idx <= 0) return; + const newObjects = [...objects]; + [newObjects[idx - 1], newObjects[idx]] = [newObjects[idx], newObjects[idx - 1]]; + set({ + objects: newObjects, + lastAction: "Send Backward", + _historyVersion: get()._historyVersion + 1, + }); + }, + + sendToBack: (objectId) => { + 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)]; + set({ + objects: [...reordered, ...otherObjects], + lastAction: "Send to Back", + _historyVersion: get()._historyVersion + 1, + }); + }, + + // Layers + addLayer: () => { + layerCounter++; + const id = generateId(); + const name = `Layer ${layerCounter}`; + const newLayer = createDefaultLayer(id, name); + const { layers, activeLayerId } = get(); + const activeIndex = layers.findIndex((l) => l.id === activeLayerId); + const newLayers = [...layers]; + newLayers.splice(activeIndex + 1, 0, newLayer); + set({ + layers: newLayers, + activeLayerId: id, + isDirty: true, + lastAction: "Add Layer", + _historyVersion: get()._historyVersion + 1, + }); + }, + + removeLayer: (id) => { + const { layers, objects, activeLayerId } = get(); + if (layers.length <= 1) return; + const idx = layers.findIndex((l) => l.id === id); + const newLayers = layers.filter((l) => l.id !== id); + const newActiveId = + activeLayerId === id ? newLayers[Math.min(idx, newLayers.length - 1)].id : activeLayerId; + set({ + layers: newLayers, + objects: objects.filter((o) => o.layerId !== id), + activeLayerId: newActiveId, + isDirty: true, + lastAction: "Delete Layer", + _historyVersion: get()._historyVersion + 1, + }); + }, + + duplicateLayer: (id) => { + const { layers, objects } = get(); + const source = layers.find((l) => l.id === id); + if (!source) return; + const newId = generateId(); + layerCounter++; + const copy: EditorLayer = { + ...source, + id: newId, + name: `${source.name} (copy)`, + thumbnail: null, + }; + const sourceObjects = objects + .filter((o) => o.layerId === id) + .map((o) => ({ ...o, id: generateId(), layerId: newId }) as CanvasObject); + const idx = layers.findIndex((l) => l.id === id); + const newLayers = [...layers]; + newLayers.splice(idx + 1, 0, copy); + set({ + layers: newLayers, + objects: [...objects, ...sourceObjects], + activeLayerId: newId, + isDirty: true, + lastAction: "Duplicate Layer", + _historyVersion: get()._historyVersion + 1, + }); + }, + + setActiveLayer: (id) => set({ activeLayerId: id }), + + updateLayer: (id, updates) => { + set({ + layers: get().layers.map((l) => (l.id === id ? { ...l, ...updates } : l)), + isDirty: true, + _historyVersion: get()._historyVersion + 1, + }); + }, + + reorderLayers: (fromIndex, toIndex) => { + const newLayers = [...get().layers]; + const [moved] = newLayers.splice(fromIndex, 1); + newLayers.splice(toIndex, 0, moved); + set({ + layers: newLayers, + isDirty: true, + lastAction: "Reorder Layers", + _historyVersion: get()._historyVersion + 1, + }); + }, + + mergeDown: (id) => { + const { layers, objects } = get(); + const idx = layers.findIndex((l) => l.id === id); + if (idx <= 0) return; + const belowLayer = layers[idx - 1]; + const mergedObjects = objects.map((o) => + o.layerId === id ? ({ ...o, layerId: belowLayer.id } as CanvasObject) : o, + ); + set({ + layers: layers.filter((l) => l.id !== id), + objects: mergedObjects, + activeLayerId: belowLayer.id, + isDirty: true, + lastAction: "Merge Down", + _historyVersion: get()._historyVersion + 1, + }); + }, + + flattenAll: () => { + const { layers, objects } = get(); + const bottomLayer = layers[0]; + set({ + layers: [{ ...bottomLayer, name: "Flattened" }], + objects: objects.map((o) => ({ ...o, layerId: bottomLayer.id }) as CanvasObject), + activeLayerId: bottomLayer.id, + isDirty: true, + lastAction: "Flatten All", + _historyVersion: get()._historyVersion + 1, + }); + }, + + // Adjustments + setAdjustment: (key, value) => { + const clamps: Record = { + brightness: [-100, 100], + contrast: [-100, 100], + hue: [0, 359], + saturation: [-100, 100], + luminance: [-100, 100], + exposure: [-100, 100], + vibrance: [-100, 100], + warmth: [-100, 100], + }; + const [min, max] = clamps[key] || [-100, 100]; + set({ + adjustments: { + ...get().adjustments, + [key]: Math.max(min, Math.min(max, value)), + }, + }); + }, + + resetAdjustments: () => set({ adjustments: { ...DEFAULT_ADJUSTMENTS } }), + + toggleFilter: (type) => { + set({ + filters: get().filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)), + }); + }, + + setFilterParam: (type, key, value) => { + set({ + filters: get().filters.map((f) => + f.type === type ? { ...f, params: { ...f.params, [key]: value } } : f, + ), + }); + }, + + // Selection + setSelection: (selection) => set({ selection }), + + invertSelection: () => { + const { selection } = get(); + if (!selection) return; + if (selection.mask) { + const inverted = new Uint8Array(selection.mask.length); + for (let i = 0; i < selection.mask.length; i++) { + inverted[i] = 255 - selection.mask[i]; + } + set({ selection: { ...selection, mask: inverted } }); + } + }, + + // Crop + setCropState: (state) => set({ cropState: state, isCropping: state !== null }), + + applyCrop: () => { + const { cropState, objects } = get(); + if (!cropState) return; + set({ + canvasSize: { width: cropState.width, height: cropState.height }, + objects: objects.map((obj) => { + const attrs = { ...obj.attrs }; + if ("x" in attrs) { + (attrs as { x: number }).x -= cropState.x; + } + if ("y" in attrs) { + (attrs as { y: number }).y -= cropState.y; + } + return { ...obj, attrs } as CanvasObject; + }), + cropState: null, + isCropping: false, + isDirty: true, + lastAction: "Crop", + _historyVersion: get()._historyVersion + 1, + }); + }, + + // Brush + setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(MAX_BRUSH_SIZE, size)) }), + setBrushOpacity: (opacity) => set({ brushOpacity: Math.max(0, Math.min(1, opacity)) }), + setBrushHardness: (hardness) => set({ brushHardness: Math.max(0, Math.min(1, hardness)) }), + + // Clipboard + copyObjects: () => { + const { objects, selectedObjectIds } = get(); + const selected = objects.filter((o) => selectedObjectIds.includes(o.id)); + set({ clipboard: selected }); + }, + + cutObjects: () => { + const { objects, selectedObjectIds } = get(); + const selected = objects.filter((o) => selectedObjectIds.includes(o.id)); + set({ clipboard: selected }); + get().removeObjects(selectedObjectIds); + }, + + pasteObjects: () => { + const { clipboard, activeLayerId } = get(); + if (!clipboard || clipboard.length === 0) return; + const pasted = clipboard.map( + (obj) => + ({ + ...obj, + id: generateId(), + layerId: activeLayerId, + attrs: { + ...obj.attrs, + ...("x" in obj.attrs ? { x: (obj.attrs as { x: number }).x + 10 } : {}), + ...("y" in obj.attrs ? { y: (obj.attrs as { y: number }).y + 10 } : {}), + }, + }) as CanvasObject, + ); + set({ + objects: [...get().objects, ...pasted], + selectedObjectIds: pasted.map((o) => o.id), + isDirty: true, + lastAction: "Paste", + _historyVersion: get()._historyVersion + 1, + }); + }, + + pasteInPlace: () => { + const { clipboard, activeLayerId } = get(); + if (!clipboard || clipboard.length === 0) return; + const pasted = clipboard.map( + (obj) => + ({ + ...obj, + id: generateId(), + layerId: activeLayerId, + }) as CanvasObject, + ); + set({ + objects: [...get().objects, ...pasted], + selectedObjectIds: pasted.map((o) => o.id), + isDirty: true, + lastAction: "Paste in Place", + _historyVersion: get()._historyVersion + 1, + }); + }, + + // Guides + addGuide: (orientation, position) => { + set({ + guides: [...get().guides, { id: generateId(), orientation, position }], + }); + }, + + removeGuide: (id) => { + set({ guides: get().guides.filter((g) => g.id !== id) }); + }, + + updateGuide: (id, position) => { + set({ + guides: get().guides.map((g) => (g.id === id ? { ...g, position } : g)), + }); + }, + + toggleSnapping: () => set({ snappingEnabled: !get().snappingEnabled }), + toggleRulers: () => set({ rulersVisible: !get().rulersVisible }), + toggleGuides: () => set({ guidesVisible: !get().guidesVisible }), + toggleGrid: () => set({ gridVisible: !get().gridVisible }), + + // Document + markDirty: () => set({ isDirty: true }), + markClean: () => set({ isDirty: false }), + setLoadingState: (state) => set({ loadingState: state }), + + // Right panel + setRightPanelTab: (tab) => set({ rightPanelTab: tab }), + toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }), + }), + { + partialize: (state) => ({ + layers: state.layers, + objects: state.objects, + canvasSize: state.canvasSize, + adjustments: state.adjustments, + filters: state.filters, + guides: state.guides, + sourceImageUrl: state.sourceImageUrl, + sourceImageSize: state.sourceImageSize, + _historyVersion: state._historyVersion, + }), + limit: MAX_HISTORY, + equality: (a, b) => + (a as { _historyVersion: number })._historyVersion === + (b as { _historyVersion: number })._historyVersion, + handleSet: (handleSet) => { + let timeout: ReturnType; + return (state) => { + clearTimeout(timeout); + timeout = setTimeout(() => handleSet(state), 500); + }; + }, + }, + ), +); diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts new file mode 100644 index 00000000..4515f7e2 --- /dev/null +++ b/apps/web/src/types/editor.ts @@ -0,0 +1,430 @@ +// apps/web/src/types/editor.ts + +export type ToolType = + | "move" + | "marquee-rect" + | "marquee-ellipse" + | "lasso-free" + | "lasso-poly" + | "magic-wand" + | "crop" + | "eyedropper" + | "brush" + | "eraser" + | "pencil" + | "clone-stamp" + | "dodge" + | "burn" + | "sponge" + | "blur-brush" + | "sharpen-brush" + | "smudge" + | "fill" + | "gradient" + | "shape-rect" + | "shape-ellipse" + | "shape-line" + | "shape-arrow" + | "shape-polygon" + | "shape-star" + | "text" + | "hand" + | "zoom" + | "transform"; + +export interface LineAttrs { + points: number[]; + stroke: string; + strokeWidth: number; + tension: number; + lineCap: "butt" | "round" | "square"; + lineJoin: "bevel" | "round" | "miter"; + opacity: number; + globalCompositeOperation: string; + shadowBlur?: number; + shadowColor?: string; + shadowOffsetX?: number; + shadowOffsetY?: number; +} + +export interface RectAttrs { + x: number; + y: number; + width: number; + height: number; + fill: string; + stroke: string; + strokeWidth: number; + cornerRadius: number; + rotation: number; + opacity: number; +} + +export interface EllipseAttrs { + x: number; + y: number; + radiusX: number; + radiusY: number; + fill: string; + stroke: string; + strokeWidth: number; + rotation: number; + opacity: number; +} + +export interface TextAttrs { + x: number; + y: number; + text: string; + fontFamily: string; + fontSize: number; + fontStyle: string; + fontVariant: string; + textDecoration: string; + align: "left" | "center" | "right"; + fill: string; + lineHeight: number; + letterSpacing: number; + width?: number; + height?: number; + wrap?: "word" | "char" | "none"; + rotation: number; + opacity: number; +} + +export interface ImageAttrs { + x: number; + y: number; + width: number; + height: number; + rotation: number; + opacity: number; + src: string; +} + +export interface ArrowAttrs { + points: number[]; + fill: string; + stroke: string; + strokeWidth: number; + pointerLength: number; + pointerWidth: number; + rotation: number; + opacity: number; +} + +export interface PolygonAttrs { + x: number; + y: number; + sides: number; + radius: number; + fill: string; + stroke: string; + strokeWidth: number; + rotation: number; + opacity: number; +} + +export interface StarAttrs { + x: number; + y: number; + numPoints: number; + innerRadius: number; + outerRadius: number; + fill: string; + stroke: string; + strokeWidth: number; + rotation: number; + opacity: number; +} + +export type CanvasObject = + | { id: string; type: "line"; layerId: string; attrs: LineAttrs; effects?: ObjectEffects } + | { id: string; type: "rect"; layerId: string; attrs: RectAttrs; effects?: ObjectEffects } + | { id: string; type: "ellipse"; layerId: string; attrs: EllipseAttrs; effects?: ObjectEffects } + | { id: string; type: "text"; layerId: string; attrs: TextAttrs; effects?: ObjectEffects } + | { id: string; type: "image"; layerId: string; attrs: ImageAttrs; effects?: ObjectEffects } + | { id: string; type: "arrow"; layerId: string; attrs: ArrowAttrs; effects?: ObjectEffects } + | { id: string; type: "polygon"; layerId: string; attrs: PolygonAttrs; effects?: ObjectEffects } + | { id: string; type: "star"; layerId: string; attrs: StarAttrs; effects?: ObjectEffects }; + +export interface ObjectEffects { + dropShadow?: { + enabled: boolean; + color: string; + opacity: number; + angle: number; + distance: number; + blur: number; + spread: number; + }; + innerShadow?: { + enabled: boolean; + color: string; + opacity: number; + angle: number; + distance: number; + blur: number; + }; + outerGlow?: { + enabled: boolean; + color: string; + opacity: number; + blur: number; + spread: number; + }; + stroke?: { + enabled: boolean; + color: string; + width: number; + position: "inside" | "center" | "outside"; + }; +} + +export interface EditorLayer { + id: string; + name: string; + visible: boolean; + locked: boolean; + opacity: number; + blendMode: string; + thumbnail: string | null; +} + +export interface SelectionState { + type: "rect" | "ellipse" | "lasso" | "wand"; + points: number[]; + bounds: { x: number; y: number; width: number; height: number }; + mask?: Uint8Array; +} + +export interface CropState { + x: number; + y: number; + width: number; + height: number; + aspectRatio: string | null; +} + +export interface AdjustmentValues { + brightness: number; + contrast: number; + hue: number; + saturation: number; + luminance: number; + exposure: number; + vibrance: number; + warmth: number; +} + +export interface FilterConfig { + type: string; + enabled: boolean; + params: Record; +} + +export interface Guide { + id: string; + orientation: "horizontal" | "vertical"; + position: number; +} + +export type AnchorPosition = + | "top-left" + | "top-center" + | "top-right" + | "center-left" + | "center" + | "center-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + +export interface LoadingState { + operation: string; + progress: number | null; + cancellable: boolean; +} + +export interface CloneSource { + x: number; + y: number; + aligned: boolean; +} + +export interface EditorState { + // Canvas + canvasSize: { width: number; height: number }; + zoom: number; + panOffset: { x: number; y: number }; + cursorPosition: { x: number; y: number }; + + // Image + sourceImageUrl: string | null; + sourceImageSize: { width: number; height: number } | null; + + // Active tool + activeTool: ToolType; + previousTool: ToolType | null; + + // Brush/Eraser settings + brushSize: number; + brushOpacity: number; + brushHardness: number; + + // Colors + foregroundColor: string; + backgroundColor: string; + recentColors: string[]; + + // Layers + layers: EditorLayer[]; + activeLayerId: string; + + // Objects + objects: CanvasObject[]; + selectedObjectIds: string[]; + + // Selection + selection: SelectionState | null; + + // Crop + cropState: CropState | null; + isCropping: boolean; + + // Adjustments & Filters + adjustments: AdjustmentValues; + filters: FilterConfig[]; + + // Text + editingTextId: string | null; + + // Shape settings + shapeFill: string; + shapeStroke: string; + shapeStrokeWidth: number; + shapeCornerRadius: number; + shapePolygonSides: number; + shapeStarPoints: number; + + // Clone stamp + cloneSource: CloneSource | null; + + // Dodge/Burn/Sponge + dodgeBurnRange: "shadows" | "midtones" | "highlights"; + dodgeBurnExposure: number; + spongeMode: "saturate" | "desaturate"; + spongeFlow: number; + + // UI + rightPanelTab: "layers" | "adjustments" | "history"; + rightPanelVisible: boolean; + isSpaceHeld: boolean; + + // Document state + isDirty: boolean; + lastAutoSave: number | null; + + // Clipboard + clipboard: CanvasObject[] | null; + + // Guides + guides: Guide[]; + snappingEnabled: boolean; + rulersVisible: boolean; + guidesVisible: boolean; + gridVisible: boolean; + + // Loading + loadingState: LoadingState | null; + + // History tracking + lastAction: string; + _historyVersion: number; + + // --- Actions --- + + // Tool + setTool: (tool: ToolType) => void; + setCursorPosition: (pos: { x: number; y: number }) => void; + + // Canvas + 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; + rotateCanvas: (degrees: 90 | 180 | 270) => void; + flipCanvasHorizontal: () => void; + flipCanvasVertical: () => void; + trimCanvas: () => void; + + // Colors + setForegroundColor: (color: string) => void; + setBackgroundColor: (color: string) => void; + swapColors: () => void; + resetColors: () => void; + + // Objects + addObject: (obj: CanvasObject) => void; + updateObject: (id: string, attrs: Partial) => void; + removeObjects: (ids: string[]) => void; + setSelectedObjects: (ids: string[]) => void; + bringToFront: (objectId: string) => void; + bringForward: (objectId: string) => void; + sendBackward: (objectId: string) => void; + sendToBack: (objectId: string) => void; + + // Layers + addLayer: () => void; + removeLayer: (id: string) => void; + duplicateLayer: (id: string) => void; + setActiveLayer: (id: string) => void; + updateLayer: (id: string, updates: Partial) => void; + reorderLayers: (fromIndex: number, toIndex: number) => void; + mergeDown: (id: string) => void; + flattenAll: () => void; + + // Adjustments & Filters + setAdjustment: (key: keyof AdjustmentValues, value: number) => void; + resetAdjustments: () => void; + toggleFilter: (type: string) => void; + setFilterParam: (type: string, key: string, value: number) => void; + + // Selection + setSelection: (selection: SelectionState | null) => void; + invertSelection: () => void; + + // Crop + setCropState: (state: CropState | null) => void; + applyCrop: () => void; + + // Brush + setBrushSize: (size: number) => void; + setBrushOpacity: (opacity: number) => void; + setBrushHardness: (hardness: number) => void; + + // Clipboard + copyObjects: () => void; + cutObjects: () => void; + pasteObjects: () => void; + pasteInPlace: () => void; + + // Guides + addGuide: (orientation: "horizontal" | "vertical", position: number) => void; + removeGuide: (id: string) => void; + updateGuide: (id: string, position: number) => void; + toggleSnapping: () => void; + toggleRulers: () => void; + toggleGuides: () => void; + toggleGrid: () => void; + + // Document state + markDirty: () => void; + markClean: () => void; + setLoadingState: (state: LoadingState | null) => void; + + // Right panel + setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void; + toggleRightPanel: () => void; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6e03bf5..b91e259e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,9 @@ importers: jszip: specifier: ^3.10.1 version: 3.10.1 + konva: + specifier: ^10 + version: 10.3.0 leaflet: specifier: ^1.9.4 version: 1.9.4 @@ -293,12 +296,21 @@ importers: react: specifier: ^19.0.0 version: 19.2.4 + react-colorful: + specifier: ^5 + version: 5.6.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + react-hotkeys-hook: + specifier: ^5 + version: 5.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-image-crop: specifier: ^11.0.10 version: 11.0.10(react@19.2.4) + react-konva: + specifier: ^19 + version: 19.2.3(@types/react@19.2.14)(konva@10.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-router-dom: specifier: ^7.1.0 version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -308,6 +320,12 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.1 + use-image: + specifier: ^1 + version: 1.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + zundo: + specifier: ^2 + version: 2.3.0(zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)) zustand: specifier: ^5.0.0 version: 5.0.12(@types/react@19.2.14)(react@19.2.4) @@ -3400,6 +3418,16 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + + '@types/react-reconciler@0.33.0': + resolution: {integrity: sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==} + peerDependencies: + '@types/react': '*' + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -4852,6 +4880,11 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -4944,6 +4977,9 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + konva@10.3.0: + resolution: {integrity: sha512-gt19K2gzY4lHbnkvsku7eSmB+A9PTS2jG4F9coBMsdjM1UKfJNxJbDbXVpeCW1wjEGRwBD3nBamcHnqJhAeKlg==} + lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} @@ -5884,11 +5920,23 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-colorful@5.6.1: + resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: react: ^19.2.4 + react-hotkeys-hook@5.3.2: + resolution: {integrity: sha512-DDDy9xK6mbTQ6aPlQvIl0dA/a90T/AWml4Rm21JXFDLlRHalIg4/Rv3equUQYs5xPTWq+oEl6RD7mi/nBpU3Uw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-image-crop@11.0.10: resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} peerDependencies: @@ -5897,6 +5945,19 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-konva@19.2.3: + resolution: {integrity: sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==} + peerDependencies: + konva: ^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0 + react: ^19.2.0 + react-dom: ^19.2.0 + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -6608,6 +6669,12 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + use-image@1.1.4: + resolution: {integrity: sha512-P+swhszzHHgEb2X2yQ+vQNPCq/8Ks3hyfdXAVN133pvnvK7UK++bUaZUa5E+A3S02Mw8xOCBr9O6CLhk2fjrWA==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + utif@2.0.1: resolution: {integrity: sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==} @@ -6899,6 +6966,11 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zundo@2.3.0: + resolution: {integrity: sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==} + peerDependencies: + zustand: ^4.3.0 || ^5.0.0 + zustand@5.0.12: resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} engines: {node: '>=12.20.0'} @@ -9771,6 +9843,14 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-reconciler@0.33.0(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -11265,6 +11345,13 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.4 + transitivePeerDependencies: + - '@types/react' + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -11377,6 +11464,8 @@ snapshots: kind-of@6.0.3: {} + konva@10.3.0: {} + lazystream@1.0.1: dependencies: readable-stream: 2.3.8 @@ -12329,17 +12418,44 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-colorful@5.6.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 scheduler: 0.27.0 + react-hotkeys-hook@5.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-image-crop@11.0.10(react@19.2.4): dependencies: react: 19.2.4 react-is@17.0.2: {} + react-konva@19.2.3(@types/react@19.2.14)(konva@10.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@types/react-reconciler': 0.33.0(@types/react@19.2.14) + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.4) + konva: 10.3.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-reconciler: 0.33.0(react@19.2.4) + scheduler: 0.27.0 + transitivePeerDependencies: + - '@types/react' + + react-reconciler@0.33.0(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + react-refresh@0.17.0: {} react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -13159,6 +13275,11 @@ snapshots: url-join@5.0.0: {} + use-image@1.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + utif@2.0.1: dependencies: pako: 1.0.11 @@ -13507,6 +13628,10 @@ snapshots: zod@4.3.6: {} + zundo@2.3.0(zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)): + dependencies: + zustand: 5.0.12(@types/react@19.2.14)(react@19.2.4) + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4): optionalDependencies: '@types/react': 19.2.14 From 01dcfa624bd18a9d2b4173b28756baf847355662 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:29:29 +0800 Subject: [PATCH 11/65] feat: add selection, crop, transform tools and supporting infrastructure for image editor Implements Agent 2 deliverables for the image editor: move tool with Konva Transformer and smart guide snapping, rectangular/elliptical marquee and lasso selection with marching ants animation, magic wand flood fill, crop tool with darkened overlay and rule-of-thirds grid, free transform with numeric inputs, context menu with z-ordering and clipboard operations, rulers with drag-to-create guides, smart alignment guides, and canvas/image resize dialogs. Also creates the shared editor types and Zustand store with full layer management, z-ordering, clipboard, guide, and document operations. --- apps/web/package.json | 6 +- .../editor/common/canvas-resize-dialog.tsx | 170 +++++ .../components/editor/common/context-menu.tsx | 272 +++++++ .../components/editor/common/guide-lines.tsx | 110 +++ .../editor/common/image-resize-dialog.tsx | 198 +++++ .../src/components/editor/common/rulers.tsx | 253 +++++++ .../components/editor/common/smart-guides.tsx | 208 ++++++ .../editor/options/crop-options.tsx | 177 +++++ .../editor/options/move-options.tsx | 133 ++++ .../editor/options/selection-options.tsx | 191 +++++ .../editor/options/transform-options.tsx | 142 ++++ .../src/components/editor/tools/crop-tool.tsx | 283 +++++++ .../src/components/editor/tools/move-tool.tsx | 428 +++++++++++ .../editor/tools/selection-tool.tsx | 561 ++++++++++++++ .../editor/tools/transform-tool.tsx | 237 ++++++ apps/web/src/stores/editor-store.ts | 706 ++++++++++++++++++ apps/web/src/types/editor.ts | 279 +++++++ pnpm-lock.yaml | 99 +++ 18 files changed, 4452 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/editor/common/canvas-resize-dialog.tsx create mode 100644 apps/web/src/components/editor/common/context-menu.tsx create mode 100644 apps/web/src/components/editor/common/guide-lines.tsx create mode 100644 apps/web/src/components/editor/common/image-resize-dialog.tsx create mode 100644 apps/web/src/components/editor/common/rulers.tsx create mode 100644 apps/web/src/components/editor/common/smart-guides.tsx create mode 100644 apps/web/src/components/editor/options/crop-options.tsx create mode 100644 apps/web/src/components/editor/options/move-options.tsx create mode 100644 apps/web/src/components/editor/options/selection-options.tsx create mode 100644 apps/web/src/components/editor/options/transform-options.tsx create mode 100644 apps/web/src/components/editor/tools/crop-tool.tsx create mode 100644 apps/web/src/components/editor/tools/move-tool.tsx create mode 100644 apps/web/src/components/editor/tools/selection-tool.tsx create mode 100644 apps/web/src/components/editor/tools/transform-tool.tsx create mode 100644 apps/web/src/stores/editor-store.ts create mode 100644 apps/web/src/types/editor.ts diff --git a/apps/web/package.json b/apps/web/package.json index d0db389a..56839c4f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,25 +12,29 @@ "clean": "rm -rf dist" }, "dependencies": { - "@snapotter/shared": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@sentry/react": "^10.49.0", + "@snapotter/shared": "workspace:*", "@use-gesture/react": "^10.3.1", "clsx": "^2.1.0", "fflate": "^0.8.2", "jszip": "^3.10.1", + "konva": "^10", "leaflet": "^1.9.4", "lucide-react": "^0.469.0", "posthog-js": "^1.370.0", "qr-code-styling": "^1.9.2", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hotkeys-hook": "^5.3.2", "react-image-crop": "^11.0.10", + "react-konva": "^19", "react-router-dom": "^7.1.0", "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", + "use-image": "^1.1.4", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/web/src/components/editor/common/canvas-resize-dialog.tsx b/apps/web/src/components/editor/common/canvas-resize-dialog.tsx new file mode 100644 index 00000000..3deac2a6 --- /dev/null +++ b/apps/web/src/components/editor/common/canvas-resize-dialog.tsx @@ -0,0 +1,170 @@ +import { X } from "lucide-react"; +import { useCallback, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { AnchorPosition } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// CanvasResizeDialog -- modal with W/H, 9-point anchor grid, fill color +// --------------------------------------------------------------------------- + +const ANCHOR_POSITIONS: AnchorPosition[] = [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right", +]; + +export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const canvasSize = useEditorStore((s) => s.canvasSize); + const resizeCanvas = useEditorStore((s) => s.resizeCanvas); + + const [width, setWidth] = useState(canvasSize.width); + const [height, setHeight] = useState(canvasSize.height); + const [anchor, setAnchor] = useState("center"); + const [fill, setFill] = useState("#ffffff"); + + const handleApply = useCallback(() => { + resizeCanvas(width, height, anchor, fill); + onClose(); + }, [width, height, anchor, fill, resizeCanvas, onClose]); + + if (!open) return null; + + const inputCn = cn( + "h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground", + "focus:border-primary focus:outline-none", + ); + + return ( +
+
+ {/* Header */} +
+

Canvas Size

+ +
+ + {/* Body */} +
+ {/* Current size info */} +

+ Current: {canvasSize.width} x {canvasSize.height} px +

+ + {/* Width / Height */} +
+
+ + setWidth(Math.max(1, Number(e.target.value) || 1))} + className={inputCn} + /> +
+
+ + setHeight(Math.max(1, Number(e.target.value) || 1))} + className={inputCn} + /> +
+
+ + {/* Anchor grid */} +
+

Anchor:

+
+ {ANCHOR_POSITIONS.map((pos) => ( +
+
+ + {/* Background fill */} +
+ + setFill(e.target.value)} + className="h-7 w-7 cursor-pointer rounded border border-border" + /> + setFill(e.target.value)} + className={cn( + "h-7 w-20 rounded border border-border bg-card px-1.5 text-xs text-foreground", + "focus:border-primary focus:outline-none", + )} + /> +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/context-menu.tsx b/apps/web/src/components/editor/common/context-menu.tsx new file mode 100644 index 00000000..ca8d599a --- /dev/null +++ b/apps/web/src/components/editor/common/context-menu.tsx @@ -0,0 +1,272 @@ +import { + ArrowDown, + ArrowUp, + ClipboardPaste, + Copy, + CopyPlus, + ImageIcon, + Maximize, + MousePointer, + Scissors, + Trash2, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// Context menu state +// --------------------------------------------------------------------------- + +interface MenuPosition { + x: number; + y: number; +} + +interface MenuItem { + label: string; + icon: React.ComponentType<{ className?: string }>; + shortcut?: string; + action: () => void; + disabled?: boolean; + dividerAfter?: boolean; +} + +// --------------------------------------------------------------------------- +// Hook: useContextMenu +// --------------------------------------------------------------------------- + +export function useContextMenu() { + const [position, setPosition] = useState(null); + const [menuType, setMenuType] = useState<"object" | "canvas">("canvas"); + + const handleContextMenu = useCallback((e: React.MouseEvent, hasSelectedObject: boolean) => { + e.preventDefault(); + setPosition({ x: e.clientX, y: e.clientY }); + setMenuType(hasSelectedObject ? "object" : "canvas"); + }, []); + + const close = useCallback(() => { + setPosition(null); + }, []); + + return { position, menuType, handleContextMenu, close }; +} + +// --------------------------------------------------------------------------- +// ContextMenu component +// --------------------------------------------------------------------------- + +export function ContextMenu({ + position, + menuType, + onClose, +}: { + position: MenuPosition; + menuType: "object" | "canvas"; + onClose: () => void; +}) { + const menuRef = useRef(null); + + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const copyObjects = useEditorStore((s) => s.copyObjects); + const cutObjects = useEditorStore((s) => s.cutObjects); + const pasteObjects = useEditorStore((s) => s.pasteObjects); + const removeObjects = useEditorStore((s) => s.removeObjects); + const duplicateObjects = useEditorStore((s) => s.duplicateObjects); + const bringToFront = useEditorStore((s) => s.bringToFront); + const bringForward = useEditorStore((s) => s.bringForward); + const sendBackward = useEditorStore((s) => s.sendBackward); + const sendToBack = useEditorStore((s) => s.sendToBack); + const clipboard = useEditorStore((s) => s.clipboard); + const setSelection = useEditorStore((s) => s.setSelection); + const canvasSize = useEditorStore((s) => s.canvasSize); + + // Close on click outside or Escape + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + const objectItems: MenuItem[] = [ + { + label: "Cut", + icon: Scissors, + shortcut: "Ctrl+X", + action: () => { + cutObjects(selectedObjectIds); + onClose(); + }, + }, + { + label: "Copy", + icon: Copy, + shortcut: "Ctrl+C", + action: () => { + copyObjects(selectedObjectIds); + onClose(); + }, + }, + { + label: "Paste", + icon: ClipboardPaste, + shortcut: "Ctrl+V", + action: () => { + pasteObjects(); + onClose(); + }, + disabled: clipboard.length === 0, + }, + { + label: "Duplicate", + icon: CopyPlus, + shortcut: "Ctrl+D", + action: () => { + duplicateObjects(selectedObjectIds); + onClose(); + }, + dividerAfter: true, + }, + { + label: "Bring to Front", + icon: ArrowUp, + action: () => { + for (const id of selectedObjectIds) bringToFront(id); + onClose(); + }, + }, + { + label: "Bring Forward", + icon: ArrowUp, + action: () => { + for (const id of selectedObjectIds) bringForward(id); + onClose(); + }, + }, + { + label: "Send Backward", + icon: ArrowDown, + action: () => { + for (const id of selectedObjectIds) sendBackward(id); + onClose(); + }, + }, + { + label: "Send to Back", + icon: ArrowDown, + action: () => { + for (const id of selectedObjectIds) sendToBack(id); + onClose(); + }, + dividerAfter: true, + }, + { + label: "Delete", + icon: Trash2, + shortcut: "Del", + action: () => { + removeObjects(selectedObjectIds); + onClose(); + }, + }, + ]; + + const canvasItems: MenuItem[] = [ + { + label: "Paste", + icon: ClipboardPaste, + shortcut: "Ctrl+V", + action: () => { + pasteObjects(); + onClose(); + }, + disabled: clipboard.length === 0, + }, + { + label: "Select All", + icon: MousePointer, + shortcut: "Ctrl+A", + action: () => { + setSelection({ + type: "rect", + points: [], + bounds: { + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + }, + }); + onClose(); + }, + dividerAfter: true, + }, + { + label: "Canvas Size...", + icon: Maximize, + action: () => { + // Trigger canvas resize dialog (handled by parent) + onClose(); + }, + }, + { + label: "Image Size...", + icon: ImageIcon, + action: () => { + // Trigger image resize dialog (handled by parent) + onClose(); + }, + }, + ]; + + const items = menuType === "object" ? objectItems : canvasItems; + + // Adjust position to stay within viewport + const adjustedX = Math.min(position.x, window.innerWidth - 220); + const adjustedY = Math.min(position.y, window.innerHeight - items.length * 36); + + return ( +
+ {items.map((item) => ( +
+ + {item.dividerAfter &&
} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/editor/common/guide-lines.tsx b/apps/web/src/components/editor/common/guide-lines.tsx new file mode 100644 index 00000000..39b47442 --- /dev/null +++ b/apps/web/src/components/editor/common/guide-lines.tsx @@ -0,0 +1,110 @@ +import type Konva from "konva"; +import { useCallback, useRef } from "react"; +import { Group, Line } from "react-konva"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// GuideLines -- draggable guide lines rendered as Konva.Line +// --------------------------------------------------------------------------- + +const GUIDE_COLOR = "#22d3ee"; // cyan-400 +const GUIDE_WIDTH = 1; + +export function GuideLines() { + const guides = useEditorStore((s) => s.guides); + const showGuides = useEditorStore((s) => s.showGuides); + const canvasSize = useEditorStore((s) => s.canvasSize); + const updateGuide = useEditorStore((s) => s.updateGuide); + const removeGuide = useEditorStore((s) => s.removeGuide); + + if (!showGuides || guides.length === 0) return null; + + return ( + + {guides.map((guide) => ( + updateGuide(guide.id, pos)} + onRemove={() => removeGuide(guide.id)} + /> + ))} + + ); +} + +// --------------------------------------------------------------------------- +// DraggableGuide -- individual guide line +// --------------------------------------------------------------------------- + +function DraggableGuide({ + id, + orientation, + position, + canvasWidth, + canvasHeight, + onPositionChange, + onRemove, +}: { + id: string; + orientation: "horizontal" | "vertical"; + position: number; + canvasWidth: number; + canvasHeight: number; + onPositionChange: (pos: number) => void; + onRemove: () => void; +}) { + const lineRef = useRef(null); + + const isHorizontal = orientation === "horizontal"; + + const points = isHorizontal + ? [0, position, canvasWidth, position] + : [position, 0, position, canvasHeight]; + + const handleDragEnd = useCallback( + (e: Konva.KonvaEventObject) => { + const node = e.target; + if (isHorizontal) { + const newY = node.y() + position; + node.y(0); // reset drag offset + onPositionChange(newY); + } else { + const newX = node.x() + position; + node.x(0); + onPositionChange(newX); + } + }, + [isHorizontal, position, onPositionChange], + ); + + const handleDblClick = useCallback(() => { + onRemove(); + }, [onRemove]); + + return ( + { + // Constrain drag to the guide's axis + if (isHorizontal) { + return { x: 0, y: pos.y }; + } + return { x: pos.x, y: 0 }; + }} + onDragEnd={handleDragEnd} + onDblClick={handleDblClick} + hitStrokeWidth={8} + /> + ); +} diff --git a/apps/web/src/components/editor/common/image-resize-dialog.tsx b/apps/web/src/components/editor/common/image-resize-dialog.tsx new file mode 100644 index 00000000..980a1d99 --- /dev/null +++ b/apps/web/src/components/editor/common/image-resize-dialog.tsx @@ -0,0 +1,198 @@ +import { Lock, Unlock, X } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { ResampleMethod } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// ImageResizeDialog -- modal with W/H, aspect lock, resampling method +// --------------------------------------------------------------------------- + +const RESAMPLE_METHODS: { value: ResampleMethod; label: string }[] = [ + { value: "nearest", label: "Nearest Neighbor (fast)" }, + { value: "bilinear", label: "Bilinear" }, + { value: "bicubic", label: "Bicubic (smooth)" }, + { value: "lanczos", label: "Lanczos (sharp)" }, +]; + +export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const canvasSize = useEditorStore((s) => s.canvasSize); + const resizeImage = useEditorStore((s) => s.resizeImage); + + const [width, setWidth] = useState(canvasSize.width); + const [height, setHeight] = useState(canvasSize.height); + const [lockAspect, setLockAspect] = useState(true); + const [resample, setResample] = useState("bicubic"); + + const aspectRatio = canvasSize.width / canvasSize.height; + + // Sync when dialog opens + useEffect(() => { + if (open) { + setWidth(canvasSize.width); + setHeight(canvasSize.height); + } + }, [open, canvasSize]); + + const handleWidthChange = useCallback( + (e: React.ChangeEvent) => { + const w = Math.max(1, Number(e.target.value) || 1); + setWidth(w); + if (lockAspect) { + setHeight(Math.round(w / aspectRatio)); + } + }, + [lockAspect, aspectRatio], + ); + + const handleHeightChange = useCallback( + (e: React.ChangeEvent) => { + const h = Math.max(1, Number(e.target.value) || 1); + setHeight(h); + if (lockAspect) { + setWidth(Math.round(h * aspectRatio)); + } + }, + [lockAspect, aspectRatio], + ); + + const handleApply = useCallback(() => { + resizeImage(width, height); + onClose(); + }, [width, height, resizeImage, onClose]); + + const pctWidth = canvasSize.width > 0 ? ((width / canvasSize.width) * 100).toFixed(1) : "100.0"; + const pctHeight = + canvasSize.height > 0 ? ((height / canvasSize.height) * 100).toFixed(1) : "100.0"; + + if (!open) return null; + + const inputCn = cn( + "h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground", + "focus:border-primary focus:outline-none", + ); + + return ( +
+
+ {/* Header */} +
+

Image Size

+ +
+ + {/* Body */} +
+ {/* Current size info */} +

+ Original: {canvasSize.width} x {canvasSize.height} px +

+ + {/* Width / Lock / Height */} +
+
+ + +

{pctWidth}%

+
+ + + +
+ + +

{pctHeight}%

+
+
+ + {/* Resampling method */} +
+ + +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/rulers.tsx b/apps/web/src/components/editor/common/rulers.tsx new file mode 100644 index 00000000..6be03c1b --- /dev/null +++ b/apps/web/src/components/editor/common/rulers.tsx @@ -0,0 +1,253 @@ +import { useCallback, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// Ruler configuration +// --------------------------------------------------------------------------- + +const RULER_SIZE = 20; // px +const TICK_COLOR = "var(--color-muted-foreground)"; +const BG_COLOR = "var(--color-card)"; +const TEXT_COLOR = "var(--color-muted-foreground)"; + +// --------------------------------------------------------------------------- +// Helper: pick tick spacing based on zoom level +// --------------------------------------------------------------------------- + +function getTickInterval(zoom: number): { major: number; minor: number } { + // Adjust tick spacing so labels don't overlap at any zoom + if (zoom >= 4) return { major: 25, minor: 5 }; + if (zoom >= 2) return { major: 50, minor: 10 }; + if (zoom >= 1) return { major: 100, minor: 10 }; + if (zoom >= 0.5) return { major: 200, minor: 50 }; + if (zoom >= 0.25) return { major: 500, minor: 100 }; + return { major: 1000, minor: 200 }; +} + +// --------------------------------------------------------------------------- +// HorizontalRuler +// --------------------------------------------------------------------------- + +export function HorizontalRuler() { + const canvasRef = useRef(null); + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + const canvasSize = useEditorStore((s) => s.canvasSize); + const showRulers = useEditorStore((s) => s.showRulers); + const addGuide = useEditorStore((s) => s.addGuide); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth; + const h = RULER_SIZE; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + + // Background + ctx.fillStyle = BG_COLOR; + ctx.fillRect(0, 0, w, h); + + const { major, minor } = getTickInterval(zoom); + const startPx = -panOffset.x / zoom; + const endPx = (w - panOffset.x) / zoom; + const startTick = Math.floor(startPx / minor) * minor; + + ctx.strokeStyle = TICK_COLOR; + ctx.fillStyle = TEXT_COLOR; + ctx.font = "9px sans-serif"; + ctx.textBaseline = "top"; + + for (let t = startTick; t <= endPx + minor; t += minor) { + const screenX = t * zoom + panOffset.x; + if (screenX < 0 || screenX > w) continue; + + const isMajor = t % major === 0; + const tickHeight = isMajor ? 12 : 6; + + ctx.beginPath(); + ctx.moveTo(screenX, h); + ctx.lineTo(screenX, h - tickHeight); + ctx.lineWidth = isMajor ? 0.8 : 0.4; + ctx.stroke(); + + if (isMajor) { + ctx.fillText(String(Math.round(t)), screenX + 2, 2); + } + } + + // Bottom border + ctx.strokeStyle = TICK_COLOR; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(0, h - 0.5); + ctx.lineTo(w, h - 0.5); + ctx.stroke(); + // canvasSize referenced to trigger redraw when canvas dimensions change + void canvasSize; + }, [zoom, panOffset, canvasSize]); + + useEffect(() => { + draw(); + }, [draw]); + + useEffect(() => { + const handleResize = () => draw(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [draw]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // Drag from ruler to create a horizontal guide + const startY = e.clientY; + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientY - startY) > 10) { + const canvasY = (me.clientY - startY) / zoom; + addGuide("horizontal", canvasY); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + } + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [zoom, addGuide], + ); + + if (!showRulers) return null; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// VerticalRuler +// --------------------------------------------------------------------------- + +export function VerticalRuler() { + const canvasRef = useRef(null); + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + const canvasSize = useEditorStore((s) => s.canvasSize); + const showRulers = useEditorStore((s) => s.showRulers); + const addGuide = useEditorStore((s) => s.addGuide); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = RULER_SIZE; + const h = canvas.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + + ctx.fillStyle = BG_COLOR; + ctx.fillRect(0, 0, w, h); + + const { major, minor } = getTickInterval(zoom); + const startPx = -panOffset.y / zoom; + const endPx = (h - panOffset.y) / zoom; + const startTick = Math.floor(startPx / minor) * minor; + + ctx.strokeStyle = TICK_COLOR; + ctx.fillStyle = TEXT_COLOR; + ctx.font = "9px sans-serif"; + + for (let t = startTick; t <= endPx + minor; t += minor) { + const screenY = t * zoom + panOffset.y; + if (screenY < 0 || screenY > h) continue; + + const isMajor = t % major === 0; + const tickWidth = isMajor ? 12 : 6; + + ctx.beginPath(); + ctx.moveTo(w, screenY); + ctx.lineTo(w - tickWidth, screenY); + ctx.lineWidth = isMajor ? 0.8 : 0.4; + ctx.stroke(); + + if (isMajor) { + ctx.save(); + ctx.translate(3, screenY + 2); + ctx.rotate(-Math.PI / 2); + ctx.textBaseline = "top"; + ctx.fillText(String(Math.round(t)), 0, 0); + ctx.restore(); + } + } + + // Right border + ctx.strokeStyle = TICK_COLOR; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(w - 0.5, 0); + ctx.lineTo(w - 0.5, h); + ctx.stroke(); + void canvasSize; + }, [zoom, panOffset, canvasSize]); + + useEffect(() => { + draw(); + }, [draw]); + + useEffect(() => { + const handleResize = () => draw(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [draw]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const startX = e.clientX; + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientX - startX) > 10) { + const canvasX = (me.clientX - startX) / zoom; + addGuide("vertical", canvasX); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + } + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [zoom, addGuide], + ); + + if (!showRulers) return null; + + return ( + + ); +} + +export { RULER_SIZE }; diff --git a/apps/web/src/components/editor/common/smart-guides.tsx b/apps/web/src/components/editor/common/smart-guides.tsx new file mode 100644 index 00000000..01439a60 --- /dev/null +++ b/apps/web/src/components/editor/common/smart-guides.tsx @@ -0,0 +1,208 @@ +import { Group, Line } from "react-konva"; +import { useEditorStore } from "@/stores/editor-store"; +import type { SmartGuide } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// Smart guide calculation utilities (exported for testing) +// --------------------------------------------------------------------------- + +const SNAP_THRESHOLD = 5; + +interface ObjectBounds { + id: string; + x: number; + y: number; + width: number; + height: number; +} + +export function findAlignmentGuides( + dragging: ObjectBounds, + others: ObjectBounds[], + canvasWidth: number, + canvasHeight: number, + threshold = SNAP_THRESHOLD, +): SmartGuide[] { + const guides: SmartGuide[] = []; + + const dragEdges = { + left: dragging.x, + right: dragging.x + dragging.width, + centerX: dragging.x + dragging.width / 2, + top: dragging.y, + bottom: dragging.y + dragging.height, + centerY: dragging.y + dragging.height / 2, + }; + + // Canvas alignment + const canvasTargets = [ + { pos: 0, orient: "vertical" as const, type: "canvas" as const }, + { + pos: canvasWidth / 2, + orient: "vertical" as const, + type: "canvas" as const, + }, + { pos: canvasWidth, orient: "vertical" as const, type: "canvas" as const }, + { pos: 0, orient: "horizontal" as const, type: "canvas" as const }, + { + pos: canvasHeight / 2, + orient: "horizontal" as const, + type: "canvas" as const, + }, + { + pos: canvasHeight, + orient: "horizontal" as const, + type: "canvas" as const, + }, + ]; + + for (const ct of canvasTargets) { + const edges = + ct.orient === "vertical" + ? [dragEdges.left, dragEdges.centerX, dragEdges.right] + : [dragEdges.top, dragEdges.centerY, dragEdges.bottom]; + + for (const edge of edges) { + if (Math.abs(edge - ct.pos) < threshold) { + guides.push({ + orientation: ct.orient, + position: ct.pos, + type: ct.type, + }); + } + } + } + + // Object-to-object alignment + for (const other of others) { + if (other.id === dragging.id) continue; + + const otherEdges = { + left: other.x, + right: other.x + other.width, + centerX: other.x + other.width / 2, + top: other.y, + bottom: other.y + other.height, + centerY: other.y + other.height / 2, + }; + + // Vertical guides (x-axis alignment) + const vPairs: [number, number, "edge" | "center"][] = [ + [dragEdges.left, otherEdges.left, "edge"], + [dragEdges.left, otherEdges.right, "edge"], + [dragEdges.right, otherEdges.left, "edge"], + [dragEdges.right, otherEdges.right, "edge"], + [dragEdges.centerX, otherEdges.centerX, "center"], + ]; + + for (const [dragVal, otherVal, type] of vPairs) { + if (Math.abs(dragVal - otherVal) < threshold) { + guides.push({ orientation: "vertical", position: otherVal, type }); + } + } + + // Horizontal guides (y-axis alignment) + const hPairs: [number, number, "edge" | "center"][] = [ + [dragEdges.top, otherEdges.top, "edge"], + [dragEdges.top, otherEdges.bottom, "edge"], + [dragEdges.bottom, otherEdges.top, "edge"], + [dragEdges.bottom, otherEdges.bottom, "edge"], + [dragEdges.centerY, otherEdges.centerY, "center"], + ]; + + for (const [dragVal, otherVal, type] of hPairs) { + if (Math.abs(dragVal - otherVal) < threshold) { + guides.push({ orientation: "horizontal", position: otherVal, type }); + } + } + } + + return guides; +} + +export function snapToGuides( + pos: { x: number; y: number }, + size: { width: number; height: number }, + guides: SmartGuide[], + threshold = SNAP_THRESHOLD, +): { x: number; y: number } { + let { x, y } = pos; + + for (const g of guides) { + if (g.orientation === "vertical") { + // Snap left edge, center, or right edge + if (Math.abs(x - g.position) < threshold) { + x = g.position; + } else if (Math.abs(x + size.width / 2 - g.position) < threshold) { + x = g.position - size.width / 2; + } else if (Math.abs(x + size.width - g.position) < threshold) { + x = g.position - size.width; + } + } else { + if (Math.abs(y - g.position) < threshold) { + y = g.position; + } else if (Math.abs(y + size.height / 2 - g.position) < threshold) { + y = g.position - size.height / 2; + } else if (Math.abs(y + size.height - g.position) < threshold) { + y = g.position - size.height; + } + } + } + + return { x, y }; +} + +// --------------------------------------------------------------------------- +// SmartGuidesOverlay -- renders temporary guide lines during drag +// --------------------------------------------------------------------------- + +const GUIDE_COLORS = { + edge: "#f43f5e", + center: "#8b5cf6", + canvas: "#22c55e", +}; + +export function SmartGuidesOverlay({ guides }: { guides: SmartGuide[] }) { + const canvasSize = useEditorStore((s) => s.canvasSize); + + if (guides.length === 0) return null; + + // Deduplicate guides by position + orientation + const seen = new Set(); + const unique = guides.filter((g) => { + const key = `${g.orientation}-${g.position}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + return ( + + {unique.map((g) => { + const color = GUIDE_COLORS[g.type]; + if (g.orientation === "vertical") { + return ( + + ); + } + return ( + + ); + })} + + ); +} diff --git a/apps/web/src/components/editor/options/crop-options.tsx b/apps/web/src/components/editor/options/crop-options.tsx new file mode 100644 index 00000000..05a5cc66 --- /dev/null +++ b/apps/web/src/components/editor/options/crop-options.tsx @@ -0,0 +1,177 @@ +import { ArrowLeftRight, Check, X } from "lucide-react"; +import { useCallback, useState } from "react"; +import { ASPECT_RATIOS } from "@/components/editor/tools/crop-tool"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// CropOptions -- aspect ratio dropdown, W/H inputs, apply/cancel +// --------------------------------------------------------------------------- + +export function CropOptions() { + const cropState = useEditorStore((s) => s.cropState); + const setCropState = useEditorStore((s) => s.setCropState); + const applyCrop = useEditorStore((s) => s.applyCrop); + const [selectedRatio, setSelectedRatio] = useState("Free"); + + const handleAspectChange = useCallback( + (e: React.ChangeEvent) => { + const label = e.target.value; + setSelectedRatio(label); + if (!cropState) return; + + const preset = ASPECT_RATIOS.find((p) => p.label === label); + if (!preset || !preset.value) { + setCropState({ ...cropState, aspectRatio: null }); + return; + } + + const ratio = preset.value; + let w = cropState.width; + let h = w / ratio; + if (h > cropState.height * 1.5) { + h = cropState.height; + w = h * ratio; + } + setCropState({ ...cropState, width: w, height: h, aspectRatio: label }); + }, + [cropState, setCropState], + ); + + const handleWidthChange = useCallback( + (e: React.ChangeEvent) => { + if (!cropState) return; + const w = Math.max(1, Number(e.target.value) || 1); + setCropState({ ...cropState, width: w }); + }, + [cropState, setCropState], + ); + + const handleHeightChange = useCallback( + (e: React.ChangeEvent) => { + if (!cropState) return; + const h = Math.max(1, Number(e.target.value) || 1); + setCropState({ ...cropState, height: h }); + }, + [cropState, setCropState], + ); + + const handleSwap = useCallback(() => { + if (!cropState) return; + setCropState({ + ...cropState, + width: cropState.height, + height: cropState.width, + }); + }, [cropState, setCropState]); + + const handleApply = useCallback(() => { + applyCrop(); + }, [applyCrop]); + + const handleCancel = useCallback(() => { + setCropState(null); + }, [setCropState]); + + const inputCn = cn( + "h-6 w-16 rounded border border-border bg-card px-1.5 text-xs text-foreground", + "focus:border-primary focus:outline-none", + ); + + return ( +
+ {/* Aspect ratio dropdown */} +
+ + +
+ +
+ + {/* Width and Height inputs */} +
+ + +
+ + + +
+ + +
+ +
+ + {/* Apply / Cancel */} + + +
+ ); +} diff --git a/apps/web/src/components/editor/options/move-options.tsx b/apps/web/src/components/editor/options/move-options.tsx new file mode 100644 index 00000000..4f432a91 --- /dev/null +++ b/apps/web/src/components/editor/options/move-options.tsx @@ -0,0 +1,133 @@ +import { + AlignCenterHorizontal, + AlignCenterVertical, + AlignEndHorizontal, + AlignEndVertical, + AlignStartHorizontal, + AlignStartVertical, + ArrowLeftRight, + ArrowUpDown, +} from "lucide-react"; +import { alignObjects } from "@/components/editor/tools/move-tool"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// MoveOptions -- alignment and distribute buttons in the options bar +// --------------------------------------------------------------------------- + +function OptionButton({ + icon: Icon, + label, + onClick, + disabled, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +export function MoveOptions() { + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const objects = useEditorStore((s) => s.objects); + const updateObject = useEditorStore((s) => s.updateObject); + + const hasSelection = selectedObjectIds.length >= 2; + const hasThreeOrMore = selectedObjectIds.length >= 3; + + const handleAlign = ( + direction: + | "left" + | "center-h" + | "right" + | "top" + | "center-v" + | "bottom" + | "distribute-h" + | "distribute-v", + ) => { + alignObjects(direction, selectedObjectIds, objects, updateObject); + }; + + return ( +
+ Align: + + handleAlign("left")} + disabled={!hasSelection} + /> + handleAlign("center-h")} + disabled={!hasSelection} + /> + handleAlign("right")} + disabled={!hasSelection} + /> + +
+ + handleAlign("top")} + disabled={!hasSelection} + /> + handleAlign("center-v")} + disabled={!hasSelection} + /> + handleAlign("bottom")} + disabled={!hasSelection} + /> + +
+ + Distribute: + + handleAlign("distribute-h")} + disabled={!hasThreeOrMore} + /> + handleAlign("distribute-v")} + disabled={!hasThreeOrMore} + /> +
+ ); +} diff --git a/apps/web/src/components/editor/options/selection-options.tsx b/apps/web/src/components/editor/options/selection-options.tsx new file mode 100644 index 00000000..9ba03596 --- /dev/null +++ b/apps/web/src/components/editor/options/selection-options.tsx @@ -0,0 +1,191 @@ +import { Circle, Minus, PenTool, Plus, Square, Wand2 } from "lucide-react"; +import { useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { SelectionMode, SelectionType, ToolType } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// SelectionOptions -- selection type toggle, mode buttons, feather input +// --------------------------------------------------------------------------- + +function ToggleButton({ + active, + onClick, + label, + children, +}: { + active: boolean; + onClick: () => void; + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function SelectionOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const selectionMode = useEditorStore((s) => s.selectionMode); + const setSelectionMode = useEditorStore((s) => s.setSelectionMode); + + const selectionType: SelectionType = + activeTool === "marquee-ellipse" + ? "ellipse" + : activeTool === "lasso-free" || activeTool === "lasso-poly" + ? "lasso" + : "rect"; + + const handleTypeChange = useCallback( + (type: SelectionType) => { + const toolMap: Record = { + rect: "marquee-rect", + ellipse: "marquee-ellipse", + lasso: "lasso-free", + }; + setTool(toolMap[type]); + }, + [setTool], + ); + + const handleModeChange = useCallback( + (mode: SelectionMode) => { + setSelectionMode(mode); + }, + [setSelectionMode], + ); + + const isMarquee = activeTool === "marquee-rect" || activeTool === "marquee-ellipse"; + const isLasso = activeTool === "lasso-free" || activeTool === "lasso-poly"; + const isMagicWand = activeTool === "magic-wand"; + + return ( +
+ {/* Selection type toggle */} + {!isMagicWand && ( +
+ Type: + handleTypeChange("rect")} + label="Rectangular" + > + + Rect + + handleTypeChange("ellipse")} + label="Elliptical" + > + + Ellipse + + handleTypeChange("lasso")} label="Lasso"> + + Lasso + +
+ )} + + {isMagicWand && ( +
+ + Magic Wand +
+ )} + +
+ + {/* Selection mode buttons */} +
+ Mode: + handleModeChange("new")} + label="New Selection" + > + New + + handleModeChange("add")} + label="Add to Selection" + > + + Add + + handleModeChange("subtract")} + label="Subtract from Selection" + > + + Sub + +
+ + {/* Lasso sub-type toggle */} + {isLasso && ( + <> +
+
+ setTool("lasso-free")} + label="Freehand Lasso" + > + Freehand + + setTool("lasso-poly")} + label="Polygonal Lasso" + > + Polygonal + +
+ + )} + + {/* Magic Wand tolerance */} + {isMagicWand && ( + <> +
+
+ + +
+ + )} +
+ ); +} diff --git a/apps/web/src/components/editor/options/transform-options.tsx b/apps/web/src/components/editor/options/transform-options.tsx new file mode 100644 index 00000000..69a80c84 --- /dev/null +++ b/apps/web/src/components/editor/options/transform-options.tsx @@ -0,0 +1,142 @@ +import { FlipHorizontal2, FlipVertical2, Lock, Unlock } from "lucide-react"; +import { useCallback } from "react"; +import type { TransformToolApi } from "@/components/editor/tools/transform-tool"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// TransformOptions -- X/Y/W/H/rotation inputs, aspect lock, flip buttons +// --------------------------------------------------------------------------- + +function NumericInput({ + id, + label, + value, + onChange, + min, + max, + step, +}: { + id: string; + label: string; + value: number; + onChange: (v: number) => void; + min?: number; + max?: number; + step?: number; +}) { + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const v = Number(e.target.value); + if (Number.isFinite(v)) onChange(v); + }, + [onChange], + ); + + return ( +
+ + +
+ ); +} + +export function TransformOptions({ api }: { api: TransformToolApi }) { + const { values, lockedAspect, setLockedAspect, setValues, flipHorizontal, flipVertical } = api; + + return ( +
+ setValues({ x: v })} + /> + setValues({ y: v })} + /> + +
+ + setValues({ width: v })} + /> + + + + setValues({ height: v })} + /> + +
+ + setValues({ rotation: v })} + /> + +
+ + + +
+ ); +} diff --git a/apps/web/src/components/editor/tools/crop-tool.tsx b/apps/web/src/components/editor/tools/crop-tool.tsx new file mode 100644 index 00000000..0026646c --- /dev/null +++ b/apps/web/src/components/editor/tools/crop-tool.tsx @@ -0,0 +1,283 @@ +import type Konva from "konva"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Group, Line, Rect, Transformer } from "react-konva"; +import { useEditorStore } from "@/stores/editor-store"; +import type { CropState } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// Aspect ratio presets +// --------------------------------------------------------------------------- + +export const ASPECT_RATIOS = [ + { label: "Free", value: null }, + { label: "1:1", value: 1 }, + { label: "4:3", value: 4 / 3 }, + { label: "3:4", value: 3 / 4 }, + { label: "16:9", value: 16 / 9 }, + { label: "9:16", value: 9 / 16 }, + { label: "3:2", value: 3 / 2 }, + { label: "2:3", value: 2 / 3 }, +] as const; + +// --------------------------------------------------------------------------- +// Hook: useCropTool +// --------------------------------------------------------------------------- + +export interface CropToolApi { + cropRef: React.RefObject; + transformerRef: React.RefObject; + cropState: CropState | null; + aspectRatio: string; + setAspectRatio: (label: string) => void; + initCrop: () => void; + applyCrop: () => void; + cancelCrop: () => void; + updateCropSize: (w: number, h: number) => void; + swapDimensions: () => void; +} + +export function useCropTool(): CropToolApi { + const cropRef = useRef(null); + const transformerRef = useRef(null); + const [aspectRatio, setAspectRatioState] = useState("Free"); + + const cropState = useEditorStore((s) => s.cropState); + const setCropState = useEditorStore((s) => s.setCropState); + const applyCropAction = useEditorStore((s) => s.applyCrop); + const canvasSize = useEditorStore((s) => s.canvasSize); + + // Attach transformer to crop rect + useEffect(() => { + const tr = transformerRef.current; + const node = cropRef.current; + if (tr && node && cropState) { + tr.nodes([node]); + tr.getLayer()?.batchDraw(); + } + }, [cropState]); + + const initCrop = useCallback(() => { + const margin = 0.1; + setCropState({ + x: canvasSize.width * margin, + y: canvasSize.height * margin, + width: canvasSize.width * (1 - 2 * margin), + height: canvasSize.height * (1 - 2 * margin), + aspectRatio: null, + }); + }, [canvasSize, setCropState]); + + const setAspectRatio = useCallback( + (label: string) => { + setAspectRatioState(label); + const preset = ASPECT_RATIOS.find((p) => p.label === label); + if (!preset || !preset.value || !cropState) return; + const ratio = preset.value; + + let w = cropState.width; + let h = w / ratio; + if (h > canvasSize.height) { + h = canvasSize.height * 0.8; + w = h * ratio; + } + + setCropState({ + ...cropState, + width: w, + height: h, + aspectRatio: label, + }); + }, + [cropState, canvasSize, setCropState], + ); + + const applyCrop = useCallback(() => { + applyCropAction(); + }, [applyCropAction]); + + const cancelCrop = useCallback(() => { + setCropState(null); + }, [setCropState]); + + const updateCropSize = useCallback( + (w: number, h: number) => { + if (!cropState) return; + setCropState({ ...cropState, width: w, height: h }); + }, + [cropState, setCropState], + ); + + const swapDimensions = useCallback(() => { + if (!cropState) return; + setCropState({ + ...cropState, + width: cropState.height, + height: cropState.width, + }); + }, [cropState, setCropState]); + + return { + cropRef, + transformerRef, + cropState, + aspectRatio, + setAspectRatio, + initCrop, + applyCrop, + cancelCrop, + updateCropSize, + swapDimensions, + }; +} + +// --------------------------------------------------------------------------- +// CropOverlay -- renders darkened overlay + crop region + rule-of-thirds +// --------------------------------------------------------------------------- + +export function CropOverlay() { + const cropState = useEditorStore((s) => s.cropState); + const canvasSize = useEditorStore((s) => s.canvasSize); + const setCropState = useEditorStore((s) => s.setCropState); + const cropRectRef = useRef(null); + const trRef = useRef(null); + + useEffect(() => { + if (trRef.current && cropRectRef.current && cropState) { + trRef.current.nodes([cropRectRef.current]); + trRef.current.getLayer()?.batchDraw(); + } + }, [cropState]); + + if (!cropState) return null; + + const { x, y, width, height } = cropState; + const cw = canvasSize.width; + const ch = canvasSize.height; + const overlayFill = "rgba(0, 0, 0, 0.5)"; + + // Rule-of-thirds grid lines + const thirdW = width / 3; + const thirdH = height / 3; + + const handleTransformEnd = () => { + const node = cropRectRef.current; + if (!node) return; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + const newW = Math.max(10, node.width() * scaleX); + const newH = Math.max(10, node.height() * scaleY); + node.scaleX(1); + node.scaleY(1); + setCropState({ + ...cropState, + x: node.x(), + y: node.y(), + width: newW, + height: newH, + }); + }; + + const handleDragEnd = () => { + const node = cropRectRef.current; + if (!node) return; + setCropState({ + ...cropState, + x: Math.max(0, Math.min(node.x(), cw - width)), + y: Math.max(0, Math.min(node.y(), ch - height)), + }); + }; + + return ( + + {/* Darkened overlays: top, bottom, left, right */} + + + + + + {/* Crop region */} + + + {/* Rule-of-thirds grid */} + + + + + + {/* Transformer */} + ({ + ...newBox, + width: Math.max(10, newBox.width), + height: Math.max(10, newBox.height), + })} + /> + + ); +} diff --git a/apps/web/src/components/editor/tools/move-tool.tsx b/apps/web/src/components/editor/tools/move-tool.tsx new file mode 100644 index 00000000..3b47fd39 --- /dev/null +++ b/apps/web/src/components/editor/tools/move-tool.tsx @@ -0,0 +1,428 @@ +import type Konva from "konva"; +import { useCallback, useEffect, useRef } from "react"; +import { Transformer } from "react-konva"; +import { useEditorStore } from "@/stores/editor-store"; +import type { SmartGuide } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// Smart guide calculation +// --------------------------------------------------------------------------- + +function findSmartGuides( + node: Konva.Node, + allNodes: Konva.Node[], + canvasWidth: number, + canvasHeight: number, + threshold: number, +): SmartGuide[] { + const box = node.getClientRect({ relativeTo: node.getParent() ?? undefined }); + const guides: SmartGuide[] = []; + + const dragEdges = { + left: box.x, + right: box.x + box.width, + centerX: box.x + box.width / 2, + top: box.y, + bottom: box.y + box.height, + centerY: box.y + box.height / 2, + }; + + // Canvas edges + center + const canvasSnaps = [ + { pos: 0, type: "canvas" as const, orient: "vertical" as const }, + { + pos: canvasWidth / 2, + type: "canvas" as const, + orient: "vertical" as const, + }, + { pos: canvasWidth, type: "canvas" as const, orient: "vertical" as const }, + { pos: 0, type: "canvas" as const, orient: "horizontal" as const }, + { + pos: canvasHeight / 2, + type: "canvas" as const, + orient: "horizontal" as const, + }, + { + pos: canvasHeight, + type: "canvas" as const, + orient: "horizontal" as const, + }, + ]; + + for (const snap of canvasSnaps) { + if (snap.orient === "vertical") { + for (const edge of [dragEdges.left, dragEdges.centerX, dragEdges.right]) { + if (Math.abs(edge - snap.pos) < threshold) { + guides.push({ + orientation: "vertical", + position: snap.pos, + type: snap.type, + }); + } + } + } else { + for (const edge of [dragEdges.top, dragEdges.centerY, dragEdges.bottom]) { + if (Math.abs(edge - snap.pos) < threshold) { + guides.push({ + orientation: "horizontal", + position: snap.pos, + type: snap.type, + }); + } + } + } + } + + // Other objects + for (const other of allNodes) { + if (other === node) continue; + const ob = other.getClientRect({ + relativeTo: other.getParent() ?? undefined, + }); + const targetEdges = { + left: ob.x, + right: ob.x + ob.width, + centerX: ob.x + ob.width / 2, + top: ob.y, + bottom: ob.y + ob.height, + centerY: ob.y + ob.height / 2, + }; + + for (const edgeVal of [targetEdges.left, targetEdges.centerX, targetEdges.right]) { + for (const dragVal of [dragEdges.left, dragEdges.centerX, dragEdges.right]) { + if (Math.abs(dragVal - edgeVal) < threshold) { + guides.push({ + orientation: "vertical", + position: edgeVal, + type: dragVal === dragEdges.centerX ? "center" : "edge", + }); + } + } + } + + for (const edgeVal of [targetEdges.top, targetEdges.centerY, targetEdges.bottom]) { + for (const dragVal of [dragEdges.top, dragEdges.centerY, dragEdges.bottom]) { + if (Math.abs(dragVal - edgeVal) < threshold) { + guides.push({ + orientation: "horizontal", + position: edgeVal, + type: dragVal === dragEdges.centerY ? "center" : "edge", + }); + } + } + } + } + + return guides; +} + +function snapPosition( + pos: { x: number; y: number }, + guides: SmartGuide[], + box: { width: number; height: number }, + threshold: number, +): { x: number; y: number } { + let { x, y } = pos; + + for (const g of guides) { + if (g.orientation === "vertical") { + if (Math.abs(x - g.position) < threshold) x = g.position; + else if (Math.abs(x + box.width / 2 - g.position) < threshold) x = g.position - box.width / 2; + else if (Math.abs(x + box.width - g.position) < threshold) x = g.position - box.width; + } else { + if (Math.abs(y - g.position) < threshold) y = g.position; + else if (Math.abs(y + box.height / 2 - g.position) < threshold) + y = g.position - box.height / 2; + else if (Math.abs(y + box.height - g.position) < threshold) y = g.position - box.height; + } + } + + return { x, y }; +} + +// --------------------------------------------------------------------------- +// Exported helpers for alignment +// --------------------------------------------------------------------------- + +export function alignObjects( + direction: + | "left" + | "center-h" + | "right" + | "top" + | "center-v" + | "bottom" + | "distribute-h" + | "distribute-v", + objectIds: string[], + objects: { id: string; attrs: Record }[], + updateObject: (id: string, attrs: Record) => void, +): void { + const selected = objects.filter((o) => objectIds.includes(o.id)); + if (selected.length < 2 && !direction.startsWith("distribute")) return; + if (selected.length < 3 && direction.startsWith("distribute")) return; + + const bounds = selected.map((o) => ({ + id: o.id, + x: (o.attrs.x as number) ?? 0, + y: (o.attrs.y as number) ?? 0, + w: (o.attrs.width as number) ?? 0, + h: (o.attrs.height as number) ?? 0, + })); + + switch (direction) { + case "left": { + const minX = Math.min(...bounds.map((b) => b.x)); + for (const b of bounds) updateObject(b.id, { x: minX }); + break; + } + case "center-h": { + const minX = Math.min(...bounds.map((b) => b.x)); + const maxX = Math.max(...bounds.map((b) => b.x + b.w)); + const center = (minX + maxX) / 2; + for (const b of bounds) updateObject(b.id, { x: center - b.w / 2 }); + break; + } + case "right": { + const maxX = Math.max(...bounds.map((b) => b.x + b.w)); + for (const b of bounds) updateObject(b.id, { x: maxX - b.w }); + break; + } + case "top": { + const minY = Math.min(...bounds.map((b) => b.y)); + for (const b of bounds) updateObject(b.id, { y: minY }); + break; + } + case "center-v": { + const minY = Math.min(...bounds.map((b) => b.y)); + const maxY = Math.max(...bounds.map((b) => b.y + b.h)); + const center = (minY + maxY) / 2; + for (const b of bounds) updateObject(b.id, { y: center - b.h / 2 }); + break; + } + case "bottom": { + const maxY = Math.max(...bounds.map((b) => b.y + b.h)); + for (const b of bounds) updateObject(b.id, { y: maxY - b.h }); + break; + } + case "distribute-h": { + const sorted = [...bounds].sort((a, b) => a.x - b.x); + const totalW = sorted.reduce((s, b) => s + b.w, 0); + const minX = sorted[0].x; + const maxX = sorted[sorted.length - 1].x + sorted[sorted.length - 1].w; + const gap = (maxX - minX - totalW) / (sorted.length - 1); + let cx = minX; + for (const b of sorted) { + updateObject(b.id, { x: cx }); + cx += b.w + gap; + } + break; + } + case "distribute-v": { + const sorted = [...bounds].sort((a, b) => a.y - b.y); + const totalH = sorted.reduce((s, b) => s + b.h, 0); + const minY = sorted[0].y; + const maxY = sorted[sorted.length - 1].y + sorted[sorted.length - 1].h; + const gap = (maxY - minY - totalH) / (sorted.length - 1); + let cy = minY; + for (const b of sorted) { + updateObject(b.id, { y: cy }); + cy += b.h + gap; + } + break; + } + } +} + +// --------------------------------------------------------------------------- +// Hook: useMoveTool +// --------------------------------------------------------------------------- + +export interface MoveToolApi { + transformerRef: React.RefObject; + smartGuides: SmartGuide[]; + onSelect: (e: Konva.KonvaEventObject) => void; + onStageClick: (e: Konva.KonvaEventObject) => void; + onDragStart: (e: Konva.KonvaEventObject) => void; + onDragMove: (e: Konva.KonvaEventObject) => void; + onDragEnd: (e: Konva.KonvaEventObject) => void; + onTransformEnd: (e: Konva.KonvaEventObject) => void; + nudge: (dx: number, dy: number) => void; +} + +export function useMoveTool(): MoveToolApi { + const transformerRef = useRef(null); + const smartGuidesRef = useRef([]); + + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const setSelectedObjects = useEditorStore((s) => s.setSelectedObjects); + const updateObject = useEditorStore((s) => s.updateObject); + const canvasSize = useEditorStore((s) => s.canvasSize); + const snapToGuides = useEditorStore((s) => s.snapToGuides); + + // Attach transformer to selected nodes + useEffect(() => { + const tr = transformerRef.current; + if (!tr) return; + const stage = tr.getStage(); + if (!stage) return; + + const nodes = selectedObjectIds + .map((id) => stage.findOne(`#${id}`)) + .filter(Boolean) as Konva.Node[]; + tr.nodes(nodes); + tr.getLayer()?.batchDraw(); + }, [selectedObjectIds]); + + const onSelect = useCallback( + (e: Konva.KonvaEventObject) => { + const target = e.target; + const id = target.id(); + if (!id) return; + + if (e.evt.shiftKey) { + // Toggle multi-select + if (selectedObjectIds.includes(id)) { + setSelectedObjects(selectedObjectIds.filter((i) => i !== id)); + } else { + setSelectedObjects([...selectedObjectIds, id]); + } + } else { + setSelectedObjects([id]); + } + }, + [selectedObjectIds, setSelectedObjects], + ); + + const onStageClick = useCallback( + (e: Konva.KonvaEventObject) => { + // Clicked on stage background - deselect + if (e.target === e.target.getStage()) { + setSelectedObjects([]); + } + }, + [setSelectedObjects], + ); + + const onDragStart = useCallback((_e: Konva.KonvaEventObject) => { + // No-op -- selection already handled by onClick + }, []); + + const onDragMove = useCallback( + (e: Konva.KonvaEventObject) => { + const node = e.target; + if (!snapToGuides) return; + + const stage = node.getStage(); + if (!stage) return; + + const allNodes = stage + .find("Rect, Ellipse, Text, Image, Line, Arrow, RegularPolygon, Star") + .filter((n) => n.id() && n.id() !== node.id()); + + const guides = findSmartGuides(node, allNodes, canvasSize.width, canvasSize.height, 5); + smartGuidesRef.current = guides; + + if (guides.length > 0) { + const box = node.getClientRect({ + relativeTo: node.getParent() ?? undefined, + }); + const snapped = snapPosition( + { x: node.x(), y: node.y() }, + guides, + { width: box.width, height: box.height }, + 5, + ); + node.position(snapped); + } + }, + [canvasSize, snapToGuides], + ); + + const onDragEnd = useCallback( + (e: Konva.KonvaEventObject) => { + const node = e.target; + smartGuidesRef.current = []; + updateObject(node.id(), { + x: node.x(), + y: node.y(), + }); + }, + [updateObject], + ); + + const onTransformEnd = useCallback( + (e: Konva.KonvaEventObject) => { + const node = e.target; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + + // Normalize scale into width/height + const newWidth = Math.max(1, node.width() * scaleX); + const newHeight = Math.max(1, node.height() * scaleY); + node.scaleX(1); + node.scaleY(1); + + updateObject(node.id(), { + x: node.x(), + y: node.y(), + width: newWidth, + height: newHeight, + rotation: node.rotation(), + }); + }, + [updateObject], + ); + + const nudge = useCallback( + (dx: number, dy: number) => { + for (const id of selectedObjectIds) { + const obj = useEditorStore.getState().objects.find((o) => o.id === id); + if (!obj) continue; + updateObject(id, { + x: ((obj.attrs.x as number) ?? 0) + dx, + y: ((obj.attrs.y as number) ?? 0) + dy, + }); + } + }, + [selectedObjectIds, updateObject], + ); + + return { + transformerRef, + smartGuides: smartGuidesRef.current, + onSelect, + onStageClick, + onDragStart, + onDragMove, + onDragEnd, + onTransformEnd, + nudge, + }; +} + +// --------------------------------------------------------------------------- +// MoveToolTransformer -- Konva Transformer component +// --------------------------------------------------------------------------- + +export function MoveToolTransformer({ + transformerRef, +}: { + transformerRef: React.RefObject; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/editor/tools/selection-tool.tsx b/apps/web/src/components/editor/tools/selection-tool.tsx new file mode 100644 index 00000000..3f12f84c --- /dev/null +++ b/apps/web/src/components/editor/tools/selection-tool.tsx @@ -0,0 +1,561 @@ +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 { SelectionMode, SelectionState, SelectionType } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// Marching ants animation +// --------------------------------------------------------------------------- + +const DASH = [6, 4]; +const MARCH_SPEED = 1; + +function useMarchingAnts(layerRef: React.RefObject) { + const dashOffsetRef = useRef(0); + const animRef = useRef(null); + + useEffect(() => { + const layer = layerRef.current; + if (!layer) return; + + // Dynamically import Konva for Animation + import("konva").then((KonvaModule) => { + const anim = new KonvaModule.default.Animation(() => { + dashOffsetRef.current -= MARCH_SPEED; + }, layer); + animRef.current = anim; + anim.start(); + }); + + return () => { + animRef.current?.stop(); + }; + }, [layerRef]); + + return dashOffsetRef; +} + +// --------------------------------------------------------------------------- +// Magic wand -- flood fill to generate selection mask +// --------------------------------------------------------------------------- + +function floodFillMask( + imageData: ImageData, + startX: number, + startY: number, + tolerance: number, + contiguous: boolean, +): boolean[][] { + const { width, height, data } = imageData; + const mask: boolean[][] = Array.from( + { length: height }, + () => Array(width).fill(false) as boolean[], + ); + + const sx = Math.round(startX); + const sy = Math.round(startY); + if (sx < 0 || sx >= width || sy < 0 || sy >= height) return mask; + + const idx = (sy * width + sx) * 4; + const targetR = data[idx]; + const targetG = data[idx + 1]; + const targetB = data[idx + 2]; + + function colorDist(i: number): number { + const dr = data[i] - targetR; + const dg = data[i + 1] - targetG; + const db = data[i + 2] - targetB; + return Math.sqrt(dr * dr + dg * dg + db * db); + } + + if (contiguous) { + // Scanline flood fill + const stack: [number, number][] = [[sx, sy]]; + while (stack.length > 0) { + const item = stack.pop(); + if (!item) break; + const [cx, cy] = item; + if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue; + if (mask[cy][cx]) continue; + const ci = (cy * width + cx) * 4; + if (colorDist(ci) > tolerance) continue; + mask[cy][cx] = true; + stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); + } + } else { + // Select all matching pixels + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const ci = (y * width + x) * 4; + if (colorDist(ci) <= tolerance) { + mask[y][x] = true; + } + } + } + } + + return mask; +} + +function maskToBounds( + mask: boolean[][], +): { x: number; y: number; width: number; height: number } | null { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let found = false; + + for (let y = 0; y < mask.length; y++) { + for (let x = 0; x < mask[y].length; x++) { + if (mask[y][x]) { + found = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (!found) return null; + return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 }; +} + +// --------------------------------------------------------------------------- +// Selection mask modification utilities +// --------------------------------------------------------------------------- + +export function expandMask(mask: boolean[][], amount: number): boolean[][] { + const h = mask.length; + const w = mask[0]?.length ?? 0; + const result: boolean[][] = Array.from({ length: h }, () => Array(w).fill(false) as boolean[]); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (!mask[y][x]) continue; + for (let dy = -amount; dy <= amount; dy++) { + for (let dx = -amount; dx <= amount; dx++) { + const ny = y + dy; + const nx = x + dx; + if (ny >= 0 && ny < h && nx >= 0 && nx < w) { + if (dx * dx + dy * dy <= amount * amount) { + result[ny][nx] = true; + } + } + } + } + } + } + return result; +} + +export function contractMask(mask: boolean[][], amount: number): boolean[][] { + const inverted = mask.map((row) => row.map((v) => !v)); + const expanded = expandMask(inverted, amount); + return expanded.map((row) => row.map((v) => !v)); +} + +export function featherMask(mask: boolean[][], radius: number): number[][] { + const h = mask.length; + const w = mask[0]?.length ?? 0; + const result: number[][] = Array.from({ length: h }, () => Array(w).fill(0) as number[]); + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (mask[y][x]) { + result[y][x] = 1; + continue; + } + // Distance to nearest mask pixel within radius + let minDist = radius + 1; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const ny = y + dy; + const nx = x + dx; + if (ny >= 0 && ny < h && nx >= 0 && nx < w && mask[ny][nx]) { + const d = Math.sqrt(dx * dx + dy * dy); + minDist = Math.min(minDist, d); + } + } + } + if (minDist <= radius) { + result[y][x] = 1 - minDist / radius; + } + } + } + return result; +} + +export function invertMask(mask: boolean[][]): boolean[][] { + return mask.map((row) => row.map((v) => !v)); +} + +/** Ray-casting point-in-polygon test */ +export function pointInPolygon(x: number, y: number, points: number[]): boolean { + let inside = false; + const n = points.length / 2; + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = points[i * 2]; + const yi = points[i * 2 + 1]; + const xj = points[j * 2]; + const yj = points[j * 2 + 1]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; +} + +// --------------------------------------------------------------------------- +// Hook: useSelectionTool +// --------------------------------------------------------------------------- + +export interface SelectionToolApi { + selectionType: SelectionType; + setSelectionType: (t: SelectionType) => void; + isDrawing: boolean; + currentPoints: number[]; + onMouseDown: (pos: { x: number; y: number }, stage?: Konva.Stage) => void; + onMouseMove: (pos: { x: number; y: number }) => void; + onMouseUp: () => void; + onDoubleClick: () => void; + selectAll: () => void; + deselect: () => void; + magicWandSelect: ( + stage: Konva.Stage, + x: number, + y: number, + tolerance: number, + contiguous: boolean, + ) => void; +} + +export function useSelectionTool(): SelectionToolApi { + const [selectionType, setSelectionType] = useState("rect"); + const [isDrawing, setIsDrawing] = useState(false); + const [currentPoints, setCurrentPoints] = useState([]); + const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + + const setSelection = useEditorStore((s) => s.setSelection); + const selectionMode = useEditorStore((s) => s.selectionMode); + const canvasSize = useEditorStore((s) => s.canvasSize); + const existingSelection = useEditorStore((s) => s.selection); + + const mergeSelection = useCallback( + (newSel: SelectionState, mode: SelectionMode) => { + if (mode === "new" || !existingSelection) { + setSelection(newSel); + return; + } + + const eb = existingSelection.bounds; + const nb = newSel.bounds; + + if (mode === "add") { + const x = Math.min(eb.x, nb.x); + const y = Math.min(eb.y, nb.y); + setSelection({ + ...newSel, + bounds: { + x, + y, + width: Math.max(eb.x + eb.width, nb.x + nb.width) - x, + height: Math.max(eb.y + eb.height, nb.y + nb.height) - y, + }, + }); + } else { + // subtract: use the new bounds minus overlap (simplified) + setSelection(newSel); + } + }, + [existingSelection, setSelection], + ); + + const onMouseDown = useCallback( + (pos: { x: number; y: number }, _stage?: Konva.Stage) => { + setIsDrawing(true); + startRef.current = pos; + if (selectionType === "lasso") { + setCurrentPoints([pos.x, pos.y]); + } else { + setCurrentPoints([]); + } + }, + [selectionType], + ); + + const onMouseMove = useCallback( + (pos: { x: number; y: number }) => { + if (!isDrawing) return; + + if (selectionType === "lasso") { + setCurrentPoints((prev) => [...prev, pos.x, pos.y]); + } else { + const s = startRef.current; + setCurrentPoints([s.x, s.y, pos.x, pos.y]); + } + }, + [isDrawing, selectionType], + ); + + const onMouseUp = useCallback(() => { + if (!isDrawing) return; + setIsDrawing(false); + + if (selectionType === "lasso") { + if (currentPoints.length < 6) { + setSelection(null); + setCurrentPoints([]); + return; + } + const xs = currentPoints.filter((_, i) => i % 2 === 0); + const ys = currentPoints.filter((_, i) => i % 2 === 1); + const bounds = { + x: Math.min(...xs), + y: Math.min(...ys), + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + }; + mergeSelection({ type: "lasso", points: currentPoints, bounds }, selectionMode); + } else { + if (currentPoints.length < 4) { + setCurrentPoints([]); + return; + } + const [x1, y1, x2, y2] = currentPoints; + const x = Math.min(x1, x2); + const y = Math.min(y1, y2); + const w = Math.abs(x2 - x1); + const h = Math.abs(y2 - y1); + if (w < 2 || h < 2) { + setSelection(null); + setCurrentPoints([]); + return; + } + mergeSelection( + { + type: selectionType, + points: [], + bounds: { x, y, width: w, height: h }, + }, + selectionMode, + ); + } + setCurrentPoints([]); + }, [isDrawing, currentPoints, selectionType, selectionMode, mergeSelection, setSelection]); + + const onDoubleClick = useCallback(() => { + // Close polygonal lasso + if (selectionType === "lasso" && currentPoints.length >= 6) { + setIsDrawing(false); + const xs = currentPoints.filter((_, i) => i % 2 === 0); + const ys = currentPoints.filter((_, i) => i % 2 === 1); + mergeSelection( + { + type: "lasso", + points: currentPoints, + bounds: { + x: Math.min(...xs), + y: Math.min(...ys), + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + }, + }, + selectionMode, + ); + setCurrentPoints([]); + } + }, [selectionType, currentPoints, selectionMode, mergeSelection]); + + const selectAll = useCallback(() => { + setSelection({ + type: "rect", + points: [], + bounds: { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height }, + }); + }, [canvasSize, setSelection]); + + const deselect = useCallback(() => { + setSelection(null); + }, [setSelection]); + + const magicWandSelect = useCallback( + (stage: Konva.Stage, x: number, y: number, tolerance: number, contiguous: boolean) => { + const canvas = stage.toCanvas(); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const mask = floodFillMask(imageData, x, y, tolerance, contiguous); + const bounds = maskToBounds(mask); + if (!bounds) return; + mergeSelection({ type: "rect", points: [], bounds }, selectionMode); + }, + [selectionMode, mergeSelection], + ); + + return { + selectionType, + setSelectionType, + isDrawing, + currentPoints, + onMouseDown, + onMouseMove, + onMouseUp, + onDoubleClick, + selectAll, + deselect, + magicWandSelect, + }; +} + +// --------------------------------------------------------------------------- +// SelectionOverlay -- renders selection outline with marching ants +// --------------------------------------------------------------------------- + +export function SelectionOverlay({ layerRef }: { layerRef: React.RefObject }) { + const selection = useEditorStore((s) => s.selection); + const dashOffset = useMarchingAnts(layerRef); + + if (!selection) return null; + + const { type, bounds, points } = selection; + + if (type === "lasso" && points.length >= 6) { + return ( + + + + + ); + } + + if (type === "ellipse") { + const rx = bounds.width / 2; + const ry = bounds.height / 2; + return ( + + + + + ); + } + + // Rectangular selection + return ( + + + + + ); +} + +// --------------------------------------------------------------------------- +// ActiveSelectionPreview -- rendered during drag to show selection shape +// --------------------------------------------------------------------------- + +export function ActiveSelectionPreview({ + type, + points, +}: { + type: SelectionType; + points: number[]; +}) { + if (type === "lasso" && points.length >= 4) { + return ( + + ); + } + + if (points.length < 4) return null; + + const [x1, y1, x2, y2] = points; + const x = Math.min(x1, x2); + const y = Math.min(y1, y2); + const w = Math.abs(x2 - x1); + const h = Math.abs(y2 - y1); + + if (type === "ellipse") { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/editor/tools/transform-tool.tsx b/apps/web/src/components/editor/tools/transform-tool.tsx new file mode 100644 index 00000000..9baa6586 --- /dev/null +++ b/apps/web/src/components/editor/tools/transform-tool.tsx @@ -0,0 +1,237 @@ +import type Konva from "konva"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Transformer } from "react-konva"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// Transform state -- position/size/rotation for the options bar +// --------------------------------------------------------------------------- + +export interface TransformValues { + x: number; + y: number; + width: number; + height: number; + rotation: number; +} + +// --------------------------------------------------------------------------- +// Hook: useTransformTool +// --------------------------------------------------------------------------- + +export interface TransformToolApi { + transformerRef: React.RefObject; + isTransforming: boolean; + values: TransformValues; + lockedAspect: boolean; + setLockedAspect: (v: boolean) => void; + activate: () => void; + applyTransform: () => void; + cancelTransform: () => void; + setValues: (v: Partial) => void; + flipHorizontal: () => void; + flipVertical: () => void; +} + +export function useTransformTool(): TransformToolApi { + const transformerRef = useRef(null); + const [isTransforming, setIsTransforming] = useState(false); + const [lockedAspect, setLockedAspect] = useState(false); + const [values, setValuesState] = useState({ + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + }); + const preTransformRef = useRef(null); + + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const objects = useEditorStore((s) => s.objects); + const updateObject = useEditorStore((s) => s.updateObject); + const setTool = useEditorStore((s) => s.setTool); + + // Read values from selected object(s) + useEffect(() => { + if (!isTransforming || selectedObjectIds.length === 0) return; + const obj = objects.find((o) => o.id === selectedObjectIds[0]); + if (!obj) return; + const v: TransformValues = { + x: (obj.attrs.x as number) ?? 0, + y: (obj.attrs.y as number) ?? 0, + width: (obj.attrs.width as number) ?? 0, + height: (obj.attrs.height as number) ?? 0, + rotation: (obj.attrs.rotation as number) ?? 0, + }; + setValuesState(v); + }, [isTransforming, selectedObjectIds, objects]); + + // Attach transformer + useEffect(() => { + const tr = transformerRef.current; + if (!tr || !isTransforming) return; + const stage = tr.getStage(); + if (!stage) return; + + const nodes = selectedObjectIds + .map((id) => stage.findOne(`#${id}`)) + .filter(Boolean) as Konva.Node[]; + tr.nodes(nodes); + tr.getLayer()?.batchDraw(); + }, [isTransforming, selectedObjectIds]); + + const activate = useCallback(() => { + if (selectedObjectIds.length === 0) return; + setIsTransforming(true); + // Store pre-transform state for cancel + const obj = objects.find((o) => o.id === selectedObjectIds[0]); + if (obj) { + preTransformRef.current = { + x: (obj.attrs.x as number) ?? 0, + y: (obj.attrs.y as number) ?? 0, + width: (obj.attrs.width as number) ?? 0, + height: (obj.attrs.height as number) ?? 0, + rotation: (obj.attrs.rotation as number) ?? 0, + }; + } + }, [selectedObjectIds, objects]); + + const applyTransform = useCallback(() => { + setIsTransforming(false); + preTransformRef.current = null; + setTool("move"); + }, [setTool]); + + const cancelTransform = useCallback(() => { + // Restore pre-transform state + if (preTransformRef.current && selectedObjectIds.length > 0) { + const prev = preTransformRef.current; + for (const id of selectedObjectIds) { + updateObject(id, { + x: prev.x, + y: prev.y, + width: prev.width, + height: prev.height, + rotation: prev.rotation, + }); + } + } + setIsTransforming(false); + preTransformRef.current = null; + setTool("move"); + }, [selectedObjectIds, updateObject, setTool]); + + const setValues = useCallback( + (v: Partial) => { + setValuesState((prev) => { + const next = { ...prev, ...v }; + + // If aspect is locked, derive height from width ratio or vice versa + if (lockedAspect && prev.width > 0 && prev.height > 0) { + const ratio = prev.width / prev.height; + if (v.width !== undefined && v.height === undefined) { + next.height = next.width / ratio; + } else if (v.height !== undefined && v.width === undefined) { + next.width = next.height * ratio; + } + } + + // Apply to selected objects + for (const id of selectedObjectIds) { + updateObject(id, { + x: next.x, + y: next.y, + width: next.width, + height: next.height, + rotation: next.rotation, + }); + } + + return next; + }); + }, + [selectedObjectIds, updateObject, lockedAspect], + ); + + const flipHorizontal = useCallback(() => { + for (const id of selectedObjectIds) { + const obj = objects.find((o) => o.id === id); + if (!obj) continue; + const currentScale = (obj.attrs.scaleX as number) ?? 1; + updateObject(id, { scaleX: -currentScale }); + } + }, [selectedObjectIds, objects, updateObject]); + + const flipVertical = useCallback(() => { + for (const id of selectedObjectIds) { + const obj = objects.find((o) => o.id === id); + if (!obj) continue; + const currentScale = (obj.attrs.scaleY as number) ?? 1; + updateObject(id, { scaleY: -currentScale }); + } + }, [selectedObjectIds, objects, updateObject]); + + return { + transformerRef, + isTransforming, + values, + lockedAspect, + setLockedAspect, + activate, + applyTransform, + cancelTransform, + setValues, + flipHorizontal, + flipVertical, + }; +} + +// --------------------------------------------------------------------------- +// TransformToolTransformer -- Konva Transformer for free transform mode +// --------------------------------------------------------------------------- + +export function TransformToolTransformer({ + transformerRef, + onTransformEnd, +}: { + transformerRef: React.RefObject; + onTransformEnd?: (e: Konva.KonvaEventObject) => void; +}) { + const handleTransformEnd = useCallback( + (e: Konva.KonvaEventObject) => { + const node = e.target; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + const newW = Math.max(1, node.width() * scaleX); + const newH = Math.max(1, node.height() * scaleY); + node.scaleX(1); + node.scaleY(1); + node.width(newW); + node.height(newH); + onTransformEnd?.(e); + }, + [onTransformEnd], + ); + + return ( + + ); +} diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts new file mode 100644 index 00000000..e4e958f8 --- /dev/null +++ b/apps/web/src/stores/editor-store.ts @@ -0,0 +1,706 @@ +import { create } from "zustand"; +import { generateId } from "@/lib/utils"; +import type { + AdjustmentValues, + AnchorPosition, + CropState, + EditorLayer, + EditorState, + GuideOrientation, + SelectionMode, + SelectionState, + ToolType, +} from "@/types/editor"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createDefaultLayer(name = "Layer 1"): EditorLayer { + return { + id: generateId(), + name, + visible: true, + locked: false, + opacity: 1, + blendMode: "source-over", + thumbnail: null, + }; +} + +const DEFAULT_ADJUSTMENTS: AdjustmentValues = { + brightness: 0, + contrast: 0, + hue: 0, + saturation: 0, + luminance: 0, + exposure: 0, + vibrance: 0, + warmth: 0, +}; + +function nextLayerName(layers: EditorLayer[]): string { + const max = layers.reduce((n, l) => { + const m = l.name.match(/^Layer (\d+)/); + return m ? Math.max(n, Number(m[1])) : n; + }, 0); + return `Layer ${max + 1}`; +} + +// --------------------------------------------------------------------------- +// Anchor offset calculation for canvas resize +// --------------------------------------------------------------------------- + +function anchorOffset( + anchor: AnchorPosition, + oldW: number, + oldH: number, + newW: number, + newH: number, +): { dx: number; dy: number } { + const dw = newW - oldW; + const dh = newH - oldH; + let dx = 0; + let dy = 0; + + if (anchor.includes("center") && !anchor.includes("left") && !anchor.includes("right")) { + dx = dw / 2; + } else if (anchor.includes("right")) { + dx = dw; + } + + if (anchor === "center" || anchor === "center-left" || anchor === "center-right") { + dy = dh / 2; + } else if (anchor.startsWith("bottom")) { + dy = dh; + } + + return { dx, dy }; +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +const defaultLayer = createDefaultLayer(); + +export const useEditorStore = create()((set, get) => ({ + // Canvas + canvasSize: { width: 1920, height: 1080 }, + zoom: 1, + panOffset: { x: 0, y: 0 }, + + // Image + sourceImageUrl: null, + sourceImageSize: null, + + // Active tool + activeTool: "move", + previousTool: null, + + // Brush/Eraser + brushSize: 12, + brushOpacity: 1, + brushHardness: 1, + + // Colors + foregroundColor: "#000000", + backgroundColor: "#ffffff", + recentColors: [], + + // Layers + layers: [defaultLayer], + activeLayerId: defaultLayer.id, + + // Objects + objects: [], + selectedObjectIds: [], + clipboard: [], + + // Selection + selection: null, + selectionMode: "new" as SelectionMode, + + // Crop + cropState: null, + isCropping: false, + + // Adjustments & Filters + adjustments: { ...DEFAULT_ADJUSTMENTS }, + filters: [], + + // Text + editingTextId: null, + + // Shape settings + shapeFill: "#3b82f6", + shapeStroke: "#000000", + shapeStrokeWidth: 2, + shapeCornerRadius: 0, + shapePolygonSides: 6, + shapeStarPoints: 5, + + // Guides + guides: [], + showRulers: true, + showGuides: true, + snapToGuides: true, + + // UI + rightPanelTab: "layers", + rightPanelVisible: true, + + // Document + isDirty: false, + + // History + lastAction: "", + + // ----------------------------------------------------------------------- + // Actions + // ----------------------------------------------------------------------- + + setTool: (tool: ToolType) => set((s) => ({ activeTool: tool, previousTool: s.activeTool })), + + setZoom: (zoom: number) => set({ zoom: Math.max(0.01, Math.min(64, zoom)) }), + + setPanOffset: (offset) => set({ panOffset: offset }), + + loadImage: (url, width, height) => + set({ + sourceImageUrl: url, + sourceImageSize: { width, height }, + canvasSize: { width, height }, + zoom: 1, + panOffset: { x: 0, y: 0 }, + isDirty: false, + }), + + // -- Colors -- + + setForegroundColor: (color) => + set((s) => { + const recent = [color, ...s.recentColors.filter((c) => c !== color)].slice(0, 12); + return { foregroundColor: color, recentColors: recent }; + }), + + setBackgroundColor: (color) => set({ backgroundColor: color }), + + swapColors: () => + set((s) => ({ + foregroundColor: s.backgroundColor, + backgroundColor: s.foregroundColor, + })), + + resetColors: () => set({ foregroundColor: "#000000", backgroundColor: "#ffffff" }), + + // -- Objects -- + + addObject: (obj) => + set((s) => ({ + objects: [...s.objects, { ...obj, layerId: s.activeLayerId }], + isDirty: true, + lastAction: "Add Object", + })), + + updateObject: (id, attrs) => + set((s) => ({ + objects: s.objects.map((o) => (o.id === id ? { ...o, attrs: { ...o.attrs, ...attrs } } : o)), + isDirty: true, + })), + + removeObjects: (ids) => + set((s) => ({ + objects: s.objects.filter((o) => !ids.includes(o.id)), + selectedObjectIds: s.selectedObjectIds.filter((i) => !ids.includes(i)), + isDirty: true, + lastAction: "Delete Object", + })), + + setSelectedObjects: (ids) => set({ selectedObjectIds: ids }), + + duplicateObjects: (ids) => { + const s = get(); + const dupes = s.objects + .filter((o) => ids.includes(o.id)) + .map((o) => ({ + ...o, + id: generateId(), + attrs: { + ...o.attrs, + x: ((o.attrs.x as number) ?? 0) + 10, + y: ((o.attrs.y as number) ?? 0) + 10, + }, + })); + set({ + objects: [...s.objects, ...dupes], + selectedObjectIds: dupes.map((d) => d.id), + isDirty: true, + lastAction: "Duplicate", + }); + }, + + // -- Z-ordering -- + + bringToFront: (id) => + set((s) => { + const obj = s.objects.find((o) => o.id === id); + if (!obj) return s; + const layerObjs = s.objects.filter((o) => o.layerId === obj.layerId && o.id !== id); + const others = s.objects.filter((o) => o.layerId !== obj.layerId); + return { + objects: [...others, ...layerObjs, obj], + isDirty: true, + lastAction: "Bring to Front", + }; + }), + + bringForward: (id) => + set((s) => { + const idx = s.objects.findIndex((o) => o.id === id); + if (idx === -1) return s; + const obj = s.objects[idx]; + // Find the next object in the same layer + let nextIdx = -1; + for (let i = idx + 1; i < s.objects.length; i++) { + if (s.objects[i].layerId === obj.layerId) { + nextIdx = i; + break; + } + } + if (nextIdx === -1) return s; + const copy = [...s.objects]; + copy.splice(idx, 1); + copy.splice(nextIdx, 0, obj); + return { objects: copy, isDirty: true, lastAction: "Bring Forward" }; + }), + + sendBackward: (id) => + set((s) => { + const idx = s.objects.findIndex((o) => o.id === id); + if (idx === -1) return s; + const obj = s.objects[idx]; + let prevIdx = -1; + for (let i = idx - 1; i >= 0; i--) { + if (s.objects[i].layerId === obj.layerId) { + prevIdx = i; + break; + } + } + if (prevIdx === -1) return s; + const copy = [...s.objects]; + copy.splice(idx, 1); + copy.splice(prevIdx, 0, obj); + return { objects: copy, isDirty: true, lastAction: "Send Backward" }; + }), + + sendToBack: (id) => + set((s) => { + const obj = s.objects.find((o) => o.id === id); + if (!obj) return s; + const layerObjs = s.objects.filter((o) => o.layerId === obj.layerId && o.id !== id); + const others = s.objects.filter((o) => o.layerId !== obj.layerId); + return { + objects: [...others, obj, ...layerObjs], + isDirty: true, + lastAction: "Send to Back", + }; + }), + + // -- Clipboard -- + + copyObjects: (ids) => { + const s = get(); + const copied = s.objects.filter((o) => ids.includes(o.id)); + set({ clipboard: copied }); + }, + + cutObjects: (ids) => { + const s = get(); + const copied = s.objects.filter((o) => ids.includes(o.id)); + set({ + clipboard: copied, + objects: s.objects.filter((o) => !ids.includes(o.id)), + selectedObjectIds: [], + isDirty: true, + lastAction: "Cut", + }); + }, + + pasteObjects: () => { + const s = get(); + if (s.clipboard.length === 0) return; + const pasted = s.clipboard.map((o) => ({ + ...o, + id: generateId(), + layerId: s.activeLayerId, + attrs: { + ...o.attrs, + x: ((o.attrs.x as number) ?? 0) + 10, + y: ((o.attrs.y as number) ?? 0) + 10, + }, + })); + set({ + objects: [...s.objects, ...pasted], + selectedObjectIds: pasted.map((p) => p.id), + isDirty: true, + lastAction: "Paste", + }); + }, + + // -- Layers -- + + addLayer: () => { + const s = get(); + const layer = createDefaultLayer(nextLayerName(s.layers)); + const idx = s.layers.findIndex((l) => l.id === s.activeLayerId); + const copy = [...s.layers]; + copy.splice(idx + 1, 0, layer); + set({ + layers: copy, + activeLayerId: layer.id, + isDirty: true, + lastAction: "Add Layer", + }); + }, + + removeLayer: (id) => + set((s) => { + if (s.layers.length <= 1) return s; + const idx = s.layers.findIndex((l) => l.id === id); + const remaining = s.layers.filter((l) => l.id !== id); + const newActive = + s.activeLayerId === id + ? remaining[Math.min(idx, remaining.length - 1)].id + : s.activeLayerId; + return { + layers: remaining, + objects: s.objects.filter((o) => o.layerId !== id), + activeLayerId: newActive, + isDirty: true, + lastAction: "Delete Layer", + }; + }), + + duplicateLayer: (id) => { + const s = get(); + const src = s.layers.find((l) => l.id === id); + if (!src) return; + const newId = generateId(); + const dup: EditorLayer = { + ...src, + id: newId, + name: `${src.name} (copy)`, + thumbnail: null, + }; + const dupeObjs = s.objects + .filter((o) => o.layerId === id) + .map((o) => ({ ...o, id: generateId(), layerId: newId })); + const idx = s.layers.findIndex((l) => l.id === id); + const copy = [...s.layers]; + copy.splice(idx + 1, 0, dup); + set({ + layers: copy, + objects: [...s.objects, ...dupeObjs], + activeLayerId: newId, + isDirty: true, + lastAction: "Duplicate Layer", + }); + }, + + setActiveLayer: (id) => set({ activeLayerId: id }), + + updateLayer: (id, updates) => + set((s) => ({ + layers: s.layers.map((l) => (l.id === id ? { ...l, ...updates } : l)), + isDirty: true, + })), + + reorderLayers: (fromIndex, toIndex) => + set((s) => { + const copy = [...s.layers]; + const [moved] = copy.splice(fromIndex, 1); + copy.splice(toIndex, 0, moved); + return { layers: copy, isDirty: true, lastAction: "Reorder Layers" }; + }), + + mergeDown: (id) => + set((s) => { + const idx = s.layers.findIndex((l) => l.id === id); + if (idx <= 0) return s; + const below = s.layers[idx - 1]; + const merged = s.objects.map((o) => (o.layerId === id ? { ...o, layerId: below.id } : o)); + return { + layers: s.layers.filter((l) => l.id !== id), + objects: merged, + activeLayerId: below.id, + isDirty: true, + lastAction: "Merge Down", + }; + }), + + flattenAll: () => + set((s) => { + const first = s.layers[0]; + if (!first) return s; + return { + layers: [{ ...first, name: "Layer 1" }], + objects: s.objects.map((o) => ({ ...o, layerId: first.id })), + activeLayerId: first.id, + isDirty: true, + lastAction: "Flatten All", + }; + }), + + // -- Adjustments & Filters -- + + setAdjustment: (key, value) => + set((s) => ({ + adjustments: { ...s.adjustments, [key]: value }, + isDirty: true, + })), + + resetAdjustments: () => set({ adjustments: { ...DEFAULT_ADJUSTMENTS } }), + + toggleFilter: (type) => + set((s) => { + const existing = s.filters.find((f) => f.type === type); + if (existing) { + return { + filters: s.filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)), + isDirty: true, + }; + } + return { + filters: [...s.filters, { type, enabled: true, params: {} }], + isDirty: true, + }; + }), + + setFilterParam: (type, key, value) => + set((s) => ({ + filters: s.filters.map((f) => + f.type === type ? { ...f, params: { ...f.params, [key]: value } } : f, + ), + isDirty: true, + })), + + // -- Selection -- + + setSelection: (selection: SelectionState | null) => set({ selection }), + + setSelectionMode: (mode: SelectionMode) => set({ selectionMode: mode }), + + invertSelection: () => + set((s) => { + if (!s.selection) return s; + // Invert: swap to full canvas bounds minus current selection + return { + selection: { + ...s.selection, + bounds: { + x: 0, + y: 0, + width: s.canvasSize.width, + height: s.canvasSize.height, + }, + }, + isDirty: true, + }; + }), + + // -- Crop -- + + setCropState: (state: CropState | null) => set({ cropState: state, isCropping: state !== null }), + + applyCrop: () => + set((s) => { + if (!s.cropState) return s; + const { x, y, width, height } = s.cropState; + return { + canvasSize: { width, height }, + objects: s.objects.map((o) => ({ + ...o, + attrs: { + ...o.attrs, + x: ((o.attrs.x as number) ?? 0) - x, + y: ((o.attrs.y as number) ?? 0) - y, + }, + })), + cropState: null, + isCropping: false, + isDirty: true, + lastAction: "Crop", + }; + }), + + // -- Brush -- + + setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(500, size)) }), + setBrushOpacity: (opacity) => set({ brushOpacity: Math.max(0, Math.min(1, opacity)) }), + setBrushHardness: (hardness) => set({ brushHardness: Math.max(0, Math.min(1, hardness)) }), + + // -- Guides -- + + addGuide: (orientation: GuideOrientation, position: number) => + set((s) => ({ + guides: [...s.guides, { id: generateId(), orientation, position }], + })), + + removeGuide: (id) => set((s) => ({ guides: s.guides.filter((g) => g.id !== id) })), + + updateGuide: (id, position) => + set((s) => ({ + guides: s.guides.map((g) => (g.id === id ? { ...g, position } : g)), + })), + + toggleRulers: () => set((s) => ({ showRulers: !s.showRulers })), + toggleGuides: () => set((s) => ({ showGuides: !s.showGuides })), + toggleSnapping: () => set((s) => ({ snapToGuides: !s.snapToGuides })), + + // -- Document operations -- + + resizeCanvas: (width, height, anchor, _fill) => + set((s) => { + const { dx, dy } = anchorOffset( + anchor, + s.canvasSize.width, + s.canvasSize.height, + width, + height, + ); + return { + canvasSize: { width, height }, + objects: s.objects.map((o) => ({ + ...o, + attrs: { + ...o.attrs, + x: ((o.attrs.x as number) ?? 0) + dx, + y: ((o.attrs.y as number) ?? 0) + dy, + }, + })), + isDirty: true, + lastAction: "Resize Canvas", + }; + }), + + resizeImage: (width, height) => + set((s) => { + const sx = width / s.canvasSize.width; + const sy = height / s.canvasSize.height; + return { + canvasSize: { width, height }, + objects: s.objects.map((o) => ({ + ...o, + attrs: { + ...o.attrs, + x: ((o.attrs.x as number) ?? 0) * sx, + y: ((o.attrs.y as number) ?? 0) * sy, + width: o.attrs.width ? (o.attrs.width as number) * sx : undefined, + height: o.attrs.height ? (o.attrs.height as number) * sy : undefined, + }, + })), + isDirty: true, + lastAction: "Resize Image", + }; + }), + + rotateCanvas: (degrees) => + set((s) => { + const { width: w, height: h } = s.canvasSize; + const swap = degrees === 90 || degrees === 270; + const nw = swap ? h : w; + const nh = swap ? w : h; + return { + canvasSize: { width: nw, height: nh }, + objects: s.objects.map((o) => { + const ox = (o.attrs.x as number) ?? 0; + const oy = (o.attrs.y as number) ?? 0; + let nx: number; + let ny: number; + if (degrees === 90) { + nx = h - oy; + ny = ox; + } else if (degrees === 180) { + nx = w - ox; + ny = h - oy; + } else { + nx = oy; + ny = w - ox; + } + return { ...o, attrs: { ...o.attrs, x: nx, y: ny } }; + }), + isDirty: true, + lastAction: "Rotate Canvas", + }; + }), + + flipCanvasHorizontal: () => + set((s) => ({ + objects: s.objects.map((o) => ({ + ...o, + attrs: { + ...o.attrs, + x: s.canvasSize.width - ((o.attrs.x as number) ?? 0), + scaleX: -((o.attrs.scaleX as number) ?? 1), + }, + })), + isDirty: true, + lastAction: "Flip Horizontal", + })), + + flipCanvasVertical: () => + set((s) => ({ + objects: s.objects.map((o) => ({ + ...o, + attrs: { + ...o.attrs, + y: s.canvasSize.height - ((o.attrs.y as number) ?? 0), + scaleY: -((o.attrs.scaleY as number) ?? 1), + }, + })), + isDirty: true, + lastAction: "Flip Vertical", + })), + + trimCanvas: () => + set((s) => { + if (s.objects.length === 0) return s; + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const o of s.objects) { + const x = (o.attrs.x as number) ?? 0; + const y = (o.attrs.y as number) ?? 0; + const w = (o.attrs.width as number) ?? 0; + const h = (o.attrs.height as number) ?? 0; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + w); + maxY = Math.max(maxY, y + h); + } + const tw = Math.max(1, maxX - minX); + const th = Math.max(1, maxY - minY); + return { + canvasSize: { width: tw, height: th }, + objects: s.objects.map((o) => ({ + ...o, + attrs: { + ...o.attrs, + x: ((o.attrs.x as number) ?? 0) - minX, + y: ((o.attrs.y as number) ?? 0) - minY, + }, + })), + isDirty: true, + lastAction: "Trim Canvas", + }; + }), + + // -- UI -- + + setRightPanelTab: (tab) => set({ rightPanelTab: tab }), + toggleRightPanel: () => set((s) => ({ rightPanelVisible: !s.rightPanelVisible })), + markDirty: () => set({ isDirty: true }), + markClean: () => set({ isDirty: false }), +})); diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts new file mode 100644 index 00000000..72e51e8e --- /dev/null +++ b/apps/web/src/types/editor.ts @@ -0,0 +1,279 @@ +// --------------------------------------------------------------------------- +// Editor Types -- shared across all editor components and the Zustand store +// --------------------------------------------------------------------------- + +export type ToolType = + | "move" + | "marquee-rect" + | "marquee-ellipse" + | "lasso-free" + | "lasso-poly" + | "magic-wand" + | "crop" + | "eyedropper" + | "brush" + | "eraser" + | "pencil" + | "fill" + | "gradient" + | "shape-rect" + | "shape-ellipse" + | "shape-line" + | "shape-arrow" + | "shape-polygon" + | "shape-star" + | "text" + | "hand" + | "zoom" + | "transform"; + +export type CanvasObjectType = + | "line" + | "rect" + | "ellipse" + | "polygon" + | "star" + | "arrow" + | "text" + | "image" + | "path"; + +export interface CanvasObject { + id: string; + type: CanvasObjectType; + layerId: string; + attrs: Record; +} + +export interface EditorLayer { + id: string; + name: string; + visible: boolean; + locked: boolean; + opacity: number; + blendMode: string; + thumbnail: string | null; +} + +export type SelectionType = "rect" | "ellipse" | "lasso"; + +export interface SelectionState { + type: SelectionType; + points: number[]; + bounds: { x: number; y: number; width: number; height: number }; +} + +export type SelectionMode = "new" | "add" | "subtract"; + +export interface CropState { + x: number; + y: number; + width: number; + height: number; + aspectRatio: string | null; +} + +export interface AdjustmentValues { + brightness: number; + contrast: number; + hue: number; + saturation: number; + luminance: number; + exposure: number; + vibrance: number; + warmth: number; +} + +export interface FilterConfig { + type: string; + enabled: boolean; + params: Record; +} + +export type GuideOrientation = "horizontal" | "vertical"; + +export interface Guide { + id: string; + orientation: GuideOrientation; + position: number; +} + +export type AnchorPosition = + | "top-left" + | "top-center" + | "top-right" + | "center-left" + | "center" + | "center-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + +export type ResampleMethod = "nearest" | "bilinear" | "bicubic" | "lanczos"; + +export interface SmartGuide { + orientation: GuideOrientation; + position: number; + type: "edge" | "center" | "canvas"; +} + +export interface EditorState { + // Canvas + canvasSize: { width: number; height: number }; + zoom: number; + panOffset: { x: number; y: number }; + + // Image + sourceImageUrl: string | null; + sourceImageSize: { width: number; height: number } | null; + + // Active tool + activeTool: ToolType; + previousTool: ToolType | null; + + // Brush/Eraser settings + brushSize: number; + brushOpacity: number; + brushHardness: number; + + // Colors + foregroundColor: string; + backgroundColor: string; + recentColors: string[]; + + // Layers + layers: EditorLayer[]; + activeLayerId: string; + + // Objects + objects: CanvasObject[]; + selectedObjectIds: string[]; + clipboard: CanvasObject[]; + + // Selection + selection: SelectionState | null; + selectionMode: SelectionMode; + + // Crop + cropState: CropState | null; + isCropping: boolean; + + // Adjustments & Filters + adjustments: AdjustmentValues; + filters: FilterConfig[]; + + // Text + editingTextId: string | null; + + // Shape settings + shapeFill: string; + shapeStroke: string; + shapeStrokeWidth: number; + shapeCornerRadius: number; + shapePolygonSides: number; + shapeStarPoints: number; + + // Guides + guides: Guide[]; + showRulers: boolean; + showGuides: boolean; + snapToGuides: boolean; + + // UI + rightPanelTab: "layers" | "adjustments" | "history"; + rightPanelVisible: boolean; + + // Document + isDirty: boolean; + + // History tracking + lastAction: string; + + // ----------------------------------------------------------------------- + // Actions + // ----------------------------------------------------------------------- + + // Tool + setTool: (tool: ToolType) => void; + + // Canvas + setZoom: (zoom: number) => void; + setPanOffset: (offset: { x: number; y: number }) => void; + loadImage: (url: string, width: number, height: number) => void; + + // Colors + setForegroundColor: (color: string) => void; + setBackgroundColor: (color: string) => void; + swapColors: () => void; + resetColors: () => void; + + // Objects + addObject: (obj: CanvasObject) => void; + updateObject: (id: string, attrs: Partial) => void; + removeObjects: (ids: string[]) => void; + setSelectedObjects: (ids: string[]) => void; + duplicateObjects: (ids: string[]) => void; + + // Z-ordering + bringToFront: (id: string) => void; + bringForward: (id: string) => void; + sendBackward: (id: string) => void; + sendToBack: (id: string) => void; + + // Clipboard + copyObjects: (ids: string[]) => void; + cutObjects: (ids: string[]) => void; + pasteObjects: () => void; + + // Layers + addLayer: () => void; + removeLayer: (id: string) => void; + duplicateLayer: (id: string) => void; + setActiveLayer: (id: string) => void; + updateLayer: (id: string, updates: Partial) => void; + reorderLayers: (fromIndex: number, toIndex: number) => void; + mergeDown: (id: string) => void; + flattenAll: () => void; + + // Adjustments & Filters + setAdjustment: (key: keyof AdjustmentValues, value: number) => void; + resetAdjustments: () => void; + toggleFilter: (type: string) => void; + setFilterParam: (type: string, key: string, value: number) => void; + + // Selection + setSelection: (selection: SelectionState | null) => void; + setSelectionMode: (mode: SelectionMode) => void; + invertSelection: () => void; + + // Crop + setCropState: (state: CropState | null) => void; + applyCrop: () => void; + + // Brush + setBrushSize: (size: number) => void; + setBrushOpacity: (opacity: number) => void; + setBrushHardness: (hardness: number) => void; + + // Guides + addGuide: (orientation: GuideOrientation, position: number) => void; + removeGuide: (id: string) => void; + updateGuide: (id: string, position: number) => void; + toggleRulers: () => void; + toggleGuides: () => void; + toggleSnapping: () => void; + + // Document operations + resizeCanvas: (width: number, height: number, anchor: AnchorPosition, fill: string) => void; + resizeImage: (width: number, height: number) => void; + rotateCanvas: (degrees: 90 | 180 | 270) => void; + flipCanvasHorizontal: () => void; + flipCanvasVertical: () => void; + trimCanvas: () => void; + + // UI + setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void; + toggleRightPanel: () => void; + markDirty: () => void; + markClean: () => void; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6e03bf5..5293135c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,9 @@ importers: jszip: specifier: ^3.10.1 version: 3.10.1 + konva: + specifier: ^10 + version: 10.3.0 leaflet: specifier: ^1.9.4 version: 1.9.4 @@ -296,9 +299,15 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + react-hotkeys-hook: + specifier: ^5.3.2 + version: 5.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-image-crop: specifier: ^11.0.10 version: 11.0.10(react@19.2.4) + react-konva: + specifier: ^19 + version: 19.2.3(@types/react@19.2.14)(konva@10.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-router-dom: specifier: ^7.1.0 version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -308,6 +317,9 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.1 + use-image: + specifier: ^1.1.4 + version: 1.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) zustand: specifier: ^5.0.0 version: 5.0.12(@types/react@19.2.14)(react@19.2.4) @@ -3400,6 +3412,16 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + + '@types/react-reconciler@0.33.0': + resolution: {integrity: sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==} + peerDependencies: + '@types/react': '*' + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -4852,6 +4874,11 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -4944,6 +4971,9 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + konva@10.3.0: + resolution: {integrity: sha512-gt19K2gzY4lHbnkvsku7eSmB+A9PTS2jG4F9coBMsdjM1UKfJNxJbDbXVpeCW1wjEGRwBD3nBamcHnqJhAeKlg==} + lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} @@ -5889,6 +5919,12 @@ packages: peerDependencies: react: ^19.2.4 + react-hotkeys-hook@5.3.2: + resolution: {integrity: sha512-DDDy9xK6mbTQ6aPlQvIl0dA/a90T/AWml4Rm21JXFDLlRHalIg4/Rv3equUQYs5xPTWq+oEl6RD7mi/nBpU3Uw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-image-crop@11.0.10: resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} peerDependencies: @@ -5897,6 +5933,19 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-konva@19.2.3: + resolution: {integrity: sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==} + peerDependencies: + konva: ^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0 + react: ^19.2.0 + react-dom: ^19.2.0 + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -6608,6 +6657,12 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + use-image@1.1.4: + resolution: {integrity: sha512-P+swhszzHHgEb2X2yQ+vQNPCq/8Ks3hyfdXAVN133pvnvK7UK++bUaZUa5E+A3S02Mw8xOCBr9O6CLhk2fjrWA==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + utif@2.0.1: resolution: {integrity: sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==} @@ -9771,6 +9826,14 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-reconciler@0.33.0(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -11265,6 +11328,13 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.4 + transitivePeerDependencies: + - '@types/react' + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -11377,6 +11447,8 @@ snapshots: kind-of@6.0.3: {} + konva@10.3.0: {} + lazystream@1.0.1: dependencies: readable-stream: 2.3.8 @@ -12334,12 +12406,34 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-hotkeys-hook@5.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-image-crop@11.0.10(react@19.2.4): dependencies: react: 19.2.4 react-is@17.0.2: {} + react-konva@19.2.3(@types/react@19.2.14)(konva@10.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@types/react-reconciler': 0.33.0(@types/react@19.2.14) + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.4) + konva: 10.3.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-reconciler: 0.33.0(react@19.2.4) + scheduler: 0.27.0 + transitivePeerDependencies: + - '@types/react' + + react-reconciler@0.33.0(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + react-refresh@0.17.0: {} react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -13159,6 +13253,11 @@ snapshots: url-join@5.0.0: {} + use-image@1.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + utif@2.0.1: dependencies: pako: 1.0.11 From f0941e4aeca075540be7166206c3eaf39d6c5147 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:37:31 +0800 Subject: [PATCH 12/65] fix: resolve type errors in selection/crop/transform tools --- .../editor/common/canvas-resize-dialog.tsx | 4 +-- .../components/editor/common/context-menu.tsx | 14 ++++---- .../components/editor/common/guide-lines.tsx | 2 +- .../editor/common/image-resize-dialog.tsx | 3 +- .../src/components/editor/common/rulers.tsx | 4 +-- .../components/editor/common/smart-guides.tsx | 8 +++-- .../editor/options/move-options.tsx | 7 +++- .../editor/options/selection-options.tsx | 19 ++++++----- .../src/components/editor/tools/move-tool.tsx | 9 +++--- .../editor/tools/selection-tool.tsx | 8 +++-- .../editor/tools/transform-tool.tsx | 32 +++++++++++-------- 11 files changed, 65 insertions(+), 45 deletions(-) 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 3deac2a6..07958b89 100644 --- a/apps/web/src/components/editor/common/canvas-resize-dialog.tsx +++ b/apps/web/src/components/editor/common/canvas-resize-dialog.tsx @@ -30,9 +30,9 @@ export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: const [fill, setFill] = useState("#ffffff"); const handleApply = useCallback(() => { - resizeCanvas(width, height, anchor, fill); + resizeCanvas(width, height, anchor); onClose(); - }, [width, height, anchor, fill, resizeCanvas, onClose]); + }, [width, height, anchor, resizeCanvas, onClose]); if (!open) return null; diff --git a/apps/web/src/components/editor/common/context-menu.tsx b/apps/web/src/components/editor/common/context-menu.tsx index ca8d599a..1370ac71 100644 --- a/apps/web/src/components/editor/common/context-menu.tsx +++ b/apps/web/src/components/editor/common/context-menu.tsx @@ -73,7 +73,8 @@ export function ContextMenu({ const cutObjects = useEditorStore((s) => s.cutObjects); const pasteObjects = useEditorStore((s) => s.pasteObjects); const removeObjects = useEditorStore((s) => s.removeObjects); - const duplicateObjects = useEditorStore((s) => s.duplicateObjects); + const copyObjectsFn = useEditorStore((s) => s.copyObjects); + const pasteObjectsFn = useEditorStore((s) => s.pasteObjects); const bringToFront = useEditorStore((s) => s.bringToFront); const bringForward = useEditorStore((s) => s.bringForward); const sendBackward = useEditorStore((s) => s.sendBackward); @@ -106,7 +107,7 @@ export function ContextMenu({ icon: Scissors, shortcut: "Ctrl+X", action: () => { - cutObjects(selectedObjectIds); + cutObjects(); onClose(); }, }, @@ -115,7 +116,7 @@ export function ContextMenu({ icon: Copy, shortcut: "Ctrl+C", action: () => { - copyObjects(selectedObjectIds); + copyObjects(); onClose(); }, }, @@ -127,14 +128,15 @@ export function ContextMenu({ pasteObjects(); onClose(); }, - disabled: clipboard.length === 0, + disabled: !clipboard || clipboard.length === 0, }, { label: "Duplicate", icon: CopyPlus, shortcut: "Ctrl+D", action: () => { - duplicateObjects(selectedObjectIds); + copyObjectsFn(); + pasteObjectsFn(); onClose(); }, dividerAfter: true, @@ -192,7 +194,7 @@ export function ContextMenu({ pasteObjects(); onClose(); }, - disabled: clipboard.length === 0, + disabled: !clipboard || clipboard.length === 0, }, { label: "Select All", diff --git a/apps/web/src/components/editor/common/guide-lines.tsx b/apps/web/src/components/editor/common/guide-lines.tsx index 39b47442..c87fb5c9 100644 --- a/apps/web/src/components/editor/common/guide-lines.tsx +++ b/apps/web/src/components/editor/common/guide-lines.tsx @@ -12,7 +12,7 @@ const GUIDE_WIDTH = 1; export function GuideLines() { const guides = useEditorStore((s) => s.guides); - const showGuides = useEditorStore((s) => s.showGuides); + const showGuides = useEditorStore((s) => s.guidesVisible); const canvasSize = useEditorStore((s) => s.canvasSize); const updateGuide = useEditorStore((s) => s.updateGuide); const removeGuide = useEditorStore((s) => s.removeGuide); diff --git a/apps/web/src/components/editor/common/image-resize-dialog.tsx b/apps/web/src/components/editor/common/image-resize-dialog.tsx index 980a1d99..6465b8b3 100644 --- a/apps/web/src/components/editor/common/image-resize-dialog.tsx +++ b/apps/web/src/components/editor/common/image-resize-dialog.tsx @@ -2,7 +2,8 @@ import { Lock, Unlock, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; -import type { ResampleMethod } from "@/types/editor"; + +type ResampleMethod = "nearest" | "bilinear" | "bicubic" | "lanczos"; // --------------------------------------------------------------------------- // ImageResizeDialog -- modal with W/H, aspect lock, resampling method diff --git a/apps/web/src/components/editor/common/rulers.tsx b/apps/web/src/components/editor/common/rulers.tsx index 6be03c1b..cd4fc4df 100644 --- a/apps/web/src/components/editor/common/rulers.tsx +++ b/apps/web/src/components/editor/common/rulers.tsx @@ -34,7 +34,7 @@ export function HorizontalRuler() { const zoom = useEditorStore((s) => s.zoom); const panOffset = useEditorStore((s) => s.panOffset); const canvasSize = useEditorStore((s) => s.canvasSize); - const showRulers = useEditorStore((s) => s.showRulers); + const showRulers = useEditorStore((s) => s.rulersVisible); const addGuide = useEditorStore((s) => s.addGuide); const draw = useCallback(() => { @@ -146,7 +146,7 @@ export function VerticalRuler() { const zoom = useEditorStore((s) => s.zoom); const panOffset = useEditorStore((s) => s.panOffset); const canvasSize = useEditorStore((s) => s.canvasSize); - const showRulers = useEditorStore((s) => s.showRulers); + const showRulers = useEditorStore((s) => s.rulersVisible); const addGuide = useEditorStore((s) => s.addGuide); const draw = useCallback(() => { diff --git a/apps/web/src/components/editor/common/smart-guides.tsx b/apps/web/src/components/editor/common/smart-guides.tsx index 01439a60..c9fe8c05 100644 --- a/apps/web/src/components/editor/common/smart-guides.tsx +++ b/apps/web/src/components/editor/common/smart-guides.tsx @@ -1,6 +1,10 @@ import { Group, Line } from "react-konva"; import { useEditorStore } from "@/stores/editor-store"; -import type { SmartGuide } from "@/types/editor"; +export interface SmartGuide { + orientation: "horizontal" | "vertical"; + position: number; + type: "edge" | "center" | "canvas"; +} // --------------------------------------------------------------------------- // Smart guide calculation utilities (exported for testing) @@ -156,7 +160,7 @@ export function snapToGuides( // SmartGuidesOverlay -- renders temporary guide lines during drag // --------------------------------------------------------------------------- -const GUIDE_COLORS = { +const GUIDE_COLORS: Record = { edge: "#f43f5e", center: "#8b5cf6", canvas: "#22c55e", diff --git a/apps/web/src/components/editor/options/move-options.tsx b/apps/web/src/components/editor/options/move-options.tsx index 4f432a91..ad62ce40 100644 --- a/apps/web/src/components/editor/options/move-options.tsx +++ b/apps/web/src/components/editor/options/move-options.tsx @@ -65,7 +65,12 @@ export function MoveOptions() { | "distribute-h" | "distribute-v", ) => { - alignObjects(direction, selectedObjectIds, objects, updateObject); + alignObjects( + direction, + selectedObjectIds, + objects.map((o) => ({ id: o.id, attrs: o.attrs as unknown as Record })), + updateObject, + ); }; return ( diff --git a/apps/web/src/components/editor/options/selection-options.tsx b/apps/web/src/components/editor/options/selection-options.tsx index 9ba03596..01fb7eba 100644 --- a/apps/web/src/components/editor/options/selection-options.tsx +++ b/apps/web/src/components/editor/options/selection-options.tsx @@ -1,8 +1,11 @@ import { Circle, Minus, PenTool, Plus, Square, Wand2 } from "lucide-react"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; -import type { SelectionMode, SelectionType, ToolType } from "@/types/editor"; +import type { ToolType } from "@/types/editor"; + +type SelectionMode = "new" | "add" | "subtract"; +type SelectionType = "rect" | "ellipse" | "lasso"; // --------------------------------------------------------------------------- // SelectionOptions -- selection type toggle, mode buttons, feather input @@ -42,8 +45,7 @@ function ToggleButton({ export function SelectionOptions() { const activeTool = useEditorStore((s) => s.activeTool); const setTool = useEditorStore((s) => s.setTool); - const selectionMode = useEditorStore((s) => s.selectionMode); - const setSelectionMode = useEditorStore((s) => s.setSelectionMode); + const [selectionMode, setSelectionMode] = useState("new"); const selectionType: SelectionType = activeTool === "marquee-ellipse" @@ -64,12 +66,9 @@ export function SelectionOptions() { [setTool], ); - const handleModeChange = useCallback( - (mode: SelectionMode) => { - setSelectionMode(mode); - }, - [setSelectionMode], - ); + const handleModeChange = useCallback((mode: SelectionMode) => { + setSelectionMode(mode); + }, []); const isMarquee = activeTool === "marquee-rect" || activeTool === "marquee-ellipse"; const isLasso = activeTool === "lasso-free" || activeTool === "lasso-poly"; diff --git a/apps/web/src/components/editor/tools/move-tool.tsx b/apps/web/src/components/editor/tools/move-tool.tsx index 3b47fd39..ecd3112a 100644 --- a/apps/web/src/components/editor/tools/move-tool.tsx +++ b/apps/web/src/components/editor/tools/move-tool.tsx @@ -1,8 +1,8 @@ import type Konva from "konva"; import { useCallback, useEffect, useRef } from "react"; import { Transformer } from "react-konva"; +import type { SmartGuide } from "@/components/editor/common/smart-guides"; import { useEditorStore } from "@/stores/editor-store"; -import type { SmartGuide } from "@/types/editor"; // --------------------------------------------------------------------------- // Smart guide calculation @@ -258,7 +258,7 @@ export function useMoveTool(): MoveToolApi { const setSelectedObjects = useEditorStore((s) => s.setSelectedObjects); const updateObject = useEditorStore((s) => s.updateObject); const canvasSize = useEditorStore((s) => s.canvasSize); - const snapToGuides = useEditorStore((s) => s.snapToGuides); + const snapToGuides = useEditorStore((s) => s.snappingEnabled); // Attach transformer to selected nodes useEffect(() => { @@ -379,9 +379,10 @@ export function useMoveTool(): MoveToolApi { for (const id of selectedObjectIds) { const obj = useEditorStore.getState().objects.find((o) => o.id === id); if (!obj) continue; + const attrs = obj.attrs as unknown as Record; updateObject(id, { - x: ((obj.attrs.x as number) ?? 0) + dx, - y: ((obj.attrs.y as number) ?? 0) + dy, + x: ((attrs.x as number) ?? 0) + dx, + y: ((attrs.y as number) ?? 0) + dy, }); } }, diff --git a/apps/web/src/components/editor/tools/selection-tool.tsx b/apps/web/src/components/editor/tools/selection-tool.tsx index 3f12f84c..cbf2806a 100644 --- a/apps/web/src/components/editor/tools/selection-tool.tsx +++ b/apps/web/src/components/editor/tools/selection-tool.tsx @@ -2,7 +2,10 @@ 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 { SelectionMode, SelectionState, SelectionType } from "@/types/editor"; +import type { SelectionState } from "@/types/editor"; + +type SelectionMode = "new" | "add" | "subtract"; +type SelectionType = "rect" | "ellipse" | "lasso"; // --------------------------------------------------------------------------- // Marching ants animation @@ -237,8 +240,9 @@ export function useSelectionTool(): SelectionToolApi { const [currentPoints, setCurrentPoints] = useState([]); const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const [selectionMode] = useState("new"); + const setSelection = useEditorStore((s) => s.setSelection); - const selectionMode = useEditorStore((s) => s.selectionMode); const canvasSize = useEditorStore((s) => s.canvasSize); const existingSelection = useEditorStore((s) => s.selection); diff --git a/apps/web/src/components/editor/tools/transform-tool.tsx b/apps/web/src/components/editor/tools/transform-tool.tsx index 9baa6586..95a933d0 100644 --- a/apps/web/src/components/editor/tools/transform-tool.tsx +++ b/apps/web/src/components/editor/tools/transform-tool.tsx @@ -56,12 +56,13 @@ export function useTransformTool(): TransformToolApi { if (!isTransforming || selectedObjectIds.length === 0) return; const obj = objects.find((o) => o.id === selectedObjectIds[0]); if (!obj) return; + const a = obj.attrs as unknown as Record; const v: TransformValues = { - x: (obj.attrs.x as number) ?? 0, - y: (obj.attrs.y as number) ?? 0, - width: (obj.attrs.width as number) ?? 0, - height: (obj.attrs.height as number) ?? 0, - rotation: (obj.attrs.rotation as number) ?? 0, + x: (a.x as number) ?? 0, + y: (a.y as number) ?? 0, + width: (a.width as number) ?? 0, + height: (a.height as number) ?? 0, + rotation: (a.rotation as number) ?? 0, }; setValuesState(v); }, [isTransforming, selectedObjectIds, objects]); @@ -86,12 +87,13 @@ export function useTransformTool(): TransformToolApi { // Store pre-transform state for cancel const obj = objects.find((o) => o.id === selectedObjectIds[0]); if (obj) { + const a = obj.attrs as unknown as Record; preTransformRef.current = { - x: (obj.attrs.x as number) ?? 0, - y: (obj.attrs.y as number) ?? 0, - width: (obj.attrs.width as number) ?? 0, - height: (obj.attrs.height as number) ?? 0, - rotation: (obj.attrs.rotation as number) ?? 0, + x: (a.x as number) ?? 0, + y: (a.y as number) ?? 0, + width: (a.width as number) ?? 0, + height: (a.height as number) ?? 0, + rotation: (a.rotation as number) ?? 0, }; } }, [selectedObjectIds, objects]); @@ -157,8 +159,9 @@ export function useTransformTool(): TransformToolApi { for (const id of selectedObjectIds) { const obj = objects.find((o) => o.id === id); if (!obj) continue; - const currentScale = (obj.attrs.scaleX as number) ?? 1; - updateObject(id, { scaleX: -currentScale }); + const a = obj.attrs as unknown as Record; + const currentScale = (a.scaleX as number) ?? 1; + updateObject(id, { scaleX: -currentScale } as Record); } }, [selectedObjectIds, objects, updateObject]); @@ -166,8 +169,9 @@ export function useTransformTool(): TransformToolApi { for (const id of selectedObjectIds) { const obj = objects.find((o) => o.id === id); if (!obj) continue; - const currentScale = (obj.attrs.scaleY as number) ?? 1; - updateObject(id, { scaleY: -currentScale }); + const a = obj.attrs as unknown as Record; + const currentScale = (a.scaleY as number) ?? 1; + updateObject(id, { scaleY: -currentScale } as Record); } }, [selectedObjectIds, objects, updateObject]); From 791bc2d3ce669e455c9167f1b81ea05959d69bc0 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Thu, 7 May 2026 09:12:03 +0800 Subject: [PATCH 13/65] feat: wire editor options bar, right panel, and canvas rendering --- .../src/components/editor/editor-canvas.tsx | 301 +++++++++++++++++- .../components/editor/editor-options-bar.tsx | 67 +++- .../components/editor/editor-right-panel.tsx | 21 +- 3 files changed, 378 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/editor/editor-canvas.tsx b/apps/web/src/components/editor/editor-canvas.tsx index 1da80667..bbf86fbf 100644 --- a/apps/web/src/components/editor/editor-canvas.tsx +++ b/apps/web/src/components/editor/editor-canvas.tsx @@ -1,11 +1,32 @@ // apps/web/src/components/editor/editor-canvas.tsx import type Konva from "konva"; -import React, { useCallback, useEffect, useRef } from "react"; -import { Layer, Shape, Stage } from "react-konva"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Arrow, + Ellipse, + Group, + Layer, + Line, + Rect, + RegularPolygon, + Shape, + Stage, + Star, + Text, +} from "react-konva"; import { useCanvasZoom } from "@/hooks/use-canvas-zoom"; +import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts"; import { useEditorStore } from "@/stores/editor-store"; +import type { CanvasObject } from "@/types/editor"; import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor"; +import { useBrushTool } from "./tools/brush-tool"; +import { useEraserTool } from "./tools/eraser-tool"; +import { useFillTool } from "./tools/fill-tool"; +import { useGradientTool } from "./tools/gradient-tool"; +import { MoveToolTransformer, useMoveTool } from "./tools/move-tool"; +import { useShapeTool } from "./tools/shape-tool"; const CHECKERBOARD_SIZE = 20; const CHECKERBOARD_CSS = ` @@ -15,6 +36,203 @@ const CHECKERBOARD_CSS = ` ) `; +// --------------------------------------------------------------------------- +// Canvas Object Renderer +// --------------------------------------------------------------------------- + +function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) { + switch (obj.type) { + case "line": { + const a = obj.attrs; + return ( + + ); + } + case "rect": { + const a = obj.attrs; + return ( + + ); + } + case "ellipse": { + const a = obj.attrs; + return ( + + ); + } + case "text": { + const a = obj.attrs; + return ( + + ); + } + case "arrow": { + const a = obj.attrs; + return ( + + ); + } + case "polygon": { + const a = obj.attrs; + return ( + + ); + } + case "star": { + const a = obj.attrs; + return ( + + ); + } + case "image": + // Skip image objects for now (complex: requires Konva.Image with useImage) + return null; + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// Tool handler dispatcher +// --------------------------------------------------------------------------- + +function useActiveToolHandlers(stageRef: React.RefObject) { + const activeTool = useEditorStore((s) => s.activeTool); + + const brushTool = useBrushTool(); + const eraserTool = useEraserTool(); + const shapeTool = useShapeTool(); + const fillTool = useFillTool(stageRef); + const gradientTool = useGradientTool(); + const moveTool = useMoveTool(); + + const handlers = useMemo(() => { + const toolMap: Record< + string, + { + handleMouseDown: (e: Konva.KonvaEventObject) => void; + handleMouseMove: (e: Konva.KonvaEventObject) => void; + handleMouseUp: (e: Konva.KonvaEventObject) => void; + } + > = { + brush: brushTool, + pencil: brushTool, + eraser: eraserTool, + "shape-rect": shapeTool, + "shape-ellipse": shapeTool, + "shape-line": shapeTool, + "shape-arrow": shapeTool, + "shape-polygon": shapeTool, + "shape-star": shapeTool, + fill: fillTool, + gradient: gradientTool, + }; + + return toolMap[activeTool] ?? null; + }, [activeTool, brushTool, eraserTool, shapeTool, fillTool, gradientTool]); + + return { handlers, moveTool }; +} + +// --------------------------------------------------------------------------- +// Main Canvas Component +// --------------------------------------------------------------------------- + export function EditorCanvas() { const containerRef = useRef(null); const { stageRef, handleWheel, fitToScreen } = useCanvasZoom(); @@ -25,11 +243,18 @@ export function EditorCanvas() { const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); const setCursorPosition = useEditorStore((s) => s.setCursorPosition); const gridVisible = useEditorStore((s) => s.gridVisible); + const objects = useEditorStore((s) => s.objects); + const layers = useEditorStore((s) => s.layers); + const activeLayerId = useEditorStore((s) => s.activeLayerId); + const activeTool = useEditorStore((s) => s.activeTool); const cursor = useEditorCursor(); + useEditorShortcuts(); - const [stageWidth, setStageWidth] = React.useState(800); - const [stageHeight, setStageHeight] = React.useState(600); + const { handlers, moveTool } = useActiveToolHandlers(stageRef); + + const [stageWidth, setStageWidth] = useState(800); + const [stageHeight, setStageHeight] = useState(600); useEffect(() => { const container = containerRef.current; @@ -50,6 +275,21 @@ export function EditorCanvas() { } }, [sourceImageUrl, stageWidth, stageHeight, canvasSize.width, canvasSize.height, fitToScreen]); + // Group objects by layer + const objectsByLayer = useMemo(() => { + const grouped = new Map(); + for (const layer of layers) { + grouped.set(layer.id, []); + } + for (const obj of objects) { + const existing = grouped.get(obj.layerId); + if (existing) { + existing.push(obj); + } + } + return grouped; + }, [objects, layers]); + const handleMouseMove = useCallback( (e: Konva.KonvaEventObject) => { const stage = e.target.getStage(); @@ -59,8 +299,36 @@ export function EditorCanvas() { const x = Math.round((pointer.x - panOffset.x) / zoom); const y = Math.round((pointer.y - panOffset.y) / zoom); setCursorPosition({ x, y }); + + // Forward to active tool + if (handlers) { + handlers.handleMouseMove(e); + } }, - [zoom, panOffset, setCursorPosition], + [zoom, panOffset, setCursorPosition, handlers], + ); + + const handleMouseDown = useCallback( + (e: Konva.KonvaEventObject) => { + // For move tool, handle stage click to deselect + if (activeTool === "move") { + moveTool.onStageClick(e); + } + + if (handlers) { + handlers.handleMouseDown(e); + } + }, + [handlers, activeTool, moveTool], + ); + + const handleMouseUp = useCallback( + (e: Konva.KonvaEventObject) => { + if (handlers) { + handlers.handleMouseUp(e); + } + }, + [handlers], ); const checkerboardSize = CHECKERBOARD_SIZE * zoom; @@ -86,8 +354,29 @@ export function EditorCanvas() { y={panOffset.y} onWheel={handleWheel} onMouseMove={handleMouseMove} + onMouseDown={handleMouseDown} + onMouseUp={handleMouseUp} > - {/* Canvas objects are rendered here by tool components */} + {/* Render objects grouped by layer */} + + {layers.map((layer) => { + const layerObjects = objectsByLayer.get(layer.id) ?? []; + if (!layer.visible) return null; + return ( + + {layerObjects.map((obj) => ( + + ))} + + ); + })} + + {/* Move tool transformer */} + {activeTool === "move" && ( + + )} + + {/* Grid overlay layer (Feature 49) - non-interactive */} {(gridVisible || zoom >= 8) && ( diff --git a/apps/web/src/components/editor/editor-options-bar.tsx b/apps/web/src/components/editor/editor-options-bar.tsx index f02288ff..0f0c451f 100644 --- a/apps/web/src/components/editor/editor-options-bar.tsx +++ b/apps/web/src/components/editor/editor-options-bar.tsx @@ -1,17 +1,80 @@ // apps/web/src/components/editor/editor-options-bar.tsx + import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; +import { BrushOptions } from "./options/brush-options"; +import { CloneStampOptions } from "./options/clone-stamp-options"; +import { CropOptions } from "./options/crop-options"; +import { DodgeBurnOptions } from "./options/dodge-burn-options"; +import { FillOptions } from "./options/fill-options"; +import { GradientOptions } from "./options/gradient-options"; +import { MoveOptions } from "./options/move-options"; +import { PixelBrushOptions } from "./options/pixel-brush-options"; +import { SelectionOptions } from "./options/selection-options"; +import { ShapeOptions } from "./options/shape-options"; + +function getOptionsComponent(tool: ToolType): React.ComponentType | null { + switch (tool) { + case "move": + return MoveOptions; + case "marquee-rect": + case "marquee-ellipse": + case "lasso-free": + case "lasso-poly": + case "magic-wand": + return SelectionOptions; + case "crop": + return CropOptions; + case "brush": + case "eraser": + case "pencil": + return BrushOptions; + case "clone-stamp": + return CloneStampOptions; + case "dodge": + case "burn": + case "sponge": + return DodgeBurnOptions; + case "blur-brush": + case "sharpen-brush": + case "smudge": + return PixelBrushOptions; + case "fill": + return FillOptions; + case "gradient": + return GradientOptions; + case "shape-rect": + case "shape-ellipse": + case "shape-line": + case "shape-arrow": + case "shape-polygon": + case "shape-star": + return ShapeOptions; + case "hand": + case "zoom": + case "eyedropper": + case "text": + case "transform": + return null; + default: + return null; + } +} export function EditorOptionsBar() { const activeTool = useEditorStore((s) => s.activeTool); + const OptionsComponent = getOptionsComponent(activeTool); + return (
{activeTool.replace(/-/g, " ").replace(/^shape /, "")}
- {/* Tool-specific option components are rendered here by each agent */} -
+
+ {OptionsComponent && } +
); } diff --git a/apps/web/src/components/editor/editor-right-panel.tsx b/apps/web/src/components/editor/editor-right-panel.tsx index 827d094f..1d36c51d 100644 --- a/apps/web/src/components/editor/editor-right-panel.tsx +++ b/apps/web/src/components/editor/editor-right-panel.tsx @@ -2,7 +2,11 @@ import { ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; +import { AdjustmentsPanel } from "./panels/adjustments-panel"; +import { ColorPanel } from "./panels/color-panel"; +import { HistoryPanel } from "./panels/history-panel"; import { LayersPanel } from "./panels/layers-panel"; +import { NavigatorPanel } from "./panels/navigator-panel"; const TABS = [ { id: "layers" as const, label: "Layers" }, @@ -15,6 +19,7 @@ export function EditorRightPanel() { const activeTab = useEditorStore((s) => s.rightPanelTab); const setTab = useEditorStore((s) => s.setRightPanelTab); const togglePanel = useEditorStore((s) => s.toggleRightPanel); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); if (!visible) { return ( @@ -31,6 +36,10 @@ export function EditorRightPanel() { return (
+ {/* Navigator always visible when image loaded */} + {sourceImageUrl && } + + {/* Tabs */}
{TABS.map((tab) => (
+ + {/* Tab content */}
{activeTab === "layers" && } - {activeTab !== "layers" &&
} + {activeTab === "adjustments" && } + {activeTab === "history" && } +
+ + {/* Color panel always visible at bottom */} +
+
- {/* Color panel always visible at bottom (Agent 5) */} -
); } From 76501c4fb3d8190b3b51c1165c2835d96167252b Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Thu, 7 May 2026 09:16:20 +0800 Subject: [PATCH 14/65] feat: add editor polish (mobile handling, loading states, accessibility) - Show desktop-recommended message on mobile viewports (<768px) - Add LoadingOverlay component for canvas loading/progress states - Add ARIA tablist/tab/aria-selected to right panel tabs - Add "Open in Editor" link in review panel after tool processing --- .../src/components/common/review-panel.tsx | 21 ++++++- .../editor/common/loading-overlay.tsx | 55 +++++++++++++++++++ .../src/components/editor/editor-canvas.tsx | 2 + .../components/editor/editor-right-panel.tsx | 4 +- apps/web/src/pages/editor-page.tsx | 16 ++++++ 5 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/editor/common/loading-overlay.tsx diff --git a/apps/web/src/components/common/review-panel.tsx b/apps/web/src/components/common/review-panel.tsx index a0c38f76..b0bde0f7 100644 --- a/apps/web/src/components/common/review-panel.tsx +++ b/apps/web/src/components/common/review-panel.tsx @@ -1,7 +1,15 @@ import { TOOLS } from "@snapotter/shared"; -import { ArrowRight, ChevronDown, ChevronRight, Download, FileImage, Undo2 } from "lucide-react"; +import { + ArrowRight, + ChevronDown, + ChevronRight, + Download, + FileImage, + PenTool, + Undo2, +} from "lucide-react"; import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { formatFileSize, triggerDownload } from "@/lib/download"; import { ICON_MAP } from "@/lib/icon-map"; import { getSuggestedTools } from "@/lib/suggested-tools"; @@ -103,6 +111,15 @@ export function ReviewPanel({
+ {/* Open in Editor */} + + + Open in Editor + + {/* Suggested tools */} {suggestedTools.length > 0 && (
diff --git a/apps/web/src/components/editor/common/loading-overlay.tsx b/apps/web/src/components/editor/common/loading-overlay.tsx new file mode 100644 index 00000000..21faa990 --- /dev/null +++ b/apps/web/src/components/editor/common/loading-overlay.tsx @@ -0,0 +1,55 @@ +// apps/web/src/components/editor/common/loading-overlay.tsx +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +export function LoadingOverlay() { + const loadingState = useEditorStore((s) => s.loadingState); + const setLoadingState = useEditorStore((s) => s.setLoadingState); + + if (!loadingState) return null; + + return ( +
+
+ {/* Operation name */} +

{loadingState.operation}

+ + {/* Progress bar */} + {loadingState.progress !== null && ( +
+
= 0 + ? { width: `${Math.min(100, loadingState.progress)}%` } + : undefined + } + /> +
+ )} + + {/* Indeterminate spinner when no progress */} + {loadingState.progress === null && ( +
+ )} + + {/* Cancel button */} + {loadingState.cancellable && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-canvas.tsx b/apps/web/src/components/editor/editor-canvas.tsx index bbf86fbf..11ae7dfb 100644 --- a/apps/web/src/components/editor/editor-canvas.tsx +++ b/apps/web/src/components/editor/editor-canvas.tsx @@ -21,6 +21,7 @@ import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts"; import { useEditorStore } from "@/stores/editor-store"; import type { CanvasObject } from "@/types/editor"; import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor"; +import { LoadingOverlay } from "./common/loading-overlay"; import { useBrushTool } from "./tools/brush-tool"; import { useEraserTool } from "./tools/eraser-tool"; import { useFillTool } from "./tools/fill-tool"; @@ -391,6 +392,7 @@ export function EditorCanvas() { )} +
); } diff --git a/apps/web/src/components/editor/editor-right-panel.tsx b/apps/web/src/components/editor/editor-right-panel.tsx index 1d36c51d..7b307955 100644 --- a/apps/web/src/components/editor/editor-right-panel.tsx +++ b/apps/web/src/components/editor/editor-right-panel.tsx @@ -40,11 +40,13 @@ export function EditorRightPanel() { {sourceImageUrl && } {/* Tabs */} -
+
{TABS.map((tab) => ( + ); +} + +function NumberInput({ + value, + min, + max, + step, + label, + onChange, + width = "w-16", +}: { + value: number; + min: number; + max: number; + step: number; + label: string; + onChange: (v: number) => void; + width?: string; +}) { + return ( + { + const n = Number.parseFloat(e.target.value); + if (!Number.isNaN(n)) onChange(Math.max(min, Math.min(max, n))); + }} + className={cn( + "h-7 rounded border border-border bg-background px-1.5 text-xs text-center", + "focus:outline-none focus:ring-1 focus:ring-ring", + width, + )} + /> + ); +} + +// --------------------------------------------------------------------------- +// Font Dropdown +// --------------------------------------------------------------------------- + +function FontDropdown({ value, onChange }: { value: string; onChange: (name: string) => void }) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const fonts = useMemo(() => getAllFonts(), []); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const handleSelect = useCallback( + async (name: string) => { + if (!isSystemFont(name)) { + await loadGoogleFont(name); + } + onChange(name); + setOpen(false); + }, + [onChange], + ); + + return ( +
+ + + {open && ( +
+ {/* System fonts */} +
+ System Fonts +
+ {fonts.system.map((name) => ( + + ))} + +
+ + {/* Google fonts */} +
+ Google Fonts +
+ {fonts.google.map((name) => ( + + ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Component +// --------------------------------------------------------------------------- + +export function TextOptions() { + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const objects = useEditorStore((s) => s.objects); + + // Derive current attrs from the selected text object + const attrs = useMemo(() => { + if (selectedObjectIds.length !== 1) return null; + const obj = objects.find((o) => o.id === selectedObjectIds[0] && o.type === "text"); + return obj ? (obj.attrs as TextAttrs) : null; + }, [selectedObjectIds, objects]); + + if (!attrs) return null; + + const isBold = attrs.fontStyle.includes("bold"); + const isItalic = attrs.fontStyle.includes("italic"); + const hasUnderline = attrs.textDecoration.includes("underline"); + const hasStrikethrough = attrs.textDecoration.includes("line-through"); + const isAreaText = attrs.wrap !== undefined; + + const toggleBold = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const wasBold = current.fontStyle.includes("bold"); + const parts = current.fontStyle.split(" ").filter((p) => p !== "bold" && p !== ""); + if (!wasBold) parts.push("bold"); + updateSelected({ fontStyle: parts.join(" ") || "normal" }); + }; + + const toggleItalic = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const wasItalic = current.fontStyle.includes("italic"); + const parts = current.fontStyle.split(" ").filter((p) => p !== "italic" && p !== ""); + if (!wasItalic) parts.push("italic"); + updateSelected({ fontStyle: parts.join(" ") || "normal" }); + }; + + const toggleUnderline = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const had = current.textDecoration.includes("underline"); + const parts = current.textDecoration.split(" ").filter((p) => p !== "underline" && p !== ""); + if (!had) parts.push("underline"); + updateSelected({ textDecoration: parts.join(" ") }); + }; + + const toggleStrikethrough = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const had = current.textDecoration.includes("line-through"); + const parts = current.textDecoration.split(" ").filter((p) => p !== "line-through" && p !== ""); + if (!had) parts.push("line-through"); + updateSelected({ textDecoration: parts.join(" ") }); + }; + + const toggleTextMode = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + if (current.wrap !== undefined) { + // Switch to point text: remove width/height/wrap + updateSelected({ + width: undefined, + height: undefined, + wrap: undefined, + }); + } else { + // Switch to area text + updateSelected({ + width: 200, + height: 100, + wrap: "word", + }); + } + }; + + return ( +
+ {/* Font family */} + updateSelected({ fontFamily: name })} + /> + + {/* Font size */} + updateSelected({ fontSize: v })} + width="w-14" + /> + +
+ + {/* Bold / Italic / Underline / Strikethrough */} +
+ + + + + + + + + + + + +
+ +
+ + {/* Alignment */} +
+ updateSelected({ align: "left" })} + label="Align left" + > + + + updateSelected({ align: "center" })} + label="Align center" + > + + + updateSelected({ align: "right" })} + label="Align right" + > + + +
+ +
+ + {/* Line height */} +
+ LH + updateSelected({ lineHeight: v })} + width="w-14" + /> +
+ + {/* Letter spacing */} +
+ LS + updateSelected({ letterSpacing: v })} + width="w-14" + /> +
+ +
+ + {/* Color swatch */} +