Skip to content

Commit bff14ff

Browse files
authored
fix: use <details>/<summary> for toggle block HTML export (#2524)
1 parent d76fd68 commit bff14ff

File tree

26 files changed

+961
-10
lines changed

26 files changed

+961
-10
lines changed

packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,13 @@ function serializeBlock<
319319
}
320320
}
321321

322-
if (editor.pmSchema.nodes[block.type as any].isInGroup("blockContent")) {
322+
if ("childrenDOM" in ret && ret.childrenDOM) {
323+
// block specifies where children should go (e.g. toggle blocks
324+
// place children inside <details>)
325+
ret.childrenDOM.append(childFragment);
326+
} else if (
327+
editor.pmSchema.nodes[block.type as any].isInGroup("blockContent")
328+
) {
323329
// default "blockContainer" style blocks are flattened (no "nested block" support) for externalHTML, so append the child fragment to the outer fragment
324330
fragment.append(childFragment);
325331
} else {

packages/core/src/blocks/Heading/block.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
21
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
32
import { createExtension } from "../../editor/BlockNoteExtension.js";
3+
import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
44
import {
55
addDefaultPropsExternalHTML,
66
defaultProps,
77
parseDefaultProps,
88
} from "../defaultProps.js";
9+
import { getDetailsContent } from "../getDetailsContent.js";
910
import { createToggleWrapper } from "../ToggleWrapper/createToggleWrapper.js";
1011

1112
const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const;
@@ -64,6 +65,24 @@ export const createHeadingBlockSpec = createBlockSpec(
6465
isolating: false,
6566
},
6667
parse(e) {
68+
if (allowToggleHeadings && e.tagName === "DETAILS") {
69+
const summary = e.querySelector(":scope > summary");
70+
if (!summary) {
71+
return undefined;
72+
}
73+
74+
const heading = summary.querySelector("h1, h2, h3, h4, h5, h6");
75+
if (!heading) {
76+
return undefined;
77+
}
78+
79+
return {
80+
...parseDefaultProps(heading as HTMLElement),
81+
level: parseInt(heading.tagName[1]),
82+
isToggleable: true,
83+
};
84+
}
85+
6786
let level: number;
6887
switch (e.tagName) {
6988
case "H1":
@@ -93,6 +112,20 @@ export const createHeadingBlockSpec = createBlockSpec(
93112
level,
94113
};
95114
},
115+
...(allowToggleHeadings
116+
? {
117+
parseContent: ({ el, schema }: { el: HTMLElement; schema: any }) => {
118+
if (el.tagName === "DETAILS") {
119+
return getDetailsContent(el, schema, "heading");
120+
}
121+
122+
// Regular heading (H1-H6): return undefined to fall through to
123+
// the default inline content parsing in createSpec.
124+
return undefined;
125+
},
126+
}
127+
: {}),
128+
runsBefore: ["toggleListItem"],
96129
render(block, editor) {
97130
const dom = document.createElement(`h${block.props.level}`);
98131

@@ -110,6 +143,20 @@ export const createHeadingBlockSpec = createBlockSpec(
110143
const dom = document.createElement(`h${block.props.level}`);
111144
addDefaultPropsExternalHTML(block.props, dom);
112145

146+
if (allowToggleHeadings && block.props.isToggleable) {
147+
const details = document.createElement("details");
148+
details.setAttribute("open", "");
149+
const summary = document.createElement("summary");
150+
summary.appendChild(dom);
151+
details.appendChild(summary);
152+
153+
return {
154+
dom: details,
155+
contentDOM: dom,
156+
childrenDOM: details,
157+
};
158+
}
159+
113160
return {
114161
dom,
115162
contentDOM: dom,

packages/core/src/blocks/ListItem/ToggleListItem/block.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { createBlockConfig, createBlockSpec } from "../../../schema/index.js";
33
import {
44
addDefaultPropsExternalHTML,
55
defaultProps,
6+
parseDefaultProps,
67
} from "../../defaultProps.js";
8+
import { getDetailsContent } from "../../getDetailsContent.js";
79
import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js";
810
import { handleEnter } from "../../utils/listItemEnterHandler.js";
911

@@ -28,6 +30,47 @@ export const createToggleListItemBlockSpec = createBlockSpec(
2830
meta: {
2931
isolating: false,
3032
},
33+
parse(element) {
34+
if (element.tagName === "DETAILS") {
35+
// Skip <details> that contain a heading in <summary> — those are
36+
// toggle headings, handled by the heading block's parse rule.
37+
38+
return parseDefaultProps(element);
39+
}
40+
41+
if (element.tagName === "LI") {
42+
const parent = element.parentElement;
43+
44+
if (
45+
parent &&
46+
(parent.tagName === "UL" ||
47+
(parent.tagName === "DIV" &&
48+
parent.parentElement?.tagName === "UL"))
49+
) {
50+
const details = element.querySelector(":scope > details");
51+
if (details) {
52+
return parseDefaultProps(element);
53+
}
54+
}
55+
}
56+
57+
return undefined;
58+
},
59+
parseContent: ({ el, schema }) => {
60+
const details =
61+
el.tagName === "DETAILS" ? el : el.querySelector(":scope > details");
62+
63+
if (!details) {
64+
throw new Error("No details found in toggleListItem parseContent");
65+
}
66+
67+
return getDetailsContent(
68+
details as HTMLElement,
69+
schema,
70+
"toggleListItem",
71+
);
72+
},
73+
runsBefore: ["bulletListItem"],
3174
render(block, editor) {
3275
const paragraphEl = document.createElement("p");
3376
const toggleWrapper = createToggleWrapper(
@@ -39,13 +82,20 @@ export const createToggleListItemBlockSpec = createBlockSpec(
3982
},
4083
toExternalHTML(block) {
4184
const li = document.createElement("li");
85+
const details = document.createElement("details");
86+
details.setAttribute("open", "");
87+
const summary = document.createElement("summary");
4288
const p = document.createElement("p");
89+
summary.appendChild(p);
90+
details.appendChild(summary);
91+
4392
addDefaultPropsExternalHTML(block.props, li);
44-
li.appendChild(p);
93+
li.appendChild(details);
4594

4695
return {
4796
dom: li,
4897
contentDOM: p,
98+
childrenDOM: details,
4999
};
50100
},
51101
},

packages/core/src/blocks/Paragraph/block.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const createParagraphBlockSpec = createBlockSpec(
5252
contentDOM: dom,
5353
};
5454
},
55-
runsBefore: ["default"],
55+
runsBefore: ["default", "heading"],
5656
},
5757
[
5858
createExtension({
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { DOMParser, Fragment, Schema } from "prosemirror-model";
2+
import { mergeParagraphs } from "./defaultBlockHelpers.js";
3+
4+
/**
5+
* Parses a `<details>` element into a block's inline content + nested children.
6+
*
7+
* Given:
8+
* <details>
9+
* <summary>inline content here</summary>
10+
* <p>child block 1</p>
11+
* <p>child block 2</p>
12+
* </details>
13+
*
14+
* Returns a Fragment shaped like:
15+
* [inline content, blockGroup<blockContainer<child1>, blockContainer<child2>>]
16+
*
17+
* ProseMirror's "fitting" algorithm will place the inline content into the
18+
* block node, and lift the blockGroup into the parent blockContainer as
19+
* nested children. This is the same mechanism used by `getListItemContent`.
20+
*/
21+
export function getDetailsContent(
22+
details: HTMLElement,
23+
schema: Schema,
24+
nodeName: string,
25+
): Fragment {
26+
const parser = DOMParser.fromSchema(schema);
27+
const summary = details.querySelector(":scope > summary");
28+
29+
// Parse inline content from <summary>. mergeParagraphs collapses multiple
30+
// <p> tags into one with <br> separators so it fits a single inline node.
31+
let inlineContent: Fragment;
32+
if (summary) {
33+
const clone = summary.cloneNode(true) as HTMLElement;
34+
mergeParagraphs(clone);
35+
inlineContent = parser.parse(clone, {
36+
topNode: schema.nodes.paragraph.create(),
37+
preserveWhitespace: true,
38+
}).content;
39+
} else {
40+
inlineContent = Fragment.empty;
41+
}
42+
43+
// Collect everything after <summary> as nested block children.
44+
const childrenContainer = document.createElement("div");
45+
childrenContainer.setAttribute("data-node-type", "blockGroup");
46+
let hasChildren = false;
47+
48+
for (const child of Array.from(details.childNodes)) {
49+
if ((child as HTMLElement).tagName === "SUMMARY") {
50+
continue;
51+
}
52+
// Skip whitespace-only text nodes (from HTML formatting) — ProseMirror
53+
// would otherwise create empty paragraph blocks from them.
54+
if (child.nodeType === 3 && !child.textContent?.trim()) {
55+
continue;
56+
}
57+
hasChildren = true;
58+
childrenContainer.appendChild(child.cloneNode(true));
59+
}
60+
61+
const contentNode = schema.nodes[nodeName].create({}, inlineContent);
62+
63+
if (!hasChildren) {
64+
return contentNode.content;
65+
}
66+
67+
// Parse children as a blockGroup. ProseMirror's fitting algorithm will
68+
// lift this out of the inline content node and into the parent
69+
// blockContainer as nested children.
70+
const blockGroup = parser.parse(childrenContainer, {
71+
topNode: schema.nodes.blockGroup.create(),
72+
});
73+
74+
return blockGroup.content.size > 0
75+
? contentNode.content.addToEnd(blockGroup)
76+
: contentNode.content;
77+
}

packages/core/src/schema/blocks/createSpec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,15 @@ export function getParseRules<
7878
config.content === "inline" || config.content === "none"
7979
? (node, schema) => {
8080
if (implementation.parseContent) {
81-
return implementation.parseContent({
81+
const result = implementation.parseContent({
8282
el: node as HTMLElement,
8383
schema,
8484
});
85+
// parseContent may return undefined to fall through to
86+
// the default inline content parsing below.
87+
if (result !== undefined) {
88+
return result;
89+
}
8590
}
8691

8792
if (config.content === "inline") {

packages/core/src/schema/blocks/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ export type LooseBlockSpec<
200200
| {
201201
dom: HTMLElement | DocumentFragment;
202202
contentDOM?: HTMLElement;
203+
childrenDOM?: HTMLElement;
203204
}
204205
| undefined;
205206

@@ -257,6 +258,7 @@ export type BlockSpecs = {
257258
| {
258259
dom: HTMLElement | DocumentFragment;
259260
contentDOM?: HTMLElement;
261+
childrenDOM?: HTMLElement;
260262
}
261263
| undefined;
262264
};
@@ -530,6 +532,7 @@ export type BlockImplementation<
530532
| {
531533
dom: HTMLElement | DocumentFragment;
532534
contentDOM?: HTMLElement;
535+
childrenDOM?: HTMLElement;
533536
}
534537
| undefined;
535538

@@ -548,7 +551,10 @@ export type BlockImplementation<
548551
* Advanced parsing function that controls how content within the block is parsed.
549552
* This is not recommended to use, and is only useful for advanced use cases.
550553
*/
551-
parseContent?: (options: { el: HTMLElement; schema: Schema }) => Fragment;
554+
parseContent?: (options: {
555+
el: HTMLElement;
556+
schema: Schema;
557+
}) => Fragment | undefined;
552558
};
553559

554560
/**

tests/src/unit/core/clipboard/copy/__snapshots__/text/html/basicBlocks.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ <h1>Heading 1</h1>
1414
<p class="bn-inline-content">Check List Item 1</p>
1515
</li>
1616
<li>
17-
<p class="bn-inline-content">Toggle List Item 1</p>
17+
<details open="">
18+
<summary>
19+
<p class="bn-inline-content">Toggle List Item 1</p>
20+
</summary>
21+
</details>
1822
</li>
1923
</ul>
2024
<pre>

tests/src/unit/core/clipboard/copy/__snapshots__/text/html/basicBlocksWithProps.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ <h2 data-level="2">Heading 1</h2>
1414
<p class="bn-inline-content">Check List Item 1</p>
1515
</li>
1616
<li style="text-align: right;" data-text-alignment="right">
17-
<p class="bn-inline-content">Toggle List Item 1</p>
17+
<details open="">
18+
<summary>
19+
<p class="bn-inline-content">Toggle List Item 1</p>
20+
</summary>
21+
</details>
1822
</li>
1923
</ul>
2024
<pre data-language="typescript">

0 commit comments

Comments
 (0)