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:
@@ -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={
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user