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:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user