diff --git a/packages/views/editor/content-editor.tsx b/packages/views/editor/content-editor.tsx index 381c15fc19..b5286976bd 100644 --- a/packages/views/editor/content-editor.tsx +++ b/packages/views/editor/content-editor.tsx @@ -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( 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(); }, diff --git a/packages/views/editor/index.ts b/packages/views/editor/index.ts index eb8623c8a9..263f0992f2 100644 --- a/packages/views/editor/index.ts +++ b/packages/views/editor/index.ts @@ -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 { diff --git a/packages/views/editor/text-anchor.test.ts b/packages/views/editor/text-anchor.test.ts new file mode 100644 index 0000000000..085551df20 --- /dev/null +++ b/packages/views/editor/text-anchor.test.ts @@ -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[]) => + 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("

abc

defgh

"); + 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("

abc

"); + 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
  • elements as a + // text node; ProseMirror's doc has no such separator. Non-whitespace + // counting must make both sides agree. + const root = mount("
    • URL: x
    • \n
    • Title: feat
    "); + 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("

    abc

    "); + 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("

    abc

    "); + docAny.caretPositionFromPoint = undefined; + expect(anchorFromPoint(0, 0, root)).toBeNull(); + }); +}); diff --git a/packages/views/editor/text-anchor.ts b/packages/views/editor/text-anchor.ts new file mode 100644 index 0000000000..fcf0dd1825 --- /dev/null +++ b/packages/views/editor/text-anchor.ts @@ -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; +} diff --git a/packages/views/editor/use-lazy-editor.ts b/packages/views/editor/use-lazy-editor.ts index b5e4862f4c..82cb3280aa 100644 --- a/packages/views/editor/use-lazy-editor.ts +++ b/packages/views/editor/use-lazy-editor.ts @@ -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 &&
    @@ -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(null); const focusPendingRef = useRef(false); const pendingFilesRef = useRef([]); @@ -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( diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index bc958b25ce..b86e4aa6a5 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -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(".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) => { if (e.key !== "Enter" && e.key !== " ") return;