Skip to content

Commit 11f8633

Browse files
committed
feat: add toExternalHTML for custom inline content
1 parent b16a9ac commit 11f8633

File tree

15 files changed

+312
-17
lines changed

15 files changed

+312
-17
lines changed

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

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
inlineContentToNodes,
1414
tableContentToNodes,
1515
} from "../../../nodeConversions/blockToNode.js";
16+
import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js";
1617

1718
function addAttributesAndRemoveClasses(element: HTMLElement) {
1819
// Removes all BlockNote specific class names.
@@ -53,16 +54,57 @@ export function serializeInlineContentExternalHTML<
5354
throw new UnreachableCaseError(blockContent.type);
5455
}
5556

56-
// We call the prosemirror serializer here because it handles Marks and Inline Content nodes nicely.
57-
// If we'd want to support custom serialization or externalHTML for Inline Content, we'd have to implement
58-
// a custom serializer here.
59-
const dom = serializer.serializeFragment(Fragment.from(nodes), options);
57+
// Check if any of the nodes are custom inline content with toExternalHTML
58+
const doc = options?.document ?? document;
59+
const fragment = doc.createDocumentFragment();
60+
61+
for (const node of nodes) {
62+
// Check if this is a custom inline content node with toExternalHTML
63+
if (
64+
node.type &&
65+
node.type.name &&
66+
node.type.name in editor.schema.inlineContentSchema
67+
) {
68+
const inlineContentImplementation =
69+
editor.schema.inlineContentSpecs[node.type.name]?.implementation;
70+
71+
if (inlineContentImplementation?.toExternalHTML) {
72+
// Convert the node to inline content format
73+
const inlineContent = nodeToCustomInlineContent(
74+
node,
75+
editor.schema.inlineContentSchema,
76+
editor.schema.styleSchema,
77+
);
6078

61-
if (dom.nodeType === 1 /* Node.ELEMENT_NODE */) {
62-
addAttributesAndRemoveClasses(dom as HTMLElement);
79+
// Use the custom toExternalHTML method
80+
const output = inlineContentImplementation.toExternalHTML(
81+
inlineContent as any,
82+
editor as any,
83+
);
84+
85+
if (output) {
86+
fragment.appendChild(output.dom);
87+
continue;
88+
}
89+
}
90+
}
91+
92+
// Fall back to default serialization for this node
93+
const nodeFragment = serializer.serializeFragment(
94+
Fragment.from([node]),
95+
options,
96+
);
97+
fragment.appendChild(nodeFragment);
6398
}
6499

65-
return dom;
100+
if (
101+
fragment.childNodes.length === 1 &&
102+
fragment.firstChild?.nodeType === 1 /* Node.ELEMENT_NODE */
103+
) {
104+
addAttributesAndRemoveClasses(fragment.firstChild as HTMLElement);
105+
}
106+
107+
return fragment;
66108
}
67109

68110
/**

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

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
tableContentToNodes,
1414
} from "../../../nodeConversions/blockToNode.js";
1515

16+
import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js";
1617
export function serializeInlineContentInternalHTML<
1718
BSchema extends BlockSchema,
1819
I extends InlineContentSchema,
@@ -39,12 +40,50 @@ export function serializeInlineContentInternalHTML<
3940
throw new UnreachableCaseError(blockContent.type);
4041
}
4142

42-
// We call the prosemirror serializer here because it handles Marks and Inline Content nodes nicely.
43-
// If we'd want to support custom serialization or externalHTML for Inline Content, we'd have to implement
44-
// a custom serializer here.
45-
const dom = serializer.serializeFragment(Fragment.from(nodes), options);
43+
// Check if any of the nodes are custom inline content with toExternalHTML
44+
const doc = options?.document ?? document;
45+
const fragment = doc.createDocumentFragment();
46+
47+
for (const node of nodes) {
48+
// Check if this is a custom inline content node with toExternalHTML
49+
if (
50+
node.type &&
51+
node.type.name &&
52+
editor.schema.inlineContentSchema[node.type.name]
53+
) {
54+
const inlineContentImplementation =
55+
editor.inlineContentImplementations[node.type.name]?.implementation;
56+
57+
if (inlineContentImplementation?.toExternalHTML) {
58+
// Convert the node to inline content format
59+
const inlineContent = nodeToCustomInlineContent(
60+
node,
61+
editor.schema.inlineContentSchema,
62+
editor.schema.styleSchema,
63+
);
64+
65+
// Use the custom toExternalHTML method
66+
const output = inlineContentImplementation.toExternalHTML(
67+
inlineContent as any,
68+
editor as any,
69+
);
70+
71+
if (output) {
72+
fragment.appendChild(output.dom);
73+
continue;
74+
}
75+
}
76+
}
4677

47-
return dom;
78+
// Fall back to default serialization for this node
79+
const nodeFragment = serializer.serializeFragment(
80+
Fragment.from([node]),
81+
options,
82+
);
83+
fragment.appendChild(nodeFragment);
84+
}
85+
86+
return fragment;
4887
}
4988

5089
function serializeBlock<

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,28 @@ export type CustomInlineContentImplementation<
5757
contentDOM?: HTMLElement;
5858
destroy?: () => void;
5959
};
60+
61+
/**
62+
* Renders an inline content to external HTML elements for use outside the editor
63+
* If not provided, falls back to the render method
64+
*/
65+
toExternalHTML?: (
66+
/**
67+
* The custom inline content to render
68+
*/
69+
inlineContent: InlineContentFromConfig<T, S>,
70+
/**
71+
* The BlockNote editor instance
72+
* This is typed generically. If you want an editor with your custom schema, you need to
73+
* cast it manually, e.g.: `const e = editor as BlockNoteEditor<typeof mySchema>;`
74+
*/
75+
editor: BlockNoteEditor<any, any, S>,
76+
) =>
77+
| {
78+
dom: HTMLElement | DocumentFragment;
79+
contentDOM?: HTMLElement;
80+
}
81+
| undefined;
6082
};
6183

