Browse Source

文本节点双击 还原 滚动条 和 选中的文本位置

feature/create-task
Luckyu_js 5 days ago
parent
commit
f6015ace75
  1. 15
      src/components/WorkflowCanvas.tsx
  2. 105
      src/components/nodes/SmartTextNode.tsx
  3. 57
      src/utils/__tests__/caretOffset.test.ts
  4. 91
      src/utils/caretOffset.ts

15
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<HTMLElement>("[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]);

105
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<SmartTextNodeData, "smartText">;
@ -279,6 +280,9 @@ export function SmartTextMarkdownContent({
const fileInputRef = useRef<HTMLInputElement>(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<number | null>(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}
<div className="flex h-full w-full overflow-hidden rounded-lg bg-[var(--surface-1)] p-5">
{isEditing ? (
<div className="relative h-full w-full">
<div
ref={editorRef}
role="textbox"
aria-label={t("smartText.manualPlaceholder")}
contentEditable
suppressContentEditableWarning
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);
}}
/>
</div>
) : (
<div className="relative h-full w-full">
{/* 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. */}
<div
onMouseDown={preventPreviewNativeDoubleClickSelection}
className={`nowheel flex h-full w-full rounded-md outline-none ${
textValue ? "items-start justify-start overflow-auto text-left" : "items-center justify-center text-center"
}`}
>
{textValue ? (
<div
className={MANUAL_TEXT_CONTENT_CLASS}
dangerouslySetInnerHTML={{ __html: markdownToEditorHtml(localText) }}
/>
) : (
emptyView
)}
</div>
)}
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 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-center">
{emptyView}
</div>
)}
</div>
</div>
{isMarkdownEditorOpen && createPortal(
<MarkdownEditorModal

57
src/utils/__tests__/caretOffset.test.ts

@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from "vitest";
import { getCaretCharOffsetFromPoint, setCaretAtCharOffset } from "@/utils/caretOffset";
function makeRoot(html: string): HTMLElement {
const root = document.createElement("div");
root.innerHTML = html;
document.body.appendChild(root);
return root;
}
function caretCharOffset(root: HTMLElement): number {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return -1;
const range = selection.getRangeAt(0).cloneRange();
const measure = document.createRange();
measure.setStart(root, 0);
measure.setEnd(range.startContainer, range.startOffset);
return measure.toString().length;
}
describe("setCaretAtCharOffset", () => {
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("<p>ab</p><p><strong>cd</strong>ef</p>");
// 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("<p>abc</p>");
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();
});
});

91
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);
}
Loading…
Cancel
Save