Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,13 @@ function serializeBlock<
}
}

if (editor.pmSchema.nodes[block.type as any].isInGroup("blockContent")) {
if ("childrenDOM" in ret && ret.childrenDOM) {
// block specifies where children should go (e.g. toggle blocks
// place children inside <details>)
ret.childrenDOM.append(childFragment);
} else if (
editor.pmSchema.nodes[block.type as any].isInGroup("blockContent")
) {
// default "blockContainer" style blocks are flattened (no "nested block" support) for externalHTML, so append the child fragment to the outer fragment
fragment.append(childFragment);
} else {
Expand Down
49 changes: 48 additions & 1 deletion packages/core/src/blocks/Heading/block.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { createExtension } from "../../editor/BlockNoteExtension.js";
import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
import {
addDefaultPropsExternalHTML,
defaultProps,
parseDefaultProps,
} from "../defaultProps.js";
import { getDetailsContent } from "../getDetailsContent.js";
import { createToggleWrapper } from "../ToggleWrapper/createToggleWrapper.js";

const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const;
Expand Down Expand Up @@ -64,6 +65,24 @@ export const createHeadingBlockSpec = createBlockSpec(
isolating: false,
},
parse(e) {
if (allowToggleHeadings && e.tagName === "DETAILS") {
const summary = e.querySelector(":scope > summary");
if (!summary) {
return undefined;
}

const heading = summary.querySelector("h1, h2, h3, h4, h5, h6");
if (!heading) {
return undefined;
}

return {
...parseDefaultProps(heading as HTMLElement),
level: parseInt(heading.tagName[1]),
isToggleable: true,
};
}

let level: number;
switch (e.tagName) {
case "H1":
Expand Down Expand Up @@ -93,6 +112,20 @@ export const createHeadingBlockSpec = createBlockSpec(
level,
};
},
...(allowToggleHeadings
? {
parseContent: ({ el, schema }: { el: HTMLElement; schema: any }) => {
if (el.tagName === "DETAILS") {
return getDetailsContent(el, schema, "heading");
}

// Regular heading (H1-H6): return undefined to fall through to
// the default inline content parsing in createSpec.
return undefined;
},
}
: {}),
runsBefore: ["toggleListItem"],
render(block, editor) {
const dom = document.createElement(`h${block.props.level}`);

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

if (allowToggleHeadings && block.props.isToggleable) {
const details = document.createElement("details");
details.setAttribute("open", "");
const summary = document.createElement("summary");
summary.appendChild(dom);
details.appendChild(summary);

return {
dom: details,
contentDOM: dom,
childrenDOM: details,
};
}

return {
dom,
contentDOM: dom,
Expand Down
52 changes: 51 additions & 1 deletion packages/core/src/blocks/ListItem/ToggleListItem/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { createBlockConfig, createBlockSpec } from "../../../schema/index.js";
import {
addDefaultPropsExternalHTML,
defaultProps,
parseDefaultProps,
} from "../../defaultProps.js";
import { getDetailsContent } from "../../getDetailsContent.js";
import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js";
import { handleEnter } from "../../utils/listItemEnterHandler.js";

Expand All @@ -28,6 +30,47 @@ export const createToggleListItemBlockSpec = createBlockSpec(
meta: {
isolating: false,
},
parse(element) {
if (element.tagName === "DETAILS") {
// Skip <details> that contain a heading in <summary> — those are
// toggle headings, handled by the heading block's parse rule.

return parseDefaultProps(element);
}

if (element.tagName === "LI") {
const parent = element.parentElement;

if (
parent &&
(parent.tagName === "UL" ||
(parent.tagName === "DIV" &&
parent.parentElement?.tagName === "UL"))
) {
const details = element.querySelector(":scope > details");
if (details) {
return parseDefaultProps(element);
}
}
}

return undefined;
},
parseContent: ({ el, schema }) => {
const details =
el.tagName === "DETAILS" ? el : el.querySelector(":scope > details");

if (!details) {
throw new Error("No details found in toggleListItem parseContent");
}

return getDetailsContent(
details as HTMLElement,
schema,
"toggleListItem",
);
},
runsBefore: ["bulletListItem"],
render(block, editor) {
const paragraphEl = document.createElement("p");
const toggleWrapper = createToggleWrapper(
Expand All @@ -39,13 +82,20 @@ export const createToggleListItemBlockSpec = createBlockSpec(
},
toExternalHTML(block) {
const li = document.createElement("li");
const details = document.createElement("details");
details.setAttribute("open", "");
const summary = document.createElement("summary");
const p = document.createElement("p");
summary.appendChild(p);
details.appendChild(summary);

addDefaultPropsExternalHTML(block.props, li);
li.appendChild(p);
li.appendChild(details);

return {
dom: li,
contentDOM: p,
childrenDOM: details,
};
},
},
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/blocks/Paragraph/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const createParagraphBlockSpec = createBlockSpec(
contentDOM: dom,
};
},
runsBefore: ["default"],
runsBefore: ["default", "heading"],
},
[
createExtension({
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/blocks/getDetailsContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { DOMParser, Fragment, Schema } from "prosemirror-model";
import { mergeParagraphs } from "./defaultBlockHelpers.js";

/**
* Parses a `<details>` element into a block's inline content + nested children.
*
* Given:
* <details>
* <summary>inline content here</summary>
* <p>child block 1</p>
* <p>child block 2</p>
* </details>
*
* Returns a Fragment shaped like:
* [inline content, blockGroup<blockContainer<child1>, blockContainer<child2>>]
*
* ProseMirror's "fitting" algorithm will place the inline content into the
* block node, and lift the blockGroup into the parent blockContainer as
* nested children. This is the same mechanism used by `getListItemContent`.
*/
export function getDetailsContent(
details: HTMLElement,
schema: Schema,
nodeName: string,
): Fragment {
const parser = DOMParser.fromSchema(schema);
const summary = details.querySelector(":scope > summary");

// Parse inline content from <summary>. mergeParagraphs collapses multiple
// <p> tags into one with <br> separators so it fits a single inline node.
let inlineContent: Fragment;
if (summary) {
const clone = summary.cloneNode(true) as HTMLElement;
mergeParagraphs(clone);
inlineContent = parser.parse(clone, {
topNode: schema.nodes.paragraph.create(),
preserveWhitespace: true,
}).content;
} else {
inlineContent = Fragment.empty;
}

// Collect everything after <summary> as nested block children.
const childrenContainer = document.createElement("div");
childrenContainer.setAttribute("data-node-type", "blockGroup");
let hasChildren = false;

for (const child of Array.from(details.childNodes)) {
if ((child as HTMLElement).tagName === "SUMMARY") {
continue;
}
// Skip whitespace-only text nodes (from HTML formatting) — ProseMirror
// would otherwise create empty paragraph blocks from them.
if (child.nodeType === 3 && !child.textContent?.trim()) {
continue;
}
hasChildren = true;
childrenContainer.appendChild(child.cloneNode(true));
}

const contentNode = schema.nodes[nodeName].create({}, inlineContent);

if (!hasChildren) {
return contentNode.content;
}

// Parse children as a blockGroup. ProseMirror's fitting algorithm will
// lift this out of the inline content node and into the parent
// blockContainer as nested children.
const blockGroup = parser.parse(childrenContainer, {
topNode: schema.nodes.blockGroup.create(),
});

return blockGroup.content.size > 0
? contentNode.content.addToEnd(blockGroup)
: contentNode.content;
}
7 changes: 6 additions & 1 deletion packages/core/src/schema/blocks/createSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,15 @@ export function getParseRules<
config.content === "inline" || config.content === "none"
? (node, schema) => {
if (implementation.parseContent) {
return implementation.parseContent({
const result = implementation.parseContent({
el: node as HTMLElement,
schema,
});
// parseContent may return undefined to fall through to
// the default inline content parsing below.
if (result !== undefined) {
return result;
}
}

if (config.content === "inline") {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/schema/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export type LooseBlockSpec<
| {
dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
childrenDOM?: HTMLElement;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know about this childrenDOM, maybe it is because we don't have an equivalent for the editor rendered version.

}
| undefined;

Expand Down Expand Up @@ -203,6 +204,7 @@ export type BlockSpecs = {
| {
dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
childrenDOM?: HTMLElement;
}
| undefined;
};
Expand Down Expand Up @@ -476,6 +478,7 @@ export type BlockImplementation<
| {
dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
childrenDOM?: HTMLElement;
}
| undefined;

Expand All @@ -494,7 +497,10 @@ export type BlockImplementation<
* Advanced parsing function that controls how content within the block is parsed.
* This is not recommended to use, and is only useful for advanced use cases.
*/
parseContent?: (options: { el: HTMLElement; schema: Schema }) => Fragment;
parseContent?: (options: {
el: HTMLElement;
schema: Schema;
}) => Fragment | undefined;
};

// restrict content to "inline" and "none" only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ <h1>Heading 1</h1>
<p class="bn-inline-content">Check List Item 1</p>
</li>
<li>
<p class="bn-inline-content">Toggle List Item 1</p>
<details open="">
<summary>
<p class="bn-inline-content">Toggle List Item 1</p>
</summary>
</details>
</li>
</ul>
<pre>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ <h2 data-level="2">Heading 1</h2>
<p class="bn-inline-content">Check List Item 1</p>
</li>
<li style="text-align: right;" data-text-alignment="right">
<p class="bn-inline-content">Toggle List Item 1</p>
<details open="">
<summary>
<p class="bn-inline-content">Toggle List Item 1</p>
</summary>
</details>
</li>
</ul>
<pre data-language="typescript">
Expand Down
Loading
Loading