test(editor): address PR #147 review — reflow tests, code-block guard, a11y
Resolve the pre-merge review items for the #146 NodeView content-first fix: - Export collectScrollAncestors/reflowAfterPaste and add editor-paste-handler unit tests covering ancestor selection (overlay included, non-overflowing auto excluded, X axis), the scrollHeight>clientHeight gate, scrollingElement dedup, the docEl==null branch, and the double-rAF nudge. - Extend the structural guard with CodeBlockView and merge the two it.each blocks into one document-order assertion (handles the <pre> nesting where the contentDOM is not the literal first child). - Simplify the post-paste nudge to a single scrollTo(scrollLeft, scrollTop). - Document that the post-paste reflow runs on every paste path intentionally, and cross-reference the two #146 mitigations in both fixes. - a11y: aria-hidden the decorative footnotes heading and number marker, and label the footnotes list via role="group" + aria-label so the visual reorder does not break screen-reader reading order (WCAG 1.3.2). - CHANGELOG: add a Fixed entry noting the caret fix is macOS-verified manually. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,14 @@ import { render } from "@testing-library/react";
|
||||
/**
|
||||
* Structural regression guard for #146 (PR #147).
|
||||
*
|
||||
* Guards ALL THREE editable NodeViews touched by the fix: the two footnote views
|
||||
* (FootnotesListView, FootnoteDefinitionView) AND the code block (CodeBlockView).
|
||||
*
|
||||
* 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
|
||||
* NodeView the editable `NodeViewContent` (contentDOM) must come FIRST in the
|
||||
* wrapper, with no non-editable (`contenteditable="false"`) element before it.
|
||||
* If a future edit reinserts chrome (separator, heading, marker, back-link,
|
||||
* language menu) 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.
|
||||
*
|
||||
@@ -39,8 +42,36 @@ vi.mock("@docmost/editor-ext", () => ({
|
||||
getFootnoteNumber: () => 1,
|
||||
}));
|
||||
|
||||
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
|
||||
// The Group mock MUST forward contentEditable: React serializes
|
||||
// contentEditable={false} to the DOM attribute contenteditable="false", which
|
||||
// the structural guard selects on to identify non-editable chrome.
|
||||
vi.mock("@mantine/core", () => ({
|
||||
Group: ({ children, className, contentEditable }: any) => (
|
||||
<div className={className} contentEditable={contentEditable}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Select: () => null,
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
ActionIcon: ({ children, onClick }: any) => (
|
||||
<button onClick={onClick}>{children}</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/common/copy-button", () => ({
|
||||
CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
|
||||
}));
|
||||
vi.mock("@tabler/icons-react", () => ({
|
||||
IconCheck: () => null,
|
||||
IconCopy: () => null,
|
||||
}));
|
||||
vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import FootnotesListView from "./footnotes-list-view";
|
||||
import FootnoteDefinitionView from "./footnote-definition-view";
|
||||
import CodeBlockView from "../code-block/code-block-view";
|
||||
|
||||
// Minimal NodeViewProps stub: definition view only touches node.attrs.id and
|
||||
// editor.state (the latter unused once getFootnoteNumber is mocked).
|
||||
@@ -52,38 +83,59 @@ const props = {
|
||||
deleteNode: () => {},
|
||||
} as any;
|
||||
|
||||
// CodeBlockView needs more than the footnote stub: a language attr (non-mermaid
|
||||
// so MermaidView never renders), an editor with selection/on/off, and an
|
||||
// extension exposing lowlight.listLanguages.
|
||||
const codeBlockProps = {
|
||||
node: { attrs: { language: "javascript" }, textContent: "", nodeSize: 1 },
|
||||
editor: {
|
||||
state: { selection: { from: 0, to: 0 } },
|
||||
isEditable: true,
|
||||
commands: {},
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
extension: {
|
||||
options: { lowlight: { listLanguages: () => ["javascript", "python"] } },
|
||||
},
|
||||
getPos: () => 0,
|
||||
updateAttributes: () => {},
|
||||
deleteNode: () => {},
|
||||
} as any;
|
||||
|
||||
const cases: Array<{ name: string; ui: React.ReactElement }> = [
|
||||
{ name: "FootnotesListView", ui: <FootnotesListView {...props} /> },
|
||||
{ name: "FootnoteDefinitionView", ui: <FootnoteDefinitionView {...props} /> },
|
||||
{ name: "CodeBlockView", ui: <CodeBlockView {...codeBlockProps} /> },
|
||||
];
|
||||
|
||||
describe("#146 footnote NodeView DOM-order invariant", () => {
|
||||
describe("#146 editable NodeView contentDOM-first invariant", () => {
|
||||
it.each(cases)(
|
||||
"$name renders contentDOM as the first child",
|
||||
"$name renders the editable contentDOM ahead of all non-editable chrome",
|
||||
({ 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);
|
||||
},
|
||||
);
|
||||
const content = wrapper.querySelector("[data-node-view-content]");
|
||||
expect(content).not.toBeNull();
|
||||
|
||||
it.each(cases)(
|
||||
"$name has no contentEditable=false chrome BEFORE the content",
|
||||
({ ui }) => {
|
||||
const { getByTestId } = render(ui);
|
||||
const wrapper = getByTestId("nvw");
|
||||
// The contentDOM sits at the FRONT of the wrapper: it is either the
|
||||
// wrapper's first child (footnote views) or nested in the first child
|
||||
// (code-block wraps it in <pre>). Either way the first element child
|
||||
// must contain it. (compareDocumentPosition below is NOT redundant here:
|
||||
// for code-block the content is not the literal first child, so we keep
|
||||
// the document-order check to prove no chrome precedes the content.)
|
||||
const firstEl = wrapper.firstElementChild!;
|
||||
expect(firstEl === content || firstEl.contains(content!)).toBe(true);
|
||||
|
||||
const content = wrapper.querySelector("[data-node-view-content]")!;
|
||||
// Chrome exists (separator/heading/marker/back-link/menu)...
|
||||
const nonEditable = wrapper.querySelectorAll('[contenteditable="false"]');
|
||||
expect(nonEditable.length).toBeGreaterThan(0); // chrome exists...
|
||||
expect(nonEditable.length).toBeGreaterThan(0);
|
||||
|
||||
// ...and every non-editable element comes AFTER the contentDOM, so the
|
||||
// browser's click hit-testing reaches the editable content first (#146).
|
||||
for (const el of Array.from(nonEditable)) {
|
||||
// ...but every non-editable element must come AFTER the content node.
|
||||
const pos = content.compareDocumentPosition(el);
|
||||
const pos = content!.compareDocumentPosition(el);
|
||||
expect(pos & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user