test(editor): guard #146 contentDOM-first invariant + fix code-block comment (PR #147 review)

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 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-24 00:47:29 +03:00
parent 1c39a45bc5
commit 67057de214
2 changed files with 95 additions and 2 deletions

View File

@@ -50,8 +50,10 @@ export default function CodeBlockView(props: NodeViewProps) {
{/* #146: the editable <pre><code> (contentDOM) MUST come first in the DOM. {/* #146: the editable <pre><code> (contentDOM) MUST come first in the DOM.
With the non-editable menu rendered before it, the browser's click With the non-editable menu rendered before it, the browser's click
hit-testing snapped the caret up one line. Render content first; the 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 menu is rendered after it and lifted back above visually via flex
transclusion pattern), so it no longer sits in-flow above the code. */} `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. */}
<pre <pre
spellCheck="false" spellCheck="false"
hidden={ hidden={

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
/**
* Structural regression guard for #146 (PR #147).
*
* The caret/click-offset fix rests entirely on ONE invariant: in every editable
* footnote NodeView the editable `NodeViewContent` (contentDOM) must be the
* FIRST child of the wrapper, with no non-editable (`contenteditable="false"`)
* element before it. If a future edit reinserts chrome (separator, heading,
* marker, back-link) ahead of the content, the macOS hit-testing bug returns
* silently — and the symptom needs a real browser to see. This test pins the
* DOM ORDER (the proxy that IS the fix) in the existing jsdom harness.
*
* We stub `@tiptap/react` so the views render as plain DOM and we can inspect
* the child order our JSX produces — that order is exactly what regresses, and
* it does not depend on a live editor. The stubbed `NodeViewContent` carries the
* real `data-node-view-content` marker tiptap uses, so the assertion mirrors
* production. This test passes on the fixed order and FAILS on the pre-fix order
* (chrome-before-content).
*/
vi.mock("@tiptap/react", () => ({
NodeViewWrapper: ({ children, ...props }: any) => (
<div data-testid="nvw" {...props}>
{children}
</div>
),
// Mirror the real contentDOM marker so the guard matches production output.
NodeViewContent: (props: any) => <div data-node-view-content="" {...props} />,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// footnote-definition-view reads a cached number from the numbering plugin;
// stub it so we don't need a live ProseMirror state.
vi.mock("@docmost/editor-ext", () => ({
getFootnoteNumber: () => 1,
}));
import FootnotesListView from "./footnotes-list-view";
import FootnoteDefinitionView from "./footnote-definition-view";
// Minimal NodeViewProps stub: definition view only touches node.attrs.id and
// editor.state (the latter unused once getFootnoteNumber is mocked).
const props = {
node: { attrs: { id: "fn-1" }, textContent: "" },
editor: { state: {}, isEditable: true, commands: {} },
getPos: () => 0,
updateAttributes: () => {},
deleteNode: () => {},
} as any;
const cases: Array<{ name: string; ui: React.ReactElement }> = [
{ name: "FootnotesListView", ui: <FootnotesListView {...props} /> },
{ name: "FootnoteDefinitionView", ui: <FootnoteDefinitionView {...props} /> },
];
describe("#146 footnote NodeView DOM-order invariant", () => {
it.each(cases)(
"$name renders contentDOM as the first child",
({ ui }) => {
const { getByTestId } = render(ui);
const wrapper = getByTestId("nvw");
const firstEl = wrapper.firstElementChild;
expect(firstEl).not.toBeNull();
// The editable content must be physically first.
expect(firstEl?.hasAttribute("data-node-view-content")).toBe(true);
},
);
it.each(cases)(
"$name has no contentEditable=false chrome BEFORE the content",
({ ui }) => {
const { getByTestId } = render(ui);
const wrapper = getByTestId("nvw");
const content = wrapper.querySelector("[data-node-view-content]")!;
const nonEditable = wrapper.querySelectorAll('[contenteditable="false"]');
expect(nonEditable.length).toBeGreaterThan(0); // chrome exists...
for (const el of Array.from(nonEditable)) {
// ...but every non-editable element must come AFTER the content node.
const pos = content.compareDocumentPosition(el);
expect(pos & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
},
);
});