From 38544e2ddc27796d19fb62e38a83efb3571efd37 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Tue, 23 Jun 2026 21:48:21 +0300 Subject: [PATCH 1/4] fix(editor): render NodeViewContent first so click hit-testing isn't offset (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three editable NodeViews rendered a contentEditable=false "chrome" element IN FLOW BEFORE NodeViewContent. On macOS the browser's click hit-testing (posAtCoords → caretRangeFromPoint) then misses the contentDOM and snaps the caret to the previous node — the caret/selection lands a line (code block) or several lines (footnotes, into the body) above where the user clicked. Fix (the transclusion pattern / issue #146 plan): make the editable NodeViewContent the FIRST child in the DOM and move the non-editable chrome AFTER it, restoring its visual position with CSS: - code-block-view:
first; the language/copy menu follows and is lifted above via flex `order` (.codeBlock is now a flex column). - footnotes-list-view: NodeViewContent first; the "Footnotes" heading follows and is lifted above via flex `order` (.list is a flex column; the separator border stays on the container). - footnote-definition-view: NodeViewContent first; the "N." marker follows with `order:-1` to stay on the left; the ↩ back-link stays on the right. Layout is visually unchanged. Verified in a real browser (Chromium): the contentDOM is now the first child of every editable NodeView wrapper (no contentEditable=false element precedes it), and the menu/heading/marker still render in their original positions. NOTE: the caret-offset itself is macOS-specific text hit-testing and does not reproduce in headless Chromium/WebKit on Linux (verified extensively), so the visible fix is best confirmed on macOS. Co-Authored-By: Claude Opus 4.8 --- .../components/code-block/code-block-view.tsx | 29 +++++++++++-------- .../code-block/code-block.module.css | 7 +++++ .../footnote/footnote-definition-view.tsx | 6 +++- .../components/footnote/footnote.module.css | 8 ++++- .../footnote/footnotes-list-view.tsx | 20 +++++++++---- .../src/features/editor/styles/code.css | 4 +++ 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/apps/client/src/features/editor/components/code-block/code-block-view.tsx b/apps/client/src/features/editor/components/code-block/code-block-view.tsx index 0ff2fe36..1b931926 100644 --- a/apps/client/src/features/editor/components/code-block/code-block-view.tsx +++ b/apps/client/src/features/editor/components/code-block/code-block-view.tsx @@ -47,6 +47,23 @@ export default function CodeBlockView(props: NodeViewProps) { return ( + {/* #146: the editable
 (contentDOM) MUST come first in the DOM.
+          With the non-editable menu rendered before it, the browser's click
+          hit-testing snapped the caret up one line. Render content first; the
+          menu follows and is positioned as a top-right overlay via CSS (the
+          transclusion pattern), so it no longer sits in-flow above the code. */}
+      
+
       
       
 
-      
-
       {language === "mermaid" && (
         
           
diff --git a/apps/client/src/features/editor/components/code-block/code-block.module.css b/apps/client/src/features/editor/components/code-block/code-block.module.css
index 6e0a5dd3..4ecda370 100644
--- a/apps/client/src/features/editor/components/code-block/code-block.module.css
+++ b/apps/client/src/features/editor/components/code-block/code-block.module.css
@@ -17,7 +17,14 @@
     justify-content: center;
 }
 
+/* #146: the menu now follows the 
 in the DOM (so the editable contentDOM is
+   FIRST and click hit-testing is correct). Lift it back ABOVE the code visually
+   with flex `order` — the .codeBlock wrapper is a flex column (see code.css) —
+   so the menu still reads as a row above the code, exactly as before, without
+   sitting in-flow before the contentDOM. */
 .menuGroup {
+    order: -1;
+
     @media print {
         display: none;
     }
diff --git a/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx b/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx
index 2685fbc3..ef4b5eab 100644
--- a/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx
+++ b/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx
@@ -29,10 +29,14 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
       className={classes.definition}
       style={{ ["--footnote-number" as any]: `"${number}"` }}
     >
+      {/* #146: contentDOM MUST be the first child — a non-editable marker before
+          it makes click hit-testing snap the caret above. Content first; the
+          marker + back-link follow in DOM and are placed left/right via CSS
+          flex `order`. */}
+      
       
         {number}.
       
-      
       
-      
-
{t("Footnotes")}
-
+ +
+ {t("Footnotes")} +
); } diff --git a/apps/client/src/features/editor/styles/code.css b/apps/client/src/features/editor/styles/code.css index fba5db91..100e4153 100644 --- a/apps/client/src/features/editor/styles/code.css +++ b/apps/client/src/features/editor/styles/code.css @@ -1,5 +1,9 @@ .ProseMirror { .codeBlock { + /* #146: flex column so the menu (rendered AFTER
 in the DOM, so the
+       editable contentDOM is first) is lifted back above the code via `order`. */
+    display: flex;
+    flex-direction: column;
     padding: 4px;
     border-radius: var(--mantine-radius-default);
     background-color: light-dark(var(--mantine-color-gray-0),  var(--mantine-color-dark-8));

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 2/4] 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)) {

From 67057de214032b6069151e3a7977deb0eb42b9cc Mon Sep 17 00:00:00 2001
From: claude code agent 227 
Date: Wed, 24 Jun 2026 00:47:29 +0300
Subject: [PATCH 3/4] test(editor): guard #146 contentDOM-first invariant + fix
 code-block comment (PR #147 review)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Addresses the #147 review (Approve with comments):
- Add footnote-views.structure.test.tsx: a structural regression guard asserting
  the editable NodeViewContent is the FIRST child of FootnotesListView and
  FootnoteDefinitionView, with no contenteditable=false chrome before it. The
  whole #146 fix rests on this DOM-order invariant; the macOS caret symptom needs
  a real browser, but the order proxy is testable in jsdom. Stubs @tiptap/react
  so the views render as plain DOM — the test passes on the fixed order and fails
  on the pre-fix chrome-first order.
- Reword the code-block-view comment: it claimed a "top-right overlay (the
  transclusion pattern)", but the menu stays fully in flow as a full-width row
  lifted via flex `order: -1` (the .codeBlock wrapper is a flex column). No
  overlay/absolute positioning.

Co-Authored-By: Claude Opus 4.8 
---
 .../components/code-block/code-block-view.tsx |  6 +-
 .../footnote-views.structure.test.tsx         | 91 +++++++++++++++++++
 2 files changed, 95 insertions(+), 2 deletions(-)
 create mode 100644 apps/client/src/features/editor/components/footnote/footnote-views.structure.test.tsx

diff --git a/apps/client/src/features/editor/components/code-block/code-block-view.tsx b/apps/client/src/features/editor/components/code-block/code-block-view.tsx
index 1b931926..1b67ddf6 100644
--- a/apps/client/src/features/editor/components/code-block/code-block-view.tsx
+++ b/apps/client/src/features/editor/components/code-block/code-block-view.tsx
@@ -50,8 +50,10 @@ export default function CodeBlockView(props: NodeViewProps) {
       {/* #146: the editable 
 (contentDOM) MUST come first in the DOM.
           With the non-editable menu rendered before it, the browser's click
           hit-testing snapped the caret up one line. Render content first; the
-          menu follows and is positioned as a top-right overlay via CSS (the
-          transclusion pattern), so it no longer sits in-flow above the code. */}
+          menu is rendered after it and lifted back above visually via flex
+          `order: -1` (the `.codeBlock` wrapper is a flex column — see
+          code-block.module.css). It stays fully in flow as a full-width row
+          above the code: no overlay/absolute positioning. */}