Skip to content

Commit 43a6673

Browse files
authored
refactor: cleaned serialization code (#1129)
* cleaned serialization code * comments * update snapshot * add comment * address comments * fix lint * fix build * fix server test
1 parent 657e7e0 commit 43a6673

File tree

11 files changed

+368
-304
lines changed

11 files changed

+368
-304
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<div class="bn-block-content" data-content-type="table"><table class="bn-inline-content"><tbody><tr><td colspan="1" rowspan="1"><p>Table Cell</p></td><td colspan="1" rowspan="1"><p>Table Cell</p></td></tr><tr><td colspan="1" rowspan="1"><p>Table Cell</p></td><td colspan="1" rowspan="1"><p>Table Cell</p></td></tr></tbody></table></div>
1+
<tr><td colspan="1" rowspan="1"><p>Table Cell</p></td><td colspan="1" rowspan="1"><p>Table Cell</p></td></tr><tr><td colspan="1" rowspan="1"><p>Table Cell</p></td><td colspan="1" rowspan="1"><p>Table Cell</p></td></tr>

packages/core/src/api/clipboard/clipboard.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
55

66
import { PartialBlock } from "../../blocks/defaultBlocks";
77
import { BlockNoteEditor } from "../../editor/BlockNoteEditor";
8-
import { doPaste } from "../testUtil/paste";
98
import { initializeESMDependencies } from "../../util/esmDependencies";
9+
import { doPaste } from "../testUtil/paste";
1010
import { selectedFragmentToHTML } from "./toClipboard/copyExtension";
1111

1212
type SelectionTestCase = {
@@ -269,7 +269,6 @@ describe("Test ProseMirror selection clipboard HTML", () => {
269269
createSelection: (doc) => CellSelection.create(doc, 214, 228),
270270
},
271271
// Selection spans all cells of the table.
272-
// TODO: External HTML is wrapped in unnecessary `blockContent` element.
273272
{
274273
testName: "tableAllCells",
275274
createSelection: (doc) => CellSelection.create(doc, 214, 258),

packages/core/src/api/clipboard/toClipboard/copyExtension.ts

Lines changed: 91 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Extension } from "@tiptap/core";
2-
import { Node } from "prosemirror-model";
2+
import { Fragment, Node } from "prosemirror-model";
33
import { NodeSelection, Plugin } from "prosemirror-state";
44
import { CellSelection } from "prosemirror-tables";
55
import * as pmView from "prosemirror-view";
@@ -10,48 +10,28 @@ import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema";
1010
import { initializeESMDependencies } from "../../../util/esmDependencies";
1111
import { createExternalHTMLExporter } from "../../exporters/html/externalHTMLExporter";
1212
import { cleanHTMLToMarkdown } from "../../exporters/markdown/markdownExporter";
13+
import { fragmentToBlocks } from "../../nodeConversions/fragmentToBlocks";
14+
import {
15+
contentNodeToInlineContent,
16+
contentNodeToTableContent,
17+
} from "../../nodeConversions/nodeConversions";
1318

14-
export async function selectedFragmentToHTML<
19+
async function fragmentToExternalHTML<
1520
BSchema extends BlockSchema,
1621
I extends InlineContentSchema,
1722
S extends StyleSchema
1823
>(
19-
view: EditorView,
24+
view: pmView.EditorView,
25+
selectedFragment: Fragment,
2026
editor: BlockNoteEditor<BSchema, I, S>
21-
): Promise<{
22-
clipboardHTML: string;
23-
externalHTML: string;
24-
markdown: string;
25-
}> {
26-
// Checks if a `blockContent` node is being copied and expands
27-
// the selection to the parent `blockContainer` node. This is
28-
// for the use-case in which only a block without content is
29-
// selected, e.g. an image block.
30-
if (
31-
"node" in view.state.selection &&
32-
(view.state.selection.node as Node).type.spec.group === "blockContent"
33-
) {
34-
editor.dispatch(
35-
editor._tiptapEditor.state.tr.setSelection(
36-
new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1))
37-
)
38-
);
39-
}
40-
41-
// Uses default ProseMirror clipboard serialization.
42-
const clipboardHTML: string = (pmView as any).__serializeForClipboard(
43-
view,
44-
view.state.selection.content()
45-
).dom.innerHTML;
46-
47-
let selectedFragment = view.state.selection.content().content;
48-
49-
// Checks whether block ancestry should be included when creating external
50-
// HTML. If the selection is within a block content node, the block ancestry
51-
// is excluded as we only care about the inline content.
27+
) {
5228
let isWithinBlockContent = false;
5329
const isWithinTable = view.state.selection instanceof CellSelection;
30+
5431
if (!isWithinTable) {
32+
// Checks whether block ancestry should be included when creating external
33+
// HTML. If the selection is within a block content node, the block ancestry
34+
// is excluded as we only care about the inline content.
5535
const fragmentWithoutParents = view.state.doc.slice(
5636
view.state.selection.from,
5737
view.state.selection.to,
@@ -75,14 +55,89 @@ export async function selectedFragmentToHTML<
7555
}
7656
}
7757

58+
let externalHTML: string;
59+
7860
await initializeESMDependencies();
7961
const externalHTMLExporter = createExternalHTMLExporter(
8062
view.state.schema,
8163
editor
8264
);
83-
const externalHTML = externalHTMLExporter.exportProseMirrorFragment(
65+
66+
if (isWithinTable) {
67+
if (selectedFragment.firstChild?.type.name === "table") {
68+
// contentNodeToTableContent expects the fragment of the content of a table, not the table node itself
69+
// but cellselection.content() returns the table node itself if all cells and columns are selected
70+
selectedFragment = selectedFragment.firstChild.content;
71+
}
72+
73+
// first convert selection to blocknote-style table content, and then
74+
// pass this to the exporter
75+
const ic = contentNodeToTableContent(
76+
selectedFragment as any,
77+
editor.schema.inlineContentSchema,
78+
editor.schema.styleSchema
79+
);
80+
81+
externalHTML = externalHTMLExporter.exportInlineContent(ic as any, {
82+
simplifyBlocks: false,
83+
});
84+
} else if (isWithinBlockContent) {
85+
// first convert selection to blocknote-style inline content, and then
86+
// pass this to the exporter
87+
const ic = contentNodeToInlineContent(
88+
selectedFragment as any,
89+
editor.schema.inlineContentSchema,
90+
editor.schema.styleSchema
91+
);
92+
externalHTML = externalHTMLExporter.exportInlineContent(ic, {
93+
simplifyBlocks: false,
94+
});
95+
} else {
96+
const blocks = fragmentToBlocks(selectedFragment, editor.schema);
97+
externalHTML = externalHTMLExporter.exportBlocks(blocks, {});
98+
}
99+
return externalHTML;
100+
}
101+
102+
export async function selectedFragmentToHTML<
103+
BSchema extends BlockSchema,
104+
I extends InlineContentSchema,
105+
S extends StyleSchema
106+
>(
107+
view: EditorView,
108+
editor: BlockNoteEditor<BSchema, I, S>
109+
): Promise<{
110+
clipboardHTML: string;
111+
externalHTML: string;
112+
markdown: string;
113+
}> {
114+
// Checks if a `blockContent` node is being copied and expands
115+
// the selection to the parent `blockContainer` node. This is
116+
// for the use-case in which only a block without content is
117+
// selected, e.g. an image block.
118+
if (
119+
"node" in view.state.selection &&
120+
(view.state.selection.node as Node).type.spec.group === "blockContent"
121+
) {
122+
editor.dispatch(
123+
editor._tiptapEditor.state.tr.setSelection(
124+
new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1))
125+
)
126+
);
127+
}
128+
129+
// Uses default ProseMirror clipboard serialization.
130+
const clipboardHTML: string = (pmView as any).__serializeForClipboard(
131+
view,
132+
view.state.selection.content()
133+
).dom.innerHTML;
134+
135+
const selectedFragment = view.state.selection.content().content;
136+
137+
const externalHTML = await fragmentToExternalHTML<BSchema, I, S>(
138+
view,
84139
selectedFragment,
85-
{ simplifyBlocks: !isWithinBlockContent && !isWithinTable }
140+
editor
86141
);
87142

88143
const markdown = cleanHTMLToMarkdown(externalHTML);
Lines changed: 60 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model";
1+
import { DOMSerializer, Schema } from "prosemirror-model";
22

33
import { PartialBlock } from "../../../blocks/defaultBlocks";
44
import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
5-
import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema";
5+
import {
6+
BlockSchema,
7+
InlineContent,
8+
InlineContentSchema,
9+
StyleSchema,
10+
} from "../../../schema";
611
import { esmDependencies } from "../../../util/esmDependencies";
7-
import { blockToNode } from "../../nodeConversions/nodeConversions";
812
import {
9-
serializeNodeInner,
10-
serializeProseMirrorFragment,
13+
serializeBlocks,
14+
serializeInlineContent,
1115
} from "./util/sharedHTMLConversion";
1216
import { simplifyBlocks } from "./util/simplifyBlocksRehypePlugin";
1317

@@ -24,26 +28,6 @@ import { simplifyBlocks } from "./util/simplifyBlocksRehypePlugin";
2428
// 3. While nesting for list items is preserved, other types of blocks nested
2529
// inside a list are un-nested and a new list is created after them.
2630
// 4. The HTML is wrapped in a single `div` element.
27-
//
28-
// The serializer has 2 main methods:
29-
// `exportBlocks`: Exports an array of blocks to HTML.
30-
// `exportFragment`: Exports a ProseMirror fragment to HTML. This is mostly
31-
// useful if you want to export a selection which may not start/end at the
32-
// start/end of a block.
33-
export interface ExternalHTMLExporter<
34-
BSchema extends BlockSchema,
35-
I extends InlineContentSchema,
36-
S extends StyleSchema
37-
> {
38-
exportBlocks: (
39-
blocks: PartialBlock<BSchema, I, S>[],
40-
options: { document?: Document }
41-
) => string;
42-
exportProseMirrorFragment: (
43-
fragment: Fragment,
44-
options: { document?: Document; simplifyBlocks?: boolean }
45-
) => string;
46-
}
4731

4832
// Needs to be sync because it's used in drag handler event (SideMenuPlugin)
4933
// Ideally, call `await initializeESMDependencies()` before calling this function
@@ -54,7 +38,7 @@ export const createExternalHTMLExporter = <
5438
>(
5539
schema: Schema,
5640
editor: BlockNoteEditor<BSchema, I, S>
57-
): ExternalHTMLExporter<BSchema, I, S> => {
41+
) => {
5842
const deps = esmDependencies;
5943

6044
if (!deps) {
@@ -63,67 +47,63 @@ export const createExternalHTMLExporter = <
6347
);
6448
}
6549

66-
// TODO: maybe cache this serializer (default prosemirror serializer is cached)?
67-
const serializer = new DOMSerializer(
68-
DOMSerializer.nodesFromSchema(schema),
69-
DOMSerializer.marksFromSchema(schema)
70-
) as DOMSerializer & {
71-
serializeNodeInner: (
72-
node: Node,
73-
options: { document?: Document }
74-
) => HTMLElement;
75-
exportProseMirrorFragment: (
76-
fragment: Fragment,
77-
options: { document?: Document; simplifyBlocks?: boolean }
78-
) => string;
50+
const serializer = DOMSerializer.fromSchema(schema);
51+
52+
return {
7953
exportBlocks: (
8054
blocks: PartialBlock<BSchema, I, S>[],
8155
options: { document?: Document }
82-
) => string;
83-
};
56+
) => {
57+
const html = serializeBlocks(
58+
editor,
59+
blocks,
60+
serializer,
61+
true,
62+
options
63+
).outerHTML;
8464

85-
serializer.serializeNodeInner = (
86-
node: Node,
87-
options: { document?: Document }
88-
) => serializeNodeInner(node, options, serializer, editor, true);
65+
// Possible improvement: now, we first use the serializeBlocks function
66+
// which adds blockcontainer and blockgroup wrappers. We then pass the
67+
// result to simplifyBlocks, which then cleans the wrappers.
68+
//
69+
// It might be easier if we create a version of serializeBlocks that
70+
// doesn't add the wrappers in the first place, then we can get rid of
71+
// the more complex simplifyBlocks plugin.
72+
let externalHTML: any = deps.unified
73+
.unified()
74+
.use(deps.rehypeParse.default, { fragment: true });
75+
if ((options as any).simplifyBlocks !== false) {
76+
externalHTML = externalHTML.use(simplifyBlocks, {
77+
orderedListItemBlockTypes: new Set<string>(["numberedListItem"]),
78+
unorderedListItemBlockTypes: new Set<string>([
79+
"bulletListItem",
80+
"checkListItem",
81+
]),
82+
});
83+
}
84+
externalHTML = externalHTML
85+
.use(deps.rehypeStringify.default)
86+
.processSync(html);
8987

90-
// Like the `internalHTMLSerializer`, also uses `serializeProseMirrorFragment`
91-
// but additionally runs it through the `simplifyBlocks` rehype plugin to
92-
// convert the internal HTML to external.
93-
serializer.exportProseMirrorFragment = (fragment, options) => {
94-
let externalHTML: any = deps.unified
95-
.unified()
96-
.use(deps.rehypeParse.default, { fragment: true });
97-
if (options.simplifyBlocks !== false) {
98-
externalHTML = externalHTML.use(simplifyBlocks, {
99-
orderedListItemBlockTypes: new Set<string>(["numberedListItem"]),
100-
unorderedListItemBlockTypes: new Set<string>([
101-
"bulletListItem",
102-
"checkListItem",
103-
]),
104-
});
105-
}
106-
externalHTML = externalHTML
107-
.use(deps.rehypeStringify.default)
108-
.processSync(serializeProseMirrorFragment(fragment, serializer, options));
88+
return externalHTML.value as string;
89+
},
10990

110-
return externalHTML.value as string;
111-
};
91+
exportInlineContent: (
92+
inlineContent: InlineContent<I, S>[],
93+
options: { simplifyBlocks: boolean; document?: Document }
94+
) => {
95+
const domFragment = serializeInlineContent(
96+
editor,
97+
inlineContent as any,
98+
serializer,
99+
true,
100+
options
101+
);
112102

113-
serializer.exportBlocks = (
114-
blocks: PartialBlock<BSchema, I, S>[],
115-
options
116-
) => {
117-
const nodes = blocks.map((block) =>
118-
blockToNode(block, schema, editor.schema.styleSchema)
119-
);
120-
const blockGroup = schema.nodes["blockGroup"].create(null, nodes);
103+
const parent = document.createElement("div");
104+
parent.append(domFragment.cloneNode(true));
121105

122-
return serializer.exportProseMirrorFragment(
123-
Fragment.from(blockGroup),
124-
options
125-
);
106+
return parent.innerHTML;
107+
},
126108
};
127-
128-
return serializer;
129109
};

packages/core/src/api/exporters/html/htmlConversion.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
33
import { addIdsToBlocks, partialBlocksToBlocksForTesting } from "../../..";
44
import { PartialBlock } from "../../../blocks/defaultBlocks";
55
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
6-
import { BlockSchema } from "../../../schema";
7-
import { InlineContentSchema } from "../../../schema";
8-
import { StyleSchema } from "../../../schema";
6+
import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema";
97
import { initializeESMDependencies } from "../../../util/esmDependencies";
108
import { customBlocksTestCases } from "../../testUtil/cases/customBlocks";
119
import { customInlineContentTestCases } from "../../testUtil/cases/customInlineContent";
@@ -25,6 +23,7 @@ async function convertToHTMLAndCompareSnapshots<
2523
snapshotName: string
2624
) {
2725
addIdsToBlocks(blocks);
26+
2827
const serializer = createInternalHTMLSerializer(editor.pmSchema, editor);
2928
const internalHTML = serializer.serializeBlocks(blocks, {});
3029
const internalHTMLSnapshotPath =

0 commit comments

Comments
 (0)