Files
SnapOtter/apps/web/src/hooks/use-editor-shortcuts.ts
T
SnapOtter 2ae97a6c88 feat: add history panel, keyboard shortcuts, export dialog, and navigator minimap
Implement Agent 7 features for the image editor:
- History panel with undo/redo list, action icons, and state jumping
- 51+ keyboard shortcuts via react-hotkeys-hook (tools, modifiers, clipboard)
- Export dialog with PNG/JPEG/WebP format, quality, resize, clipboard copy
- Navigator minimap with viewport rectangle, drag-to-pan, zoom slider
- Autosave/recovery system with localStorage and recovery banner
- Project save/load (.snapotter JSON format)
- Unsaved changes warning via beforeunload
- Paste from system clipboard support
2026-05-06 23:28:29 +08:00

869 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 toolshortcut
* singleletter 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<ToolType | null>(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,
});
}
}
}