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 };
}