Skip to content

refactor: cleaned serialization code #1129

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Oct 8, 2024
Merged
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
@@ -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>
<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>
3 changes: 1 addition & 2 deletions packages/core/src/api/clipboard/clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";

import { PartialBlock } from "../../blocks/defaultBlocks";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor";
import { doPaste } from "../testUtil/paste";
import { initializeESMDependencies } from "../../util/esmDependencies";
import { doPaste } from "../testUtil/paste";
import { selectedFragmentToHTML } from "./toClipboard/copyExtension";

type SelectionTestCase = {
Expand Down Expand Up @@ -269,7 +269,6 @@ describe("Test ProseMirror selection clipboard HTML", () => {
createSelection: (doc) => CellSelection.create(doc, 214, 228),
},
// Selection spans all cells of the table.
// TODO: External HTML is wrapped in unnecessary `blockContent` element.
{
testName: "tableAllCells",
createSelection: (doc) => CellSelection.create(doc, 214, 258),
Expand Down
127 changes: 91 additions & 36 deletions packages/core/src/api/clipboard/toClipboard/copyExtension.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Extension } from "@tiptap/core";
import { Node } from "prosemirror-model";
import { Fragment, Node } from "prosemirror-model";
import { NodeSelection, Plugin } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import * as pmView from "prosemirror-view";
Expand All @@ -10,48 +10,28 @@ import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema";
import { initializeESMDependencies } from "../../../util/esmDependencies";
import { createExternalHTMLExporter } from "../../exporters/html/externalHTMLExporter";
import { cleanHTMLToMarkdown } from "../../exporters/markdown/markdownExporter";
import { fragmentToBlocks } from "../../nodeConversions/fragmentToBlocks";
import {
contentNodeToInlineContent,
contentNodeToTableContent,
} from "../../nodeConversions/nodeConversions";

export async function selectedFragmentToHTML<
async function fragmentToExternalHTML<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema
>(
view: EditorView,
view: pmView.EditorView,
selectedFragment: Fragment,
editor: BlockNoteEditor<BSchema, I, S>
): Promise<{
clipboardHTML: string;
externalHTML: string;
markdown: string;
}> {
// Checks if a `blockContent` node is being copied and expands
// the selection to the parent `blockContainer` node. This is
// for the use-case in which only a block without content is
// selected, e.g. an image block.
if (
"node" in view.state.selection &&
(view.state.selection.node as Node).type.spec.group === "blockContent"
) {
editor.dispatch(
editor._tiptapEditor.state.tr.setSelection(
new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1))
)
);
}

// Uses default ProseMirror clipboard serialization.
const clipboardHTML: string = (pmView as any).__serializeForClipboard(
view,
view.state.selection.content()
).dom.innerHTML;

let selectedFragment = view.state.selection.content().content;

// Checks whether block ancestry should be included when creating external
// HTML. If the selection is within a block content node, the block ancestry
// is excluded as we only care about the inline content.
) {
let isWithinBlockContent = false;
const isWithinTable = view.state.selection instanceof CellSelection;

if (!isWithinTable) {
// Checks whether block ancestry should be included when creating external
// HTML. If the selection is within a block content node, the block ancestry
// is excluded as we only care about the inline content.
const fragmentWithoutParents = view.state.doc.slice(
view.state.selection.from,
view.state.selection.to,
Expand All @@ -75,14 +55,89 @@ export async function selectedFragmentToHTML<
}
}

let externalHTML: string;

await initializeESMDependencies();
const externalHTMLExporter = createExternalHTMLExporter(
view.state.schema,
editor
);
const externalHTML = externalHTMLExporter.exportProseMirrorFragment(

if (isWithinTable) {
if (selectedFragment.firstChild?.type.name === "table") {
// contentNodeToTableContent expects the fragment of the content of a table, not the table node itself
// but cellselection.content() returns the table node itself if all cells and columns are selected
selectedFragment = selectedFragment.firstChild.content;
}

// first convert selection to blocknote-style table content, and then
// pass this to the exporter
const ic = contentNodeToTableContent(
selectedFragment as any,
editor.schema.inlineContentSchema,
editor.schema.styleSchema
);

externalHTML = externalHTMLExporter.exportInlineContent(ic as any, {
simplifyBlocks: false,
});
} else if (isWithinBlockContent) {
// first convert selection to blocknote-style inline content, and then
// pass this to the exporter
const ic = contentNodeToInlineContent(
selectedFragment as any,
editor.schema.inlineContentSchema,
editor.schema.styleSchema
);
externalHTML = externalHTMLExporter.exportInlineContent(ic, {
simplifyBlocks: false,
});
} else {
const blocks = fragmentToBlocks(selectedFragment, editor.schema);
externalHTML = externalHTMLExporter.exportBlocks(blocks, {});
}
return externalHTML;
}

export async function selectedFragmentToHTML<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema
>(
view: EditorView,
editor: BlockNoteEditor<BSchema, I, S>
): Promise<{
clipboardHTML: string;
externalHTML: string;
markdown: string;
}> {
// Checks if a `blockContent` node is being copied and expands
// the selection to the parent `blockContainer` node. This is
// for the use-case in which only a block without content is
// selected, e.g. an image block.
if (
"node" in view.state.selection &&
(view.state.selection.node as Node).type.spec.group === "blockContent"
) {
editor.dispatch(
editor._tiptapEditor.state.tr.setSelection(
new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1))
)
);
}

// Uses default ProseMirror clipboard serialization.
const clipboardHTML: string = (pmView as any).__serializeForClipboard(
view,
view.state.selection.content()
).dom.innerHTML;

