Skip to content

Commit 6018470

Browse files
committed
feat: add parse rules for toggle blocks (<details>/<summary>)
Made-with: Cursor
1 parent 0653326 commit 6018470

File tree

16 files changed

+646
-3
lines changed

16 files changed

+646
-3
lines changed

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
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

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

Lines changed: 43 additions & 0 deletions
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(

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
@@ -76,10 +76,15 @@ export function getParseRules<
7676
config.content === "inline" || config.content === "none"
7777
? (node, schema) => {
7878
if (implementation.parseContent) {
79-
return implementation.parseContent({
79+
const result = implementation.parseContent({
8080
el: node as HTMLElement,
8181
schema,
8282
});
83+
// parseContent may return undefined to fall through to
84+
// the default inline content parsing below.
85+
if (result !== undefined) {
86+
return result;
87+
}
8388
}
8489

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

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,10 @@ export type BlockImplementation<
497497
* Advanced parsing function that controls how content within the block is parsed.
498498
* This is not recommended to use, and is only useful for advanced use cases.
499499
*/
500-
parseContent?: (options: { el: HTMLElement; schema: Schema }) => Fragment;
500+
parseContent?: (options: {
501+
el: HTMLElement;
502+
schema: Schema;
503+
}) => Fragment | undefined;
501504
};
502505

503506
// restrict content to "inline" and "none" only

tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,62 @@ export const exportParseEqualityTestInstancesHTML: TestInstance<
203203
},
204204
executeTest: testExportParseEqualityHTML,
205205
},
206+
{
207+
testCase: {
208+
name: "lists/toggleListItem",
209+
content: [
210+
{
211+
type: "toggleListItem",
212+
content: "Toggle List Item",
213+
},
214+
],
215+
},
216+
executeTest: testExportParseEqualityHTML,
217+
},
218+
{
219+
testCase: {
220+
name: "lists/toggleListItemWithChildren",
221+
content: [
222+
{
223+
type: "toggleListItem",
224+
content: "Toggle List Item",
225+
children: [
226+
{
227+
type: "paragraph",
228+
content: "Child 1",
229+
},
230+
{
231+
type: "paragraph",
232+
content: "Child 2",
233+
},
234+
],
235+
},
236+
],
237+
},
238+
executeTest: testExportParseEqualityHTML,
239+
},
240+
{
241+
testCase: {
242+
name: "lists/toggleHeading",
243+
content: [
244+
{
245+
type: "heading",
246+
props: {
247+
level: 2,
248+
isToggleable: true,
249+
},
250+
content: "Toggle Heading",
251+
children: [
252+
{
253+
type: "paragraph",
254+
content: "Heading Child 1",
255+
},
256+
],
257+
},
258+
],
259+
},
260+
executeTest: testExportParseEqualityHTML,
261+
},
206262
{
207263
testCase: {
208264
name: "tables/advanced",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
[
2+
{
3+
"children": [],
4+
"content": [
5+
{
6+
"styles": {},
7+
"text": "Bullet Item",
8+
"type": "text",
9+
},
10+
],
11+
"id": "1",
12+
"props": {
13+
"backgroundColor": "default",
14+
"textAlignment": "left",
15+
"textColor": "default",
16+
},
17+
"type": "bulletListItem",
18+
},
19+
{
20+
"children": [
21+
{
22+
"children": [],
23+
"content": [
24+
{
25+
"styles": {},
26+
"text": "Toggle Child",
27+
"type": "text",
28+
},
29+
],
30+
"id": "3",
31+
"props": {
32+
"backgroundColor": "default",
33+
"textAlignment": "left",
34+
"textColor": "default",
35+
},
36+
"type": "paragraph",
37+
},
38+
],
39+
"content": [
40+
{
41+
"styles": {},
42+
"text": "Toggle Item",
43+
"type": "text",
44+
},
45+
],
46+
"id": "2",
47+
"props": {
48+
"backgroundColor": "default",
49+
"textAlignment": "left",
50+
"textColor": "default",
51+
},
52+
"type": "toggleListItem",
53+
},
54+
{
55+
"children": [],
56+
"content": [
57+
{
58+
"styles": {},
59+
"text": "Another Bullet",
60+
"type": "text",
61+
},
62+
],
63+
"id": "4",
64+
"props": {
65+
"backgroundColor": "default",
66+
"textAlignment": "left",
67+
"textColor": "default",
68+
},
69+
"type": "bulletListItem",
70+
},
71+
]

0 commit comments

Comments
 (0)