fix(editor): land lazy-editor caret via text anchor instead of coords

posAtCoords assumed the readonly stand-in and the Tiptap render are
pixel-identical; on long documents per-block height differences
accumulate and NodeViews keep reflowing after onReady, so the caret
landed lines away from the click.

Replace pixel mapping with a logical text anchor: the click records
"block N, non-whitespace character M" (whitespace excluded because the
two renderers disagree about inter-element whitespace inside lists), a
bias bit disambiguates word/line boundaries, and the editor resolves it
by walking the ProseMirror doc. Coordinates remain as fallback for
clicks that yield no text position and for the single-line title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-07-14 18:17:19 +08:00
parent eddeef0eb3
commit de6e688381
6 changed files with 393 additions and 21 deletions

View File

@@ -61,6 +61,7 @@ import { preprocessMarkdown } from "./utils/preprocess";
import { repairEmptyListItems } from "./utils/repair-list-items";
import { openLink, isMentionHref } from "./utils/link-handler";
import { EditorBubbleMenu } from "./bubble-menu";
import { posFromAnchor, type TextAnchor } from "./text-anchor";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { AttachmentDownloadProvider } from "./attachment-download-context";
import "katex/dist/katex.min.css";
@@ -187,6 +188,15 @@ interface ContentEditorRef {
* while the editor element is laid out (not display: none).
*/
focusAtCoords: (coords: { x: number; y: number }) => void;
/**
* Focus and place the caret at the document position a text anchor
* resolves to. Preferred over `focusAtCoords` for readonly-first hosts:
* the anchor is a logical position ("block N, character M"), so it is
* immune to layout differences between the readonly render and the
* editor render — which is exactly where pixel coordinates drift on
* long documents.
*/
focusAtAnchor: (anchor: TextAnchor) => void;
/** Drop focus from the editor — used by chat after send so the caret
* stops competing with the StatusPill / streaming reply for the user's
* attention. */
@@ -601,6 +611,14 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (pos) editor.commands.focus(pos.pos);
else editor.commands.focus("end");
},
focusAtAnchor: (anchor: TextAnchor) => {
if (!editor) {
// Editor not mounted yet — degrade to the latched plain focus.
focusOnReadyRef.current = true;
return;
}
editor.commands.focus(posFromAnchor(editor.state.doc, anchor));
},
blur: () => {
editor?.commands.blur();
},

View File

