From 1c39a45bc5e229ac77e0e4aa4e67b73f51c1dac3 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 00:37:56 +0300 Subject: [PATCH] fix(editor): reflow scroll containers after paste to refresh click hit-testing (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../common/editor-paste-handler.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 85d49872..08e47766 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -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)) {