mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: wire export shortcut, text tool canvas integration, and undo rendering
1. Export dialog (Ctrl+Shift+S): replaced react-hotkeys-hook handler with a capture-phase keydown listener on window so the browser's native "Save Page As" dialog is intercepted before it can fire. 2. Text tool: created useTextTool hook that spawns an inline textarea overlay on canvas click, commits the text as a Konva Text object on blur/Enter, and wired it into the useActiveToolHandlers dispatcher. 3. Undo (Ctrl+Z): removed the 500ms debounce from zundo's handleSet. The debounce caused a race where calling undo before the timer fired would discard the future-states stack, making undo appear to do nothing. The equality function (keyed on _historyVersion) already prevents intermediate states from being recorded, so the debounce was redundant.
This commit is contained in:
@@ -30,6 +30,7 @@ import { useFillTool } from "./tools/fill-tool";
|
||||
import { useGradientTool } from "./tools/gradient-tool";
|
||||
import { MoveToolTransformer, useMoveTool } from "./tools/move-tool";
|
||||
import { useShapeTool } from "./tools/shape-tool";
|
||||
import { useTextTool } from "./tools/text-tool";
|
||||
|
||||
// Module-level stage ref for export dialog access (Issue #6)
|
||||
export const editorStageRefHolder: { current: Konva.Stage | null } = {
|
||||
@@ -390,6 +391,7 @@ function useActiveToolHandlers(stageRef: React.RefObject<Konva.Stage | null>) {
|
||||
const brushTool = useBrushTool();
|
||||
const eraserTool = useEraserTool();
|
||||
const shapeTool = useShapeTool();
|
||||
const textTool = useTextTool();
|
||||
const fillTool = useFillTool(stageRef);
|
||||
const gradientTool = useGradientTool();
|
||||
const moveTool = useMoveTool();
|
||||
@@ -412,12 +414,13 @@ function useActiveToolHandlers(stageRef: React.RefObject<Konva.Stage | null>) {
|
||||
"shape-arrow": shapeTool,
|
||||
"shape-polygon": shapeTool,
|
||||
"shape-star": shapeTool,
|
||||
text: textTool,
|
||||
fill: fillTool,
|
||||
gradient: gradientTool,
|
||||
};
|
||||
|
||||
return toolMap[activeTool] ?? null;
|
||||
}, [activeTool, brushTool, eraserTool, shapeTool, fillTool, gradientTool]);
|
||||
}, [activeTool, brushTool, eraserTool, shapeTool, textTool, fillTool, gradientTool]);
|
||||
|
||||
return { handlers, moveTool };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// apps/web/src/components/editor/tools/text-tool.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject, TextAttrs } from "@/types/editor";
|
||||
|
||||
/**
|
||||
* Text tool hook. Clicking the canvas with the text tool active creates a
|
||||
* temporary <textarea> overlay for inline editing. When the user finishes
|
||||
* typing (blur / Escape / Enter without Shift), the text is committed as a
|
||||
* Konva Text object in the store.
|
||||
*/
|
||||
export function useTextTool() {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const pendingRef = useRef<{
|
||||
objectId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
// Clean up any lingering textarea on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
textareaRef.current?.remove();
|
||||
textareaRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const commitText = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const pending = pendingRef.current;
|
||||
if (!textarea || !pending) return;
|
||||
|
||||
const text = textarea.value.trim();
|
||||
textarea.remove();
|
||||
textareaRef.current = null;
|
||||
pendingRef.current = null;
|
||||
|
||||
useEditorStore.getState().setTool("text");
|
||||
|
||||
if (!text) {
|
||||
// Empty text -- remove the placeholder object
|
||||
useEditorStore.getState().removeObjects([pending.objectId]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the placeholder object with the final text
|
||||
useEditorStore.getState().updateObject(pending.objectId, { text });
|
||||
|
||||
// Re-add the object so history records it (updateObject is silent)
|
||||
const state = useEditorStore.getState();
|
||||
const obj = state.objects.find((o) => o.id === pending.objectId);
|
||||
if (obj) {
|
||||
state.setSelectedObjects([pending.objectId]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
// If there's already a textarea open, commit it first
|
||||
if (textareaRef.current) {
|
||||
commitText();
|
||||
return;
|
||||
}
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const { activeTool, foregroundColor, zoom, panOffset, activeLayerId } =
|
||||
useEditorStore.getState();
|
||||
|
||||
if (activeTool !== "text") return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const x = (pointer.x - panOffset.x) / zoom;
|
||||
const y = (pointer.y - panOffset.y) / zoom;
|
||||
|
||||
const id = generateId();
|
||||
const fontSize = 24;
|
||||
|
||||
const attrs: TextAttrs = {
|
||||
x,
|
||||
y,
|
||||
text: "",
|
||||
fontFamily: "Arial",
|
||||
fontSize,
|
||||
fontStyle: "normal",
|
||||
fontVariant: "normal",
|
||||
textDecoration: "",
|
||||
align: "left",
|
||||
fill: foregroundColor,
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: 0,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
const obj: CanvasObject = {
|
||||
id,
|
||||
type: "text",
|
||||
layerId: activeLayerId,
|
||||
attrs,
|
||||
};
|
||||
|
||||
useEditorStore.getState().addObject(obj);
|
||||
pendingRef.current = { objectId: id, x, y };
|
||||
|
||||
// Create textarea overlay at the click position
|
||||
const container = stage.container();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.style.position = "absolute";
|
||||
textarea.style.left = `${pointer.x + containerRect.left}px`;
|
||||
textarea.style.top = `${pointer.y + containerRect.top}px`;
|
||||
textarea.style.fontSize = `${fontSize * zoom}px`;
|
||||
textarea.style.fontFamily = "Arial";
|
||||
textarea.style.color = foregroundColor;
|
||||
textarea.style.background = "transparent";
|
||||
textarea.style.border = "1px dashed rgba(59, 130, 246, 0.6)";
|
||||
textarea.style.outline = "none";
|
||||
textarea.style.padding = "2px 4px";
|
||||
textarea.style.margin = "0";
|
||||
textarea.style.minWidth = "60px";
|
||||
textarea.style.minHeight = `${fontSize * zoom * 1.4}px`;
|
||||
textarea.style.resize = "none";
|
||||
textarea.style.overflow = "hidden";
|
||||
textarea.style.zIndex = "1000";
|
||||
textarea.style.lineHeight = "1.2";
|
||||
textarea.style.letterSpacing = "0px";
|
||||
textarea.style.whiteSpace = "pre";
|
||||
textarea.style.transformOrigin = "top left";
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
textareaRef.current = textarea;
|
||||
|
||||
// Auto-resize as user types
|
||||
const autoResize = () => {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
textarea.style.width = "auto";
|
||||
textarea.style.width = `${Math.max(60, textarea.scrollWidth + 4)}px`;
|
||||
};
|
||||
|
||||
textarea.addEventListener("input", autoResize);
|
||||
|
||||
textarea.addEventListener("blur", () => {
|
||||
commitText();
|
||||
});
|
||||
|
||||
textarea.addEventListener("keydown", (ke) => {
|
||||
// Enter without Shift commits; Shift+Enter inserts newline
|
||||
if (ke.key === "Enter" && !ke.shiftKey) {
|
||||
ke.preventDefault();
|
||||
textarea.blur();
|
||||
}
|
||||
if (ke.key === "Escape") {
|
||||
ke.preventDefault();
|
||||
textarea.value = "";
|
||||
textarea.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus after a microtask so the click doesn't immediately blur
|
||||
requestAnimationFrame(() => textarea.focus());
|
||||
},
|
||||
[commitText],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
// Text tool doesn't need mouse-move handling
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
// Text tool doesn't need mouse-up handling
|
||||
}, []);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp };
|
||||
}
|
||||
@@ -357,14 +357,23 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
|
||||
);
|
||||
|
||||
// Ctrl+Shift+S / Cmd+Shift+S - Export image
|
||||
useHotkeys(
|
||||
"mod+shift+s",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
callbacks?.onExport?.();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
// 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(
|
||||
|
||||
@@ -732,14 +732,11 @@ export const useEditorStore = create<EditorState>()(
|
||||
equality: (a, b) =>
|
||||
(a as { _historyVersion: number })._historyVersion ===
|
||||
(b as { _historyVersion: number })._historyVersion,
|
||||
// Issue #1: Forward all arguments from zundo's internal _handleSet
|
||||
handleSet: (handleSet) => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return (...args: Parameters<typeof handleSet>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => handleSet(...args), 500);
|
||||
};
|
||||
},
|
||||
// Issue #1: Forward all arguments from zundo's internal _handleSet.
|
||||
// No debounce -- the equality function (based on _historyVersion) already
|
||||
// prevents intermediate states from being recorded. Debouncing caused
|
||||
// undo/redo to race with the delayed recording and silently discard the
|
||||
// future-states stack.
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user