@@ -11,7 +11,8 @@ export {
export { ReadonlyContent } from "./readonly-content";
export { useFileDropZone } from "./use-file-drop-zone";
export { FileDropOverlay } from "./file-drop-overlay";
export { useLazyEditor, type LazyEditorHandle } from "./use-lazy-editor";
export { useLazyEditor, type LazyEditorHandle, type LazyFocusTarget } from "./use-lazy-editor";
export { anchorFromPoint, type TextAnchor } from "./text-anchor";
export { useDownloadAttachment } from "./use-download-attachment";
export { AttachmentDownloadProvider } from "./attachment-download-context";
export {

View File

@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { Schema } from "@tiptap/pm/model";
import { anchorFromPoint, posFromAnchor } from "./text-anchor";
// Minimal schema — enough shape to exercise block walking, inline atoms
// (mention-like zero-text nodes), leaf text blocks (code), and nested lists.
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "inline*" },
codeBlock: { group: "block", content: "text*", marks: "" },
bulletList: { group: "block", content: "listItem+" },
listItem: { content: "paragraph+" },
mention: { group: "inline", inline: true, atom: true },
text: { group: "inline" },
},
});
const text = (s: string) => schema.text(s);
const p = (...content: ReturnType<typeof text>[]) =>
schema.node("paragraph", null, content);
describe("posFromAnchor", () => {
it("lands before the next word or after the previous one, per bias", () => {
const doc = schema.node("doc", null, [p(text("hello world"))]);
// 5 non-whitespace chars before the caret; the space is ambiguous.
expect(posFromAnchor(doc, { block: 0, offset: 5, bias: 1 })).toBe(7); // ▮world
expect(posFromAnchor(doc, { block: 0, offset: 5, bias: -1 })).toBe(6); // hello▮
});
it("maps offsets in later blocks past preceding node sizes", () => {
const doc = schema.node("doc", null, [p(text("abc")), p(text("defgh"))]);
// Mid-word there is no whitespace ambiguity: both biases agree.
expect(posFromAnchor(doc, { block: 1, offset: 2, bias: 1 })).toBe(8);
expect(posFromAnchor(doc, { block: 1, offset: 2, bias: -1 })).toBe(8);
});
it("skips zero-text inline atoms without counting them", () => {
const doc = schema.node("doc", null, [
p(text("hi "), schema.node("mention"), text(" yo")),
]);
// Doc non-whitespace chars: h i y o. Offset 3 = caret before the "o".
expect(posFromAnchor(doc, { block: 0, offset: 3, bias: 1 })).toBe(7);
// Offset 4 = past the last one — lands right after it.
expect(posFromAnchor(doc, { block: 0, offset: 4, bias: 1 })).toBe(8);
});
it("clamps an overshooting offset to the block's content end", () => {
const doc = schema.node("doc", null, [p(text("abc")), p(text("xyz"))]);
expect(posFromAnchor(doc, { block: 0, offset: 99, bias: 1 })).toBe(4);
});
it("clamps an out-of-range block index to the document end", () => {
const doc = schema.node("doc", null, [p(text("abc"))]);
expect(posFromAnchor(doc, { block: 7, offset: 0, bias: 1 })).toBe(
doc.content.size,
);
});
it("distinguishes line end from next line start in code blocks", () => {
const doc = schema.node("doc", null, [
p(text("intro")),
schema.node("codeBlock", null, [text("line1\nline2")]),
]);
// 5 non-whitespace chars ("line1") precede both carets; the \n between
// them is whitespace, so only the bias separates the two intents.
expect(posFromAnchor(doc, { block: 1, offset: 5, bias: -1 })).toBe(13); // line1▮
expect(posFromAnchor(doc, { block: 1, offset: 5, bias: 1 })).toBe(14); // ▮line2
});
});
describe("anchorFromPoint", () => {
// `as unknown` sidesteps lib.dom's full CaretPosition shape — the code
// under test only reads offsetNode/offset.
const docAny = document as unknown as {
caretPositionFromPoint?: (x: number, y: number) => {
offsetNode: Node;
offset: number;
} | null;
};
const originalCaret = docAny.caretPositionFromPoint;
afterEach(() => {
docAny.caretPositionFromPoint = originalCaret;
document.body.innerHTML = "";
});
function mount(html: string): HTMLElement {
const root = document.createElement("div");
root.innerHTML = html;
document.body.appendChild(root);
return root;
}
it("counts non-whitespace chars before the caret and biases toward text", () => {
const root = mount("<p>abc</p><p><strong>de</strong>fgh</p>");
const second = root.children[1] as HTMLElement;
// Caret one char into "fgh" — mid-word, next char is 'g'.
docAny.caretPositionFromPoint = () => ({
offsetNode: second.childNodes[1] as Node,
offset: 1,
});
expect(anchorFromPoint(0, 0, root)).toEqual({ block: 1, offset: 3, bias: 1 });
});
it("biases backward when the caret sits at the end of the text", () => {
const root = mount("<p>abc</p>");
const para = root.children[0] as HTMLElement;
docAny.caretPositionFromPoint = () => ({
offsetNode: para.firstChild as Node,
offset: 3,
});
expect(anchorFromPoint(0, 0, root)).toEqual({ block: 0, offset: 3, bias: -1 });
});
it("ignores inter-element whitespace artifacts (the list drift bug)", () => {
// react-markdown keeps the source newline between <li> elements as a
// text node; ProseMirror's doc has no such separator. Non-whitespace
// counting must make both sides agree.
const root = mount("<ul><li>URL: x</li>\n<li>Title: feat</li></ul>");
const li2 = root.querySelector("li:nth-child(2)") as HTMLElement;
docAny.caretPositionFromPoint = () => ({
offsetNode: li2.firstChild as Node,
offset: 7, // "Title: ▮feat"
});
const anchor = anchorFromPoint(0, 0, root);
// "URL:x" (5) + "Title:" (6) — the artifact \n and real spaces excluded.
expect(anchor).toEqual({ block: 0, offset: 11, bias: 1 });
// The same anchor resolves to right before "feat" in the PM doc.
const doc = schema.node("doc", null, [
schema.node("bulletList", null, [
schema.node("listItem", null, [p(text("URL: x"))]),
schema.node("listItem", null, [p(text("Title: feat"))]),
]),
]);
const pos = posFromAnchor(doc, anchor!);
expect(doc.textBetween(pos, pos + 4)).toBe("feat");
});
it("returns null when the caret lands outside the root", () => {
const root = mount("<p>abc</p>");
const stranger = document.createElement("p");
stranger.textContent = "elsewhere";
document.body.appendChild(stranger);
docAny.caretPositionFromPoint = () => ({
offsetNode: stranger.firstChild as Node,
offset: 0,
});
expect(anchorFromPoint(0, 0, root)).toBeNull();
});
it("returns null when no caret API is available", () => {
const root = mount("<p>abc</p>");
docAny.caretPositionFromPoint = undefined;
expect(anchorFromPoint(0, 0, root)).toBeNull();
});
});

