From eb6f3c33ac77a37e330de9a5c18c0949ca71ffe2 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:05:33 +0800 Subject: [PATCH] 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; +}