mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
869 lines
20 KiB
TypeScript
869 lines
20 KiB
TypeScript
// 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<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,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|