fix(editor): reflow scroll containers after paste to refresh click hit-testing (#146)

Pasting markdown/code inserts React NodeViews that mount asynchronously; until
the next reflow the browser's hit-test geometry is stale, so ProseMirror's
posAtCoords/caretRangeFromPoint maps a click to the wrong (offset) line — which
users reported clears itself on any scroll. Reproduce that scroll's side effect
with a ZERO-delta nudge (re-assign scrollTop/scrollLeft to their current value)
on every scrollable ancestor + the document scrolling element, run across two
animation frames so it lands after the pasted content + NodeViews commit. The
nudge does not move the viewport.

Wired into editor-paste-handler's handlePaste, which ProseMirror's someProp runs
(as an editorProps handler) before the MarkdownClipboard plugin that performs the
markdown/code insert — so the nudge is scheduled on exactly the paste path that
triggers the bug. Complements the structural NodeViewContent-order fix in this
branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-24 00:37:56 +03:00
parent 38544e2ddc
commit 1c39a45bc5

View File

@@ -22,12 +22,75 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
const SCROLLABLE_OVERFLOW = new Set(["auto", "scroll", "overlay"]);
/**
* Collect every scrollable ancestor of the editor DOM whose hit-test layer
* could be stale after a paste, plus the document scrolling element. We nudge
* ALL of them (a zero-delta nudge is harmless) because the real scroll container
* varies — a styled overflow ancestor on most pages, the document itself on
* others — and `overflow: overlay` (common on macOS, where #146 reproduces)
* must count as scrollable too. Called only AFTER the paste has committed, so
* `scrollHeight > clientHeight` reflects the inserted content.
*/
function collectScrollAncestors(node: HTMLElement): HTMLElement[] {
const targets: HTMLElement[] = [];
// Walk every ancestor (incl. body/html) — on some layouts the scroll lives on
// body rather than the documentElement that scrollingElement points at.
let el: HTMLElement | null = node.parentElement;
while (el) {
const { overflowX, overflowY } = getComputedStyle(el);
const scrollsY =
SCROLLABLE_OVERFLOW.has(overflowY) && el.scrollHeight > el.clientHeight;
const scrollsX =
SCROLLABLE_OVERFLOW.has(overflowX) && el.scrollWidth > el.clientWidth;
if (scrollsY || scrollsX) targets.push(el);
el = el.parentElement;
}
const docEl = document.scrollingElement as HTMLElement | null;
if (docEl && !targets.includes(docEl)) targets.push(docEl);
return targets;
}
/**
* Re-flow the editor's scroll containers after a paste so the browser refreshes
* its click hit-testing geometry (#146). Pasting markdown/code inserts React
* NodeViews that mount ASYNCHRONOUSLY; until the next reflow, ProseMirror's
* posAtCoords/caretRangeFromPoint can map a click to a stale (offset) line —
* which users observed clears itself on any scroll. We reproduce that scroll's
* side effect with a ZERO-delta nudge (re-assign scrollTop/Left to their current
* value), invalidating the hit-test layer WITHOUT moving the viewport. The
* container lookup AND the nudge run across two animation frames so they happen
* AFTER the pasted content + NodeViews commit (only then is the real scroll
* container measurable).
*/
function reflowAfterPaste(editor: Editor) {
const dom = editor.view.dom as HTMLElement;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
for (const el of collectScrollAncestors(dom)) {
// Capture into locals first so this reads as a scroll nudge, not a
// no-op self-assignment (which lint would flag), while still poking the
// scroll position to refresh hit-testing.
const { scrollTop, scrollLeft } = el;
el.scrollTop = scrollTop;
el.scrollLeft = scrollLeft;
}
});
});
}
export const handlePaste = (
editor: Editor,
event: ClipboardEvent,
pageId: string,
creatorId?: string,
) => {
// Schedule a post-paste reflow for every paste path: the pasted content (and
// its async NodeViews) settles after this handler returns, so we nudge on the
// next frames to keep click hit-testing aligned (#146).
reflowAfterPaste(editor);
const clipboardData = event.clipboardData.getData("text/plain");
if (INTERNAL_LINK_REGEX.test(clipboardData)) {