const selectedFragment = view.state.selection.content().content;

const externalHTML = await fragmentToExternalHTML<BSchema, I, S>(
view,
selectedFragment,
{ simplifyBlocks: !isWithinBlockContent && !isWithinTable }
editor
);

const markdown = cleanHTMLToMarkdown(externalHTML);
Expand Down
140 changes: 60 additions & 80 deletions packages/core/src/api/exporters/html/externalHTMLExporter.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model";
import { DOMSerializer, Schema } from "prosemirror-model";

import { PartialBlock } from "../../../blocks/defaultBlocks";
import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema";
import {
BlockSchema,
InlineContent,
InlineContentSchema,
StyleSchema,
} from "../../../schema";
import { esmDependencies } from "../../../util/esmDependencies";
import { blockToNode } from "../../nodeConversions/nodeConversions";
import {
serializeNodeInner,
serializeProseMirrorFragment,
serializeBlocks,
serializeInlineContent,
} from "./util/sharedHTMLConversion";
import { simplifyBlocks } from "./util/simplifyBlocksRehypePlugin";

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

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

if (!deps) {
Expand All @@ -63,67 +47,63 @@ export const createExternalHTMLExporter = <
);
}

// TODO: maybe cache this serializer (default prosemirror serializer is cached)?
const serializer = new DOMSerializer(
DOMSerializer.nodesFromSchema(schema),
DOMSerializer.marksFromSchema(schema)
) as DOMSerializer & {
serializeNodeInner: (
node: Node,
options: { document?: Document }
) => HTMLElement;
exportProseMirrorFragment: (
fragment: Fragment,
options: { document?: Document; simplifyBlocks?: boolean }
) => string;
const serializer = DOMSerializer.fromSchema(schema);

return {
exportBlocks: (
blocks: PartialBlock<BSchema, I, S>[],
options: { document?: Document }
) => string;
};
) => {
const html = serializeBlocks(
editor,
blocks,
serializer,
true,
options
).outerHTML;

serializer.serializeNodeInner = (
node: Node,
options: { document?: Document }
) => serializeNodeInner(node, options, serializer, editor, true);
// Possible improvement: now, we first use the serializeBlocks function
// which adds blockcontainer and blockgroup wrappers. We then pass the
// result to simplifyBlocks, which then cleans the wrappers.
//
// It might be easier if we create a version of serializeBlocks that
// doesn't add the wrappers in the first place, then we can get rid of
// the more complex simplifyBlocks plugin.
let externalHTML: any = deps.unified
.unified()
.use(deps.rehypeParse.default, { fragment: true });
if ((options as any).simplifyBlocks !== false) {
externalHTML = externalHTML.use(simplifyBlocks, {
orderedListItemBlockTypes: new Set<string>(["numberedListItem"]),
unorderedListItemBlockTypes: new Set<string>([
"bulletListItem",
"checkListItem",
]),
});
}
externalHTML = externalHTML
.use(deps.rehypeStringify.default)
.processSync(html);

// Like the `internalHTMLSerializer`, also uses `serializeProseMirrorFragment`
// but additionally runs it through the `simplifyBlocks` rehype plugin to
// convert the internal HTML to external.
serializer.exportProseMirrorFragment = (fragment, options) => {
let externalHTML: any = deps.unified
.unified()
.use(deps.rehypeParse.default, { fragment: true });
if (options.simplifyBlocks !== false) {
externalHTML = externalHTML.use(simplifyBlocks, {
orderedListItemBlockTypes: new Set<string>(["numberedListItem"]),
unorderedListItemBlockTypes: new Set<string>([
"bulletListItem",
"checkListItem",
]),
});
}
externalHTML = externalHTML
.use(deps.rehypeStringify.default)
.processSync(serializeProseMirrorFragment(fragment, serializer, options));
return externalHTML.value as string;
},

return externalHTML.value as string;
};
exportInlineContent: (
inlineContent: InlineContent<I, S>[],
options: { simplifyBlocks: boolean; document?: Document }
) => {
const domFragment = serializeInlineContent(
editor,
inlineContent as any,
serializer,
true,
options
);

serializer.exportBlocks = (
blocks: PartialBlock<BSchema, I, S>[],
options
) => {
const nodes = blocks.map((block) =>
blockToNode(block, schema, editor.schema.styleSchema)
);
const blockGroup = schema.nodes["blockGroup"].create(null, nodes);
const parent = document.createElement("div");
parent.append(domFragment.cloneNode(true));

return serializer.exportProseMirrorFragment(
Fragment.from(blockGroup),
options
);
return parent.innerHTML;
},
};

return serializer;
};
5 changes: 2 additions & 3 deletions packages/core/src/api/exporters/html/htmlConversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { addIdsToBlocks, partialBlocksToBlocksForTesting } from "../../..";
import { PartialBlock } from "../../../blocks/defaultBlocks";
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
import { BlockSchema } from "../../../schema";
import { InlineContentSchema } from "../../../schema";
import { StyleSchema } from "../../../schema";
import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema";
import { initializeESMDependencies } from "../../../util/esmDependencies";
import { customBlocksTestCases } from "../../testUtil/cases/customBlocks";
import { customInlineContentTestCases } from "../../testUtil/cases/customInlineContent";
Expand All @@ -25,6 +23,7 @@ async function convertToHTMLAndCompareSnapshots<
snapshotName: string
) {
addIdsToBlocks(blocks);

const serializer = createInternalHTMLSerializer(editor.pmSchema, editor);
const internalHTML = serializer.serializeBlocks(blocks, {});
const internalHTMLSnapshotPath =
Expand Down
Loading
Loading