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/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-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, + }); + } + } +}