View File

@@ -0,0 +1,158 @@
/**
* Text-anchor caret mapping between the readonly stand-in and the editor.
*
* The lazy-editor swap used to land the caret with raw pixel coordinates
* (`posAtCoords`), which assumes the readonly render and the Tiptap render
* are pixel-identical. They never are on long documents: per-block height
* differences (tables, code headers, mention cards) accumulate top-to-bottom,
* and NodeViews/images keep reflowing after `onReady` — so clicks landed
* lines away from the target. A text anchor sidesteps layout entirely:
* record WHICH character of WHICH top-level block was clicked, then resolve
* that logical position in the ProseMirror document.
*
* Offsets count NON-WHITESPACE characters only. The two renderers disagree
* about whitespace inside container blocks — react-markdown keeps the HTML
* source's inter-element newlines as text nodes ("URL: x\nTitle: y"), while
* ProseMirror's textContent concatenates with no separator ("URL: xTitle: y")
* — so raw character offsets drift one per list item / nested block. The
* non-whitespace character sequence, however, is identical on both sides.
* The whitespace ambiguity this creates at word boundaries ("end of this
* word" vs "start of the next") is resolved by `bias`, captured from what
* the user actually clicked next to.
*
* Known drift: content the two renderers textify differently (a mention's
* display name is text in the readonly render but an atom — zero text — in
* the doc) shifts offsets within that one block; resolution clamps to the
* block end, so worst case the caret lands earlier in the same block, never
* in another block.
*/
import type { Node as PMNode } from "@tiptap/pm/model";
export interface TextAnchor {
/** Index of the top-level block (readonly root child ↔ doc child). */
block: number;
/** Count of non-whitespace characters before the caret within the block. */
offset: number;
/**
* Which neighbor the caret attaches to when whitespace separates the
* offset-th non-whitespace character from the next one: 1 = just before
* the next non-whitespace character (user clicked a line/word start),
* -1 = just after the previous one (user clicked a line/word end).
*/
bias: 1 | -1;
}
const NON_WS = /\S/;
function countNonWs(s: string): number {
let n = 0;
for (let i = 0; i < s.length; i++) if (NON_WS.test(s.charAt(i))) n++;
return n;
}
/**
* Resolve a click point inside the readonly render to a text anchor.
* `root` is the markdown container whose element children correspond 1:1 to
* the document's top-level blocks. Returns null when the point yields no
* caret position (unsupported API, click on empty margin below content) —
* callers fall back to coordinate focus.
*/
export function anchorFromPoint(
x: number,
y: number,
root: HTMLElement,
): TextAnchor | null {
const doc = root.ownerDocument;
let node: Node | null = null;
let nodeOffset = 0;
// Standard API (Chromium/Firefox), then the WebKit legacy one.
if (typeof doc.caretPositionFromPoint === "function") {
const p = doc.caretPositionFromPoint(x, y);
if (p) {
node = p.offsetNode;
nodeOffset = p.offset;
}
} else if (typeof doc.caretRangeFromPoint === "function") {
const r = doc.caretRangeFromPoint(x, y);
if (r) {
node = r.startContainer;
nodeOffset = r.startOffset;
}
}
if (!node || !root.contains(node) || node === root) return null;
// Climb to the top-level block (the direct child of `root`).
let blockEl: Node = node;
while (blockEl.parentNode && blockEl.parentNode !== root) {
blockEl = blockEl.parentNode;
}
if (blockEl.parentNode !== root || blockEl.nodeType !== Node.ELEMENT_NODE) {
return null;
}
const block = Array.prototype.indexOf.call(root.children, blockEl);
if (block < 0) return null;
// Text before the caret within the block. Range.toString() handles both
// text-node carets and element carets (offset = child index), and yields
// a prefix of the block's textContent — which lets us peek at the char
// right after the caret for the bias.
const range = doc.createRange();
try {
range.setStart(blockEl, 0);
range.setEnd(node, nodeOffset);
} catch {
return { block, offset: 0, bias: 1 };
}
const before = range.toString();
const nextChar = (blockEl.textContent ?? "").charAt(before.length);
return {
block,
offset: countNonWs(before),
bias: NON_WS.test(nextChar) ? 1 : -1,
};
}
/**
* Resolve a text anchor to a ProseMirror position. Out-of-range values clamp
* (block → last block, offset → block end) so a stale or drifted anchor still
* lands inside the intended block instead of throwing.
*/
export function posFromAnchor(doc: PMNode, anchor: TextAnchor): number {
if (doc.childCount === 0) return 0;
if (anchor.block >= doc.childCount) return doc.content.size;
const blockIndex = Math.max(0, anchor.block);
let blockStart = 0;
for (let i = 0; i < blockIndex; i++) blockStart += doc.child(i).nodeSize;
const block = doc.child(blockIndex);
const contentStart = blockStart + 1;
const wanted = Math.max(0, anchor.offset);
let seen = 0;
// Caret position right after the `wanted`-th non-whitespace character
// (block content start while none have been passed yet).
let afterPrev = contentStart;
let resolved: number | null = null;
block.descendants((child, pos) => {
if (resolved !== null) return false;
if (!child.isText) return true;
const text = child.text ?? "";
for (let i = 0; i < text.length; i++) {
if (!NON_WS.test(text.charAt(i))) continue;
if (seen === wanted) {
// This is the first non-whitespace character AFTER the caret.
resolved = anchor.bias === 1 ? contentStart + pos + i : afterPrev;
return false;
}
seen++;
afterPrev = contentStart + pos + i + 1;
}
return true;
});
if (resolved !== null) return resolved;
// Caret past the last non-whitespace character: exact end lands after it;
// an overshoot (renderer textified something the doc doesn't, e.g. a
// mention label) clamps to the block's content end.
return seen === wanted ? afterPrev : blockStart + block.nodeSize - 1;
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { TextAnchor } from "./text-anchor";
/**
* Minimal imperative surface useLazyEditor needs from the wrapped editor.
@@ -9,9 +10,22 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react"
export interface LazyEditorHandle {
focus: () => void;
focusAtCoords?: (coords: { x: number; y: number }) => void;
focusAtAnchor?: (anchor: TextAnchor) => void;
uploadFile?: (file: File) => void;
}
/**
* Where the caret should land after the swap. `anchor` (logical "block N,
* character M", layout-independent) wins over the raw click coordinates;
* coordinates remain as the fallback for clicks that yield no text anchor
* (image, table chrome, margin) and for single-line hosts like the title.
*/
export interface LazyFocusTarget {
x: number;
y: number;
anchor?: TextAnchor;
}
export interface UseLazyEditorOptions {
/**
* Mount the real editor immediately instead of the static stand-in.
@@ -47,8 +61,9 @@ export interface UseLazyEditorOptions {
* the content never blanks while Tiptap assembles.
* 3. The editor's `onReady` flips `ready`; the host swaps visibility in
* that commit, and the effect below (running after that commit, so the
* editor is laid out) lands the caret at the activating click's
* coordinates and flushes any uploads queued against the stand-in.
* editor is laid out) lands the caret at the activating click's focus
* target — text anchor first, raw coordinates as fallback — and
* flushes any uploads queued against the stand-in.
*
* Host render contract:
* {lazy.active && <div className={lazy.ready ? undefined : "hidden"}>
@@ -66,7 +81,7 @@ export function useLazyEditor({
}: UseLazyEditorOptions) {
const [active, setActive] = useState(initialActive);
const [ready, setReady] = useState(false);
const focusCoordsRef = useRef<{ x: number; y: number } | null>(null);
const focusTargetRef = useRef<LazyFocusTarget | null>(null);
const focusPendingRef = useRef(false);
const pendingFilesRef = useRef<File[]>([]);
@@ -76,46 +91,56 @@ export function useLazyEditor({
setPrevResetKey(resetKey);
setActive(initialActive);
setReady(false);
focusCoordsRef.current = null;
focusTargetRef.current = null;
focusPendingRef.current = false;
pendingFilesRef.current = [];
}
const focusAtTarget = useCallback(
(target: LazyFocusTarget | null) => {
const handle = editorRef.current;
if (!handle) return;
if (target?.anchor && handle.focusAtAnchor) handle.focusAtAnchor(target.anchor);
else if (target && handle.focusAtCoords) handle.focusAtCoords(target);
else handle.focus();
},
[editorRef],
);
const activate = useCallback(
(coords?: { x: number; y: number }) => {
focusCoordsRef.current = coords ?? null;
(target?: LazyFocusTarget) => {
focusTargetRef.current = target ?? null;
focusPendingRef.current = true;
setActive(true);
// Already swapped in (e.g. keyboard re-activation after a blur):
// focus straight away, there is no ready-effect coming.
if (ready) {
focusPendingRef.current = false;
const target = focusCoordsRef.current;
focusCoordsRef.current = null;
if (target && editorRef.current?.focusAtCoords) editorRef.current.focusAtCoords(target);
else editorRef.current?.focus();
const latched = focusTargetRef.current;
focusTargetRef.current = null;
focusAtTarget(latched);
}
},
[ready, editorRef],
[ready, focusAtTarget],
);
const onReady = useCallback(() => setReady(true), []);
// Post-swap work — runs after the commit that revealed the ready editor,
// so focusAtCoords can resolve layout and uploads insert into a live doc.
// so focus targets can resolve against a laid-out, live doc and uploads
// insert into it.
useEffect(() => {
if (!ready) return;
if (focusPendingRef.current) {
focusPendingRef.current = false;
const coords = focusCoordsRef.current;
focusCoordsRef.current = null;
if (coords && editorRef.current?.focusAtCoords) editorRef.current.focusAtCoords(coords);
else editorRef.current?.focus();
const target = focusTargetRef.current;
focusTargetRef.current = null;
focusAtTarget(target);
}
const pending = pendingFilesRef.current;
pendingFilesRef.current = [];
for (const file of pending) editorRef.current?.uploadFile?.(file);
}, [ready, editorRef]);
}, [ready, editorRef, focusAtTarget]);
/** Upload now when the editor is live; otherwise queue and summon it. */
const uploadOrQueue = useCallback(

View File

@@ -30,7 +30,7 @@ import { Button } from "@multica/ui/components/ui/button";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@multica/ui/components/ui/resizable";
import { Sheet, SheetContent } from "@multica/ui/components/ui/sheet";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { ContentEditor, type ContentEditorRef, ReadonlyContent, TitleEditor, type TitleEditorRef, useFileDropZone, FileDropOverlay, useLazyEditor } from "../../editor";
import { ContentEditor, type ContentEditorRef, ReadonlyContent, TitleEditor, type TitleEditorRef, useFileDropZone, FileDropOverlay, useLazyEditor, anchorFromPoint } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import {
Tooltip,
@@ -1299,7 +1299,8 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
// ReadonlyContent shares the editor's `rich-text-editor` stylesheet, so
// the swap is visually seamless. The click that used to place a caret now
// calls `activate`; useLazyEditor mounts the editor hidden, swaps it in on
// `onReady`, and lands the caret at the clicked coordinates.
// `onReady`, and lands the caret at the clicked text anchor (coordinates
// only as fallback — see text-anchor.ts for why pixels drift).
// resetKey: the web issue route reuses this component across issues, so a
// subject switch must fold both regions back to their stand-ins during the
// id-change render itself — an effect would let the new issue's keyed
@@ -1321,7 +1322,17 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
// A drag-selection (copying text) must not summon the editor.
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
descLazy.activate({ x: e.clientX, y: e.clientY });
// Prefer a text anchor ("block N, character M") over raw coordinates:
// the readonly render and the editor render are never pixel-identical
// on long documents, so posAtCoords drifts; a logical anchor doesn't.
// The `.readonly` qualifier skips nested `pre.rich-text-editor` blocks;
// the empty-placeholder stand-in has no readonly root and falls back to
// coordinates, which is fine for an empty document.
const readonlyRoot = e.currentTarget.querySelector<HTMLElement>(".rich-text-editor.readonly");
const anchor = readonlyRoot
? anchorFromPoint(e.clientX, e.clientY, readonlyRoot)
: null;
descLazy.activate({ x: e.clientX, y: e.clientY, anchor: anchor ?? undefined });
};
const handleDescReadonlyKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== "Enter" && e.key !== " ") return;