diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 9581968a..f58a28b6 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -99,6 +99,7 @@ import { suppressBoxSelectionClick } from "@/utils/canvasInteractionGuards"; import { calculateCanvasAutoLayout } from "@/utils/canvasAutoLayout"; import { canConnectByNodeConnectionSpec } from "@/utils/nodeConnectionSpec"; import { REQUEST_SMART_TEXT_EDIT_EVENT } from "@/utils/smartTextEditRequest"; +import { getCaretCharOffsetFromPoint } from "@/utils/caretOffset"; const BOX_SELECTION_EXPAND_MIN_DISTANCE = 4; const MULTI_SELECTION_BOUNDS_PADDING = 40; @@ -1239,13 +1240,25 @@ export function WorkflowCanvas() { nodes.find((candidate) => candidate.id === nodeId); if (node && !isWorkflowNode(node)) return; + // Capture the caret offset under the pointer now, while the content is still + // visible and unzoomed; the screen coordinates would be stale once the zoom + // animation runs. Smart text re-applies this offset after entering edit. + let caretOffset: number | undefined; + if (node?.type === "smartText") { + const contentElement = target?.closest("[data-smart-text-content]"); + if (contentElement) { + const offset = getCaretCharOffsetFromPoint(event.clientX, event.clientY, contentElement); + if (offset != null) caretOffset = offset; + } + } + event.preventDefault(); event.stopPropagation(); void zoomNodeIntoFocusById(nodeId).then(() => { if (node?.type !== "smartText") return; window.dispatchEvent(new CustomEvent(REQUEST_SMART_TEXT_EDIT_EVENT, { - detail: { nodeId }, + detail: { nodeId, caretOffset }, })); }); }, [nodes, zoomNodeIntoFocusById]); diff --git a/src/components/nodes/SmartTextNode.tsx b/src/components/nodes/SmartTextNode.tsx index 50fb1bb7..78e1821f 100644 --- a/src/components/nodes/SmartTextNode.tsx +++ b/src/components/nodes/SmartTextNode.tsx @@ -19,6 +19,7 @@ import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHan import { deriveSmartTextMode } from "@/utils/smartTextMode"; import { editorHtmlToMarkdown, insertEditorDivider, markdownToEditorHtml } from "@/utils/markdownEditor"; import { REQUEST_SMART_TEXT_EDIT_EVENT } from "@/utils/smartTextEditRequest"; +import { setCaretAtCharOffset } from "@/utils/caretOffset"; import { readTextDocumentFile } from "@/utils/textDocumentImport"; type SmartTextNodeType = Node; @@ -279,6 +280,9 @@ export function SmartTextMarkdownContent({ const fileInputRef = useRef(null); const draftMarkdownRef = useRef(value); const wasSelectedRef = useRef(selected); + // Caret offset (in characters) requested by a double-click, applied once the + // element becomes editable; null means "fall back to the end". + const pendingCaretOffsetRef = useRef(null); const [localText, setLocalText] = useState(value); const [isEditing, setIsEditing] = useState(false); const [isMarkdownEditorOpen, setIsMarkdownEditorOpen] = useState(false); @@ -290,24 +294,38 @@ export function SmartTextMarkdownContent({ setLocalText(value); }, [isEditing, value]); + // Render markdown into the shared content element while not editing. Entering + // edit mode deliberately does NOT rewrite the DOM: the same element just flips + // to contentEditable, so scroll position and text nodes stay put (no jump to + // top, and a double-click caret offset still maps cleanly). + useEffect(() => { + if (isEditing || !editorRef.current) return; + editorRef.current.innerHTML = markdownToEditorHtml(localText); + }, [isEditing, localText]); + useEffect(() => { if (!isEditing || !editorRef.current) return; const editor = editorRef.current; - editor.innerHTML = markdownToEditorHtml(draftMarkdownRef.current); - const focusEditorAtEnd = () => { - editor.focus(); - const range = document.createRange(); - range.selectNodeContents(editor); - range.collapse(false); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); + const focusEditor = () => { + editor.focus({ preventScroll: true }); + const pendingOffset = pendingCaretOffsetRef.current; + if (pendingOffset != null) { + setCaretAtCharOffset(editor, pendingOffset); + } else { + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } }; - const frameId = window.requestAnimationFrame(focusEditorAtEnd); - const timeoutId = window.setTimeout(focusEditorAtEnd, DOUBLE_CLICK_FOCUS_CONFIRM_DELAY_MS); + const frameId = window.requestAnimationFrame(focusEditor); + const timeoutId = window.setTimeout(focusEditor, DOUBLE_CLICK_FOCUS_CONFIRM_DELAY_MS); return () => { window.cancelAnimationFrame(frameId); window.clearTimeout(timeoutId); + pendingCaretOffsetRef.current = null; }; }, [isEditing]); @@ -395,8 +413,9 @@ export function SmartTextMarkdownContent({ useEffect(() => { if (!nodeId) return; const handleEditRequest = (event: Event) => { - const detail = (event as CustomEvent<{ nodeId?: string }>).detail; + const detail = (event as CustomEvent<{ nodeId?: string; caretOffset?: number }>).detail; if (detail?.nodeId !== nodeId) return; + pendingCaretOffsetRef.current = typeof detail.caretOffset === "number" ? detail.caretOffset : null; setIsEditing(true); }; @@ -431,42 +450,34 @@ export function SmartTextMarkdownContent({ )} {!isEditing ? nodeActionToolbar : null}
- {isEditing ? ( -
-
event.stopPropagation()} - onBlur={() => { - const nextText = syncFromEditor(); - setLocalText(nextText); - setIsEditing(false); - commitText(nextText); - }} - /> -
- ) : ( +
+ {/* Single persistent element for both preview and editing: entering edit + only flips contentEditable, so scroll position and text nodes are kept + (no jump to top) and a double-click caret offset lands correctly. */}
- {textValue ? ( -
- ) : ( - emptyView - )} -
- )} + ref={editorRef} + role={isEditing ? "textbox" : undefined} + aria-label={isEditing ? t("smartText.manualPlaceholder") : undefined} + contentEditable={isEditing} + suppressContentEditableWarning + data-smart-text-content + onMouseDown={isEditing ? undefined : preventPreviewNativeDoubleClickSelection} + className={`nodrag nopan nowheel bg-transparent ${MANUAL_TEXT_CONTENT_CLASS}`} + onInput={syncFromEditor} + onKeyDown={(event) => event.stopPropagation()} + onBlur={() => { + const nextText = syncFromEditor(); + setLocalText(nextText); + setIsEditing(false); + commitText(nextText); + }} + /> + {!textValue && !isEditing && ( +
+ {emptyView} +
+ )} +
{isMarkdownEditorOpen && createPortal( { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("places the caret inside a single text node", () => { + const root = makeRoot("hello world"); + setCaretAtCharOffset(root, 6); + expect(caretCharOffset(root)).toBe(6); + }); + + it("maps an offset across multiple/nested text nodes", () => { + const root = makeRoot("

ab

cdef

"); + // Text content is "abcdef"; offset 4 sits just before "e". + setCaretAtCharOffset(root, 4); + expect(caretCharOffset(root)).toBe(4); + }); + + it("clamps to the end when the offset exceeds the content length", () => { + const root = makeRoot("

abc

"); + setCaretAtCharOffset(root, 999); + expect(caretCharOffset(root)).toBe(3); + }); + + it("falls back to the start when there is no text", () => { + const root = makeRoot(""); + setCaretAtCharOffset(root, 5); + expect(caretCharOffset(root)).toBe(0); + }); +}); + +describe("getCaretCharOffsetFromPoint", () => { + it("returns null when the browser exposes no caret-from-point API (jsdom)", () => { + const root = makeRoot("hello"); + expect(getCaretCharOffsetFromPoint(0, 0, root)).toBeNull(); + }); +}); diff --git a/src/utils/caretOffset.ts b/src/utils/caretOffset.ts new file mode 100644 index 00000000..03067315 --- /dev/null +++ b/src/utils/caretOffset.ts @@ -0,0 +1,91 @@ +/** + * Translate a pointer position into a character offset within an element, and + * place the caret back at a character offset. Used so double-clicking a smart + * text node drops the caret where the user clicked: the offset is computed on + * the still-visible content before the zoom animation moves things, then + * re-applied once the element becomes editable (same element, so the offset + * maps cleanly). + * + * Offsets are counted as concatenated text-node content (UTF-16 code units). + */ + +type CaretPoint = { node: Node; offset: number }; + +function caretPointFromPoint(x: number, y: number): CaretPoint | null { + const doc = document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null; + }; + + // WebKit / Blink + if (typeof doc.caretRangeFromPoint === "function") { + const range = doc.caretRangeFromPoint(x, y); + if (!range) return null; + return { node: range.startContainer, offset: range.startOffset }; + } + + // Firefox / spec + if (typeof doc.caretPositionFromPoint === "function") { + const position = doc.caretPositionFromPoint(x, y); + if (!position) return null; + return { node: position.offsetNode, offset: position.offset }; + } + + return null; +} + +/** + * Character offset within `root` for the caret at viewport point (x, y), or + * null when the point is outside `root` or the browser exposes no + * caret-from-point API (e.g. jsdom). + */ +export function getCaretCharOffsetFromPoint(x: number, y: number, root: HTMLElement): number | null { + const point = caretPointFromPoint(x, y); + if (!point || !root.contains(point.node)) return null; + + const range = document.createRange(); + range.setStart(root, 0); + range.setEnd(point.node, point.offset); + return range.toString().length; +} + +/** + * Place the caret at `offset` characters into `root` (clamped to the end when + * the content is shorter). No-op when there is no active selection. + */ +export function setCaretAtCharOffset(root: HTMLElement, offset: number): void { + const selection = window.getSelection(); + if (!selection) return; + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let remaining = Math.max(0, offset); + let lastText: Text | null = null; + + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + const length = node.data.length; + if (remaining <= length) { + placeCaret(selection, node, remaining); + return; + } + remaining -= length; + lastText = node; + } + + if (lastText) { + placeCaret(selection, lastText, lastText.data.length); + } else { + const range = document.createRange(); + range.selectNodeContents(root); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } +} + +function placeCaret(selection: Selection, node: Text, offset: number): void { + const range = document.createRange(); + range.setStart(node, Math.min(offset, node.data.length)); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +}