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