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