mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'feat/format-support-expansion'
# Conflicts: # README.md # apps/web/src/components/layout/app-layout.tsx
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
// apps/web/src/hooks/use-canvas-zoom.ts
|
||||
|
||||
import Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
const ZOOM_SENSITIVITY = 1.1;
|
||||
const ZOOM_ANIMATION_DURATION = 0.15;
|
||||
|
||||
export function useCanvasZoom() {
|
||||
const stageRef = useRef<Konva.Stage>(null);
|
||||
const setZoom = useEditorStore((s) => s.setZoom);
|
||||
const setPanOffset = useEditorStore((s) => s.setPanOffset);
|
||||
const zoom = useEditorStore((s) => s.zoom);
|
||||
const panOffset = useEditorStore((s) => s.panOffset);
|
||||
const tweenRef = useRef<Konva.Tween | null>(null);
|
||||
|
||||
const animateZoom = useCallback(
|
||||
(targetZoom: number, targetPos: { x: number; y: number }) => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) {
|
||||
setZoom(targetZoom);
|
||||
setPanOffset(targetPos);
|
||||
return;
|
||||
}
|
||||
if (tweenRef.current) {
|
||||
tweenRef.current.destroy();
|
||||
}
|
||||
// Update store immediately so all tools get correct coordinates
|
||||
setZoom(targetZoom);
|
||||
setPanOffset(targetPos);
|
||||
|
||||
tweenRef.current = new Konva.Tween({
|
||||
node: stage,
|
||||
scaleX: targetZoom,
|
||||
scaleY: targetZoom,
|
||||
x: targetPos.x,
|
||||
y: targetPos.y,
|
||||
duration: ZOOM_ANIMATION_DURATION,
|
||||
easing: Konva.Easings.EaseOut,
|
||||
onFinish: () => {
|
||||
tweenRef.current?.destroy();
|
||||
tweenRef.current = null;
|
||||
},
|
||||
});
|
||||
tweenRef.current.play();
|
||||
},
|
||||
[setZoom, setPanOffset],
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
e.evt.preventDefault();
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
const isZoom = e.evt.ctrlKey || e.evt.metaKey;
|
||||
|
||||
if (isZoom) {
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const oldZoom = zoom;
|
||||
const direction = e.evt.deltaY < 0 ? 1 : -1;
|
||||
const newZoom = direction > 0 ? oldZoom * ZOOM_SENSITIVITY : oldZoom / ZOOM_SENSITIVITY;
|
||||
const clampedZoom = Math.max(0.01, Math.min(64, newZoom));
|
||||
|
||||
const mousePointTo = {
|
||||
x: (pointer.x - panOffset.x) / oldZoom,
|
||||
y: (pointer.y - panOffset.y) / oldZoom,
|
||||
};
|
||||
|
||||
const newPos = {
|
||||
x: pointer.x - mousePointTo.x * clampedZoom,
|
||||
y: pointer.y - mousePointTo.y * clampedZoom,
|
||||
};
|
||||
|
||||
animateZoom(clampedZoom, newPos);
|
||||
} else {
|
||||
const dx = e.evt.shiftKey ? -e.evt.deltaY : -e.evt.deltaX;
|
||||
const dy = e.evt.shiftKey ? 0 : -e.evt.deltaY;
|
||||
setPanOffset({ x: panOffset.x + dx, y: panOffset.y + dy });
|
||||
}
|
||||
},
|
||||
[zoom, panOffset, setPanOffset, animateZoom],
|
||||
);
|
||||
|
||||
const fitToScreen = useCallback(
|
||||
(viewportWidth: number, viewportHeight: number, imageWidth: number, imageHeight: number) => {
|
||||
const scaleX = viewportWidth / imageWidth;
|
||||
const scaleY = viewportHeight / imageHeight;
|
||||
const fitZoom = Math.min(scaleX, scaleY) * 0.9;
|
||||
const offsetX = (viewportWidth - imageWidth * fitZoom) / 2;
|
||||
const offsetY = (viewportHeight - imageHeight * fitZoom) / 2;
|
||||
animateZoom(fitZoom, { x: offsetX, y: offsetY });
|
||||
},
|
||||
[animateZoom],
|
||||
);
|
||||
|
||||
const zoomTo = useCallback(
|
||||
(targetZoom: number, viewportWidth: number, viewportHeight: number) => {
|
||||
const canvasSize = useEditorStore.getState().canvasSize;
|
||||
const offsetX = (viewportWidth - canvasSize.width * targetZoom) / 2;
|
||||
const offsetY = (viewportHeight - canvasSize.height * targetZoom) / 2;
|
||||
animateZoom(targetZoom, { x: offsetX, y: offsetY });
|
||||
},
|
||||
[animateZoom],
|
||||
);
|
||||
|
||||
return { stageRef, handleWheel, fitToScreen, zoomTo };
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
// 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
|
||||
// Use a raw keydown listener at capture phase to reliably prevent the
|
||||
// browser's "Save Page As" dialog, which fires before react-hotkeys-hook
|
||||
// can intercept.
|
||||
const onExportRef = useRef(callbacks?.onExport);
|
||||
onExportRef.current = callbacks?.onExport;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onExportRef.current?.();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handler, { capture: 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user