mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
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:
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
159
packages/views/editor/text-anchor.test.ts
Normal file
159
packages/views/editor/text-anchor.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
158
packages/views/editor/text-anchor.ts
Normal file
158
packages/views/editor/text-anchor.ts
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user