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:
SnapOtter
2026-05-07 20:21:46 +08:00
parent 79897fcdb0
commit a633eff788
4 changed files with 209 additions and 17 deletions
@@ -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 };
}
+17 -8
View File
@@ -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(
+5 -8
View File
@@ -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.
},
),
);