fix(editor,git-sync): parse details open as a boolean so open state survives render/round-trip
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
59
packages/editor-ext/src/lib/details/details.test.ts
Normal file
59
packages/editor-ext/src/lib/details/details.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { Document } from "@tiptap/extension-document";
|
||||||
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
|
import { Text } from "@tiptap/extension-text";
|
||||||
|
import { Details } from "./details";
|
||||||
|
import { DetailsSummary } from "./details-summary";
|
||||||
|
import { DetailsContent } from "./details-content";
|
||||||
|
|
||||||
|
// The `details` node's `open` attribute must parse to a strict BOOLEAN. The old
|
||||||
|
// `getAttribute("open")` returned "" (falsy) for `<details open>` and `null`
|
||||||
|
// when absent, so a parsed-open details rendered without `open` and collapsed.
|
||||||
|
// `hasAttribute` yields a real boolean, so open state survives parse → render.
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
Details,
|
||||||
|
DetailsSummary,
|
||||||
|
DetailsContent,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Parse an HTML string through the schema and return the first details node. */
|
||||||
|
function parseDetails(html: string): any {
|
||||||
|
const editor = new Editor({ extensions, content: html });
|
||||||
|
const json = editor.getJSON();
|
||||||
|
const find = (n: any): any => {
|
||||||
|
if (!n || typeof n !== "object") return undefined;
|
||||||
|
if (n.type === "details") return n;
|
||||||
|
if (Array.isArray(n.content)) {
|
||||||
|
for (const c of n.content) {
|
||||||
|
const hit = find(c);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const details = find(json);
|
||||||
|
editor.destroy();
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("details node: open attribute parses as a strict boolean", () => {
|
||||||
|
const body =
|
||||||
|
'<summary>S</summary><div data-type="detailsContent"><p>b</p></div>';
|
||||||
|
|
||||||
|
it("parses <details open> to open === true", () => {
|
||||||
|
const details = parseDetails(`<details open>${body}</details>`);
|
||||||
|
expect(details).toBeDefined();
|
||||||
|
expect(details.attrs.open).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses <details> (no open) to open === false", () => {
|
||||||
|
const details = parseDetails(`<details>${body}</details>`);
|
||||||
|
expect(details).toBeDefined();
|
||||||
|
expect(details.attrs.open).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,7 +39,7 @@ export const Details = Node.create<DetailsOptions>({
|
|||||||
return {
|
return {
|
||||||
open: {
|
open: {
|
||||||
default: false,
|
default: false,
|
||||||
parseHTML: (e) => e.getAttribute("open"),
|
parseHTML: (e) => e.hasAttribute("open"),
|
||||||
renderHTML: (a) => (a.open ? { open: "" } : {}),
|
renderHTML: (a) => (a.open ? { open: "" } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ const Details = Node.create({
|
|||||||
return {
|
return {
|
||||||
open: {
|
open: {
|
||||||
default: false,
|
default: false,
|
||||||
parseHTML: (el: HTMLElement) => el.getAttribute("open"),
|
parseHTML: (el: HTMLElement) => el.hasAttribute("open"),
|
||||||
renderHTML: (attrs: Record<string, any>) =>
|
renderHTML: (attrs: Record<string, any>) =>
|
||||||
attrs.open ? { open: "" } : {},
|
attrs.open ? { open: "" } : {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -200,11 +200,30 @@ describe('git-sync converter: lose-prone atoms keep their VALUES across a round
|
|||||||
const back = await markdownToProseMirror(md);
|
const back = await markdownToProseMirror(md);
|
||||||
const details = findNode(back, 'details');
|
const details = findNode(back, 'details');
|
||||||
expect(details).toBeDefined();
|
expect(details).toBeDefined();
|
||||||
// The schema parses the present `open` boolean attribute to "" (its raw
|
// `open` must round-trip as a STRICT boolean `true` — not "" (the old raw
|
||||||
// value); a DROPPED open parses to the default `false`. Asserting it is no
|
// getAttribute value) and not the default `false` (a dropped attribute).
|
||||||
// longer the default proves the nested path now preserves open — parity with
|
// Before the schema parseHTML fix (hasAttribute), `<details open>` parsed to
|
||||||
// the top-level <details> case. RED before the fix (open === false).
|
// "" — falsy, so it rendered as a bare <details> and collapsed. RED before
|
||||||
expect(details.attrs?.open).not.toBe(false);
|
// the fix (open was "" or false, never === true).
|
||||||
|
expect(details.attrs?.open).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('B: a TOP-LEVEL details keeps open as strict boolean true', async () => {
|
||||||
|
const original = doc({
|
||||||
|
type: 'details',
|
||||||
|
attrs: { open: true },
|
||||||
|
content: [
|
||||||
|
{ type: 'detailsSummary', content: [T('S')] },
|
||||||
|
{ type: 'detailsContent', content: [P(T('b'))] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const md = convertProseMirrorToMarkdown(original);
|
||||||
|
const back = await markdownToProseMirror(md);
|
||||||
|
const details = findNode(back, 'details');
|
||||||
|
expect(details).toBeDefined();
|
||||||
|
// Strict boolean, proving the value survives as `true` (not ""/false).
|
||||||
|
// RED before the fix: parseHTML returned getAttribute("open") === "".
|
||||||
|
expect(details.attrs?.open).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('D: htmlEmbed source VALUE and height survive', async () => {
|
it('D: htmlEmbed source VALUE and height survive', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user