6284
export function getInlineContentParseRules<C extends CustomInlineContentConfig>(
@@ -112,9 +134,7 @@ export function createInlineContentSpec<
112134
group: "inline",
113135
selectable: inlineContentConfig.content === "styled",
114136
atom: inlineContentConfig.content === "none",
115-
content: (inlineContentConfig.content === "styled"
116-
? "inline*"
117-
: "") as T["content"] extends "styled" ? "inline*" : "",
137+
content: inlineContentConfig.content === "styled" ? "inline*" : "",
118138

119139
addAttributes() {
120140
return propsToAttributes(inlineContentConfig.propSchema);
@@ -189,5 +209,6 @@ export function createInlineContentSpec<
189209
return createInlineContentSpecFromTipTapNode(
190210
node,
191211
inlineContentConfig.propSchema,
192-
) as InlineContentSpec<T>; // TODO: fix cast
212+
{ toExternalHTML: inlineContentImplementation.toExternalHTML },
213+
) as InlineContentSpec<T>;
193214
}

packages/core/src/schema/inlineContent/internal.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,22 @@ export function createInternalInlineContentSpec<T extends InlineContentConfig>(
8686
export function createInlineContentSpecFromTipTapNode<
8787
T extends Node,
8888
P extends PropSchema,
89-
>(node: T, propSchema: P) {
89+
>(
90+
node: T,
91+
propSchema: P,
92+
implementation?: Omit<
93+
InlineContentImplementation<InlineContentConfig>,
94+
"node"
95+
>,
96+
) {
9097
return createInternalInlineContentSpec(
9198
{
9299
type: node.name as T["name"],
93100
propSchema,
94101
content: node.config.content === "inline*" ? "styled" : "none",
95102
},
96103
{
104+
...implementation,
97105
node,
98106
},
99107
);

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Node } from "@tiptap/core";
22
import { PropSchema, Props } from "../propTypes.js";
33
import { StyleSchema, Styles } from "../styles/types.js";
4+
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
45

56
export type CustomInlineContentConfig = {
67
type: string;
@@ -21,6 +22,15 @@ export type InlineContentImplementation<T extends InlineContentConfig> =
2122
? undefined
2223
: {
2324
node: Node;
25+
toExternalHTML?: (
26+
inlineContent: any,
27+
editor: BlockNoteEditor<any, any, any>,
28+
) =>
29+
| {
30+
dom: HTMLElement | DocumentFragment;
31+
contentDOM?: HTMLElement;
32+
}
33+
| undefined;
2434
};
2535

2636
export type InlineContentSchemaWithInlineContent<
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<div class="bn-block-group" data-node-type="blockGroup">
2+
<div class="bn-block-outer" data-node-type="blockOuter" data-id="1">
3+
<div class="bn-block" data-node-type="blockContainer" data-id="1">
4+
<div class="bn-block-content" data-content-type="paragraph">
5+
<p class="bn-inline-content">
6+
I enjoy working with
7+
<span
8+
class="mention-external"
9+
data-external="true"
10+
data-inline-content-type="mention"
11+
data-user="Matthew"
12+
>@Matthew</span>
13+
</p>
14+
</div>
15+
</div>
16+
</div>
17+
</div>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<div class="bn-block-group" data-node-type="blockGroup">
2+
<div class="bn-block-outer" data-node-type="blockOuter" data-id="1">
3+
<div class="bn-block" data-node-type="blockContainer" data-id="1">
4+
<div class="bn-block-content" data-content-type="paragraph">
5+
<p class="bn-inline-content">
6+
I love
7+
<span data-inline-content-type="tag">
8+
#
9+
<span data-editable="">BlockNote</span>
10+
</span>
11+
</p>
12+
</div>
13+
</div>
14+
</div>
15+
</div>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<p>
2+
I enjoy working with
3+
<span
4+
class="mention-external"
5+
data-external="true"
6+
data-inline-content-type="mention"
7+
data-user="Matthew"
8+
>@Matthew</span>
9+
</p>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<p>
2+
I love
3+
<span data-inline-content-type="tag">
4+
#
5+
<span data-editable="">BlockNote</span>
6+
</span>
7+
</p>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
I enjoy working with @Matthew

0 commit comments

Comments
 (0)