diff --git a/packages/core/src/api/clipboard/__snapshots__/childToParent.html b/packages/core/src/api/clipboard/__snapshots__/childToParent.html new file mode 100644 index 000000000..a899fb60f --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/childToParent.html @@ -0,0 +1 @@ +

Heading 1

Nested Paragraph 1

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/childrenToNextParent.html b/packages/core/src/api/clipboard/__snapshots__/childrenToNextParent.html new file mode 100644 index 000000000..851b8973d --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/childrenToNextParent.html @@ -0,0 +1 @@ +

Nested Paragraph 1

Nested Paragraph 2

Nested Paragraph 3

Heading 2

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/childrenToNextParentsChildren.html b/packages/core/src/api/clipboard/__snapshots__/childrenToNextParentsChildren.html new file mode 100644 index 000000000..ba81a3a20 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/childrenToNextParentsChildren.html @@ -0,0 +1 @@ +

Nested Paragraph 1

Nested Paragraph 2

Nested Paragraph 3

Heading 2

Nested Paragraph 1

Nested Paragraph 2

Nested Paragraph 3

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/image.html b/packages/core/src/api/clipboard/__snapshots__/image.html new file mode 100644 index 000000000..09248e592 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/image.html @@ -0,0 +1 @@ +BlockNote image

Nested Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots_fragment_edge_cases__/selectionWithinBlockChildren.html b/packages/core/src/api/clipboard/__snapshots__/multipleChildren.html similarity index 100% rename from packages/core/src/api/exporters/html/__snapshots_fragment_edge_cases__/selectionWithinBlockChildren.html rename to packages/core/src/api/clipboard/__snapshots__/multipleChildren.html diff --git a/packages/core/src/api/clipboard/__snapshots__/multipleStyledText.html b/packages/core/src/api/clipboard/__snapshots__/multipleStyledText.html new file mode 100644 index 000000000..a9991463b --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/multipleStyledText.html @@ -0,0 +1 @@ +BoldItalicRegular \ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/nestedImage.html b/packages/core/src/api/clipboard/__snapshots__/nestedImage.html new file mode 100644 index 000000000..4e181d251 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/nestedImage.html @@ -0,0 +1 @@ +

BoldItalicRegular

BlockNote image

Nested Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/partialChildToParent.html b/packages/core/src/api/clipboard/__snapshots__/partialChildToParent.html new file mode 100644 index 000000000..f4d82fbae --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/partialChildToParent.html @@ -0,0 +1 @@ +

ding 1

Nested

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/styledText.html b/packages/core/src/api/clipboard/__snapshots__/styledText.html new file mode 100644 index 000000000..03d564343 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/styledText.html @@ -0,0 +1 @@ +Italic \ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/tableAllCells.html b/packages/core/src/api/clipboard/__snapshots__/tableAllCells.html new file mode 100644 index 000000000..1de956c17 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/tableAllCells.html @@ -0,0 +1 @@ +

Table Cell

Table Cell

Table Cell

Table Cell

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/tableCell.html b/packages/core/src/api/clipboard/__snapshots__/tableCell.html new file mode 100644 index 000000000..b7fa75d30 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/tableCell.html @@ -0,0 +1 @@ +

Table Cell

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/tableCellText.html b/packages/core/src/api/clipboard/__snapshots__/tableCellText.html new file mode 100644 index 000000000..cd55158ac --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/tableCellText.html @@ -0,0 +1 @@ +Table Cell \ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/tableRow.html b/packages/core/src/api/clipboard/__snapshots__/tableRow.html new file mode 100644 index 000000000..a7d0f18df --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/tableRow.html @@ -0,0 +1 @@ +

Table Cell

Table Cell

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/unstyledText.html b/packages/core/src/api/clipboard/__snapshots__/unstyledText.html new file mode 100644 index 000000000..ea9503c08 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/unstyledText.html @@ -0,0 +1 @@ +Regular \ No newline at end of file diff --git a/packages/core/src/api/clipboard/clipboard.test.ts b/packages/core/src/api/clipboard/clipboard.test.ts new file mode 100644 index 000000000..58cf9e892 --- /dev/null +++ b/packages/core/src/api/clipboard/clipboard.test.ts @@ -0,0 +1,284 @@ +import { Node } from "prosemirror-model"; +import { NodeSelection, Selection, TextSelection } from "prosemirror-state"; +import { CellSelection } from "prosemirror-tables"; +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 { selectedFragmentToHTML } from "./toClipboard/copyExtension"; + +type SelectionTestCase = { + testName: string; + createSelection: (doc: Node) => Selection; +}; + +// These tests are meant to test the copying of user selections in the editor. +// The test cases used for the other HTML conversion tests are not suitable here +// as they are represented in the BlockNote API, whereas here we want to test +// ProseMirror/TipTap selections directly. +describe("Test ProseMirror selection clipboard HTML", () => { + const initialContent: PartialBlock[] = [ + { + type: "heading", + props: { + level: 2, + textColor: "red", + }, + content: "Heading 1", + children: [ + { + type: "paragraph", + content: "Nested Paragraph 1", + }, + { + type: "paragraph", + content: "Nested Paragraph 2", + }, + { + type: "paragraph", + content: "Nested Paragraph 3", + }, + ], + }, + { + type: "heading", + props: { + level: 2, + textColor: "red", + }, + content: "Heading 2", + children: [ + { + type: "paragraph", + content: "Nested Paragraph 1", + }, + { + type: "paragraph", + content: "Nested Paragraph 2", + }, + { + type: "paragraph", + content: "Nested Paragraph 3", + }, + ], + }, + { + type: "heading", + props: { + level: 2, + textColor: "red", + }, + content: [ + { + type: "text", + text: "Bold", + styles: { + bold: true, + }, + }, + { + type: "text", + text: "Italic", + styles: { + italic: true, + }, + }, + { + type: "text", + text: "Regular", + styles: {}, + }, + ], + children: [ + { + type: "image", + props: { + url: "https://ralfvanveen.com/wp-content/uploads/2021/06/Placeholder-_-Glossary.svg", + }, + children: [ + { + type: "paragraph", + content: "Nested Paragraph", + }, + ], + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell"], + }, + ], + }, + // Not needed as selections starting in table cells will get snapped to + // the table boundaries. + // children: [ + // { + // type: "table", + // content: { + // type: "tableContent", + // rows: [ + // { + // cells: ["Table Cell", "Table Cell"], + // }, + // { + // cells: ["Table Cell", "Table Cell"], + // }, + // ], + // }, + // }, + // ], + }, + ]; + + let editor: BlockNoteEditor; + const div = document.createElement("div"); + + beforeEach(() => { + editor.replaceBlocks(editor.document, initialContent); + }); + + beforeAll(async () => { + (window as any).__TEST_OPTIONS = (window as any).__TEST_OPTIONS || {}; + + editor = BlockNoteEditor.create(); + editor.mount(div); + + await initializeESMDependencies(); + }); + + afterAll(() => { + editor.mount(undefined); + editor._tiptapEditor.destroy(); + editor = undefined as any; + + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + }); + + // Sets the editor selection to the given start and end positions, then + // exports the selected content to HTML and compares it to a snapshot. + async function testSelection(testCase: SelectionTestCase) { + editor.dispatch( + editor._tiptapEditor.state.tr.setSelection( + testCase.createSelection(editor._tiptapEditor.view.state.doc) + ) + ); + + const { clipboardHTML, externalHTML } = await selectedFragmentToHTML( + editor._tiptapEditor.view, + editor + ); + + expect(externalHTML).toMatchFileSnapshot( + `./__snapshots__/${testCase.testName}.html` + ); + + const originalDocument = editor.document; + doPaste( + editor._tiptapEditor.view, + "text", + clipboardHTML, + false, + new ClipboardEvent("paste") + ); + const newDocument = editor.document; + + expect(newDocument).toStrictEqual(originalDocument); + } + + const testCases: SelectionTestCase[] = [ + // TODO: Consider adding test cases for nested blocks & double nested blocks. + // Selection spans all of first heading's children. + { + testName: "multipleChildren", + createSelection: (doc) => TextSelection.create(doc, 16, 78), + }, + // Selection spans from start of first heading to end of its first child. + { + testName: "childToParent", + createSelection: (doc) => TextSelection.create(doc, 3, 34), + }, + // Selection spans from middle of first heading to the middle of its first + // child. + { + testName: "partialChildToParent", + createSelection: (doc) => TextSelection.create(doc, 6, 23), + }, + // Selection spans from start of first heading's first child to end of + // second heading's content (does not include second heading's children). + { + testName: "childrenToNextParent", + createSelection: (doc) => TextSelection.create(doc, 16, 93), + }, + // Selection spans from start of first heading's first child to end of + // second heading's last child. + { + testName: "childrenToNextParentsChildren", + createSelection: (doc) => TextSelection.create(doc, 16, 159), + }, + // Selection spans "Regular" text inside third heading. + { + testName: "unstyledText", + createSelection: (doc) => TextSelection.create(doc, 175, 182), + }, + // Selection spans "Italic" text inside third heading. + { + testName: "styledText", + createSelection: (doc) => TextSelection.create(doc, 169, 175), + }, + // Selection spans third heading's content (does not include third heading's + // children). + { + testName: "multipleStyledText", + createSelection: (doc) => TextSelection.create(doc, 165, 182), + }, + // Selection spans the image block content. + { + testName: "image", + createSelection: (doc) => NodeSelection.create(doc, 185), + }, + // Selection spans from start of third heading to end of it's last + // descendant. + { + testName: "nestedImage", + createSelection: (doc) => TextSelection.create(doc, 165, 205), + }, + // Selection spans text in first cell of the table. + { + testName: "tableCellText", + createSelection: (doc) => TextSelection.create(doc, 216, 226), + }, + // Selection spans first cell of the table. + // TODO: External HTML is wrapped in unnecessary `tr` element. + { + testName: "tableCell", + createSelection: (doc) => CellSelection.create(doc, 214), + }, + // Selection spans first row of the table. + { + testName: "tableRow", + 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), + }, + ]; + + for (const testCase of testCases) { + it(`${testCase.testName}`, async () => { + await testSelection(testCase); + }); + } +}); diff --git a/packages/core/src/api/parsers/acceptedMIMETypes.ts b/packages/core/src/api/clipboard/fromClipboard/acceptedMIMETypes.ts similarity index 100% rename from packages/core/src/api/parsers/acceptedMIMETypes.ts rename to packages/core/src/api/clipboard/fromClipboard/acceptedMIMETypes.ts diff --git a/packages/core/src/api/parsers/fileDropExtension.ts b/packages/core/src/api/clipboard/fromClipboard/fileDropExtension.ts similarity index 94% rename from packages/core/src/api/parsers/fileDropExtension.ts rename to packages/core/src/api/clipboard/fromClipboard/fileDropExtension.ts index 9964b1b0c..657e77527 100644 --- a/packages/core/src/api/parsers/fileDropExtension.ts +++ b/packages/core/src/api/clipboard/fromClipboard/fileDropExtension.ts @@ -1,8 +1,8 @@ import { Extension } from "@tiptap/core"; import { Plugin } from "prosemirror-state"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema"; import { handleFileInsertion } from "./handleFileInsertion"; import { acceptedMIMETypes } from "./acceptedMIMETypes"; diff --git a/packages/core/src/api/parsers/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts similarity index 94% rename from packages/core/src/api/parsers/handleFileInsertion.ts rename to packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts index b68bc4e0d..d060c0656 100644 --- a/packages/core/src/api/parsers/handleFileInsertion.ts +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts @@ -1,12 +1,12 @@ -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { PartialBlock } from "../../blocks/defaultBlocks"; +import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; +import { PartialBlock } from "../../../blocks/defaultBlocks"; import { BlockSchema, FileBlockConfig, InlineContentSchema, StyleSchema, -} from "../../schema"; -import { getBlockInfoFromPos } from "../getBlockInfoFromPos"; +} from "../../../schema"; +import { getBlockInfoFromPos } from "../../getBlockInfoFromPos"; import { acceptedMIMETypes } from "./acceptedMIMETypes"; function checkFileExtensionsMatch( diff --git a/packages/core/src/api/parsers/pasteExtension.ts b/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts similarity index 76% rename from packages/core/src/api/parsers/pasteExtension.ts rename to packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts index 97cfd751c..8b9161b89 100644 --- a/packages/core/src/api/parsers/pasteExtension.ts +++ b/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts @@ -1,11 +1,11 @@ import { Extension } from "@tiptap/core"; import { Plugin } from "prosemirror-state"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; -import { handleFileInsertion } from "./handleFileInsertion"; -import { nestedListsToBlockNoteStructure } from "./html/util/nestedLists"; +import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema"; +import { nestedListsToBlockNoteStructure } from "../../parsers/html/util/nestedLists"; import { acceptedMIMETypes } from "./acceptedMIMETypes"; +import { handleFileInsertion } from "./handleFileInsertion"; export const createPasteFromClipboardExtension = < BSchema extends BlockSchema, @@ -28,14 +28,14 @@ export const createPasteFromClipboardExtension = < return; } - let format: (typeof acceptedMIMETypes)[number] | null = null; + let format: (typeof acceptedMIMETypes)[number] | undefined; for (const mimeType of acceptedMIMETypes) { if (event.clipboardData!.types.includes(mimeType)) { format = mimeType; break; } } - if (format === null) { + if (!format) { return true; } @@ -46,12 +46,19 @@ export const createPasteFromClipboardExtension = < let data = event.clipboardData!.getData(format); + if (format === "blocknote/html") { + editor._tiptapEditor.view.pasteHTML(data); + return true; + } + if (format === "text/html") { const htmlNode = nestedListsToBlockNoteStructure(data.trim()); data = htmlNode.innerHTML; + editor._tiptapEditor.view.pasteHTML(data); + return true; } - editor._tiptapEditor.view.pasteHTML(data); + editor._tiptapEditor.view.pasteText(data); return true; }, diff --git a/packages/core/src/api/exporters/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts similarity index 65% rename from packages/core/src/api/exporters/copyExtension.ts rename to packages/core/src/api/clipboard/toClipboard/copyExtension.ts index 8a168387a..ed3446ac3 100644 --- a/packages/core/src/api/exporters/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -1,16 +1,17 @@ import { Extension } from "@tiptap/core"; import { Node } from "prosemirror-model"; import { NodeSelection, Plugin } from "prosemirror-state"; +import { CellSelection } from "prosemirror-tables"; +import * as pmView from "prosemirror-view"; import { EditorView } from "prosemirror-view"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; -import { initializeESMDependencies } from "../../util/esmDependencies"; -import { createExternalHTMLExporter } from "./html/externalHTMLExporter"; -import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; -import { cleanHTMLToMarkdown } from "./markdown/markdownExporter"; - -async function selectedFragmentToHTML< +import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema"; +import { initializeESMDependencies } from "../../../util/esmDependencies"; +import { createExternalHTMLExporter } from "../../exporters/html/externalHTMLExporter"; +import { cleanHTMLToMarkdown } from "../../exporters/markdown/markdownExporter"; + +export async function selectedFragmentToHTML< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema @@ -18,20 +19,61 @@ async function selectedFragmentToHTML< view: EditorView, editor: BlockNoteEditor ): Promise<{ - internalHTML: string; + clipboardHTML: string; externalHTML: string; - plainText: string; + markdown: string; }> { - const selectedFragment = view.state.selection.content().content; + // 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)) + ) + ); + } - const internalHTMLSerializer = createInternalHTMLSerializer( - view.state.schema, - editor - ); - const internalHTML = internalHTMLSerializer.serializeProseMirrorFragment( - selectedFragment, - {} - ); + // 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) { + const fragmentWithoutParents = view.state.doc.slice( + view.state.selection.from, + view.state.selection.to, + false + ).content; + + const children = []; + for (let i = 0; i < fragmentWithoutParents.childCount; i++) { + children.push(fragmentWithoutParents.child(i)); + } + + isWithinBlockContent = + children.find( + (child) => + child.type.name === "blockContainer" || + child.type.name === "blockGroup" || + child.type.spec.group === "blockContent" + ) === undefined; + if (isWithinBlockContent) { + selectedFragment = fragmentWithoutParents; + } + } await initializeESMDependencies(); const externalHTMLExporter = createExternalHTMLExporter( @@ -40,12 +82,12 @@ async function selectedFragmentToHTML< ); const externalHTML = externalHTMLExporter.exportProseMirrorFragment( selectedFragment, - {} + { simplifyBlocks: !isWithinBlockContent && !isWithinTable } ); - const plainText = await cleanHTMLToMarkdown(externalHTML); + const markdown = cleanHTMLToMarkdown(externalHTML); - return { internalHTML, externalHTML, plainText }; + return { clipboardHTML, externalHTML, markdown }; } const copyToClipboard = < @@ -61,30 +103,15 @@ const copyToClipboard = < event.preventDefault(); event.clipboardData!.clearData(); - // 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)) - ) - ); - } - (async () => { - const { internalHTML, externalHTML, plainText } = + const { clipboardHTML, externalHTML, markdown } = await selectedFragmentToHTML(view, editor); // TODO: Writing to other MIME types not working in Safari for // some reason. - event.clipboardData!.setData("blocknote/html", internalHTML); + event.clipboardData!.setData("blocknote/html", clipboardHTML); event.clipboardData!.setData("text/html", externalHTML); - event.clipboardData!.setData("text/plain", plainText); + event.clipboardData!.setData("text/plain", markdown); })(); }; @@ -144,14 +171,14 @@ export const createCopyToClipboardExtension = < event.dataTransfer!.clearData(); (async () => { - const { internalHTML, externalHTML, plainText } = + const { clipboardHTML, externalHTML, markdown } = await selectedFragmentToHTML(view, editor); // TODO: Writing to other MIME types not working in Safari for // some reason. - event.dataTransfer!.setData("blocknote/html", internalHTML); + event.dataTransfer!.setData("blocknote/html", clipboardHTML); event.dataTransfer!.setData("text/html", externalHTML); - event.dataTransfer!.setData("text/plain", plainText); + event.dataTransfer!.setData("text/plain", markdown); })(); // Prevent default PM handler to be called return true; diff --git a/packages/core/src/api/exporters/html/__snapshots_fragment_edge_cases__/selectionLeavesBlockChildren.html b/packages/core/src/api/exporters/html/__snapshots_fragment_edge_cases__/selectionLeavesBlockChildren.html deleted file mode 100644 index 5bad4edb4..000000000 --- a/packages/core/src/api/exporters/html/__snapshots_fragment_edge_cases__/selectionLeavesBlockChildren.html +++ /dev/null @@ -1 +0,0 @@ -

Nested Paragraph 1

Nested Paragraph 2

Nested Paragraph 3

Paragraph 2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots_fragment_edge_cases__/selectionSpansBlocksChildren.html b/packages/core/src/api/exporters/html/__snapshots_fragment_edge_cases__/selectionSpansBlocksChildren.html deleted file mode 100644 index 5a9420782..000000000 --- a/packages/core/src/api/exporters/html/__snapshots_fragment_edge_cases__/selectionSpansBlocksChildren.html +++ /dev/null @@ -1 +0,0 @@ -

Nested Paragraph 1

Nested Paragraph 2

Nested Paragraph 3

Paragraph 2

Nested Paragraph 1

Nested Paragraph 2

Nested Paragraph 3

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts index beb81487c..2d6a859d9 100644 --- a/packages/core/src/api/exporters/html/externalHTMLExporter.ts +++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts @@ -41,7 +41,7 @@ export interface ExternalHTMLExporter< ) => string; exportProseMirrorFragment: ( fragment: Fragment, - options: { document?: Document } + options: { document?: Document; simplifyBlocks?: boolean } ) => string; } @@ -63,14 +63,18 @@ export const createExternalHTMLExporter = < ); } - const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { + // 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 } + options: { document?: Document; simplifyBlocks?: boolean } ) => string; exportBlocks: ( blocks: PartialBlock[], @@ -87,16 +91,19 @@ export const createExternalHTMLExporter = < // but additionally runs it through the `simplifyBlocks` rehype plugin to // convert the internal HTML to external. serializer.exportProseMirrorFragment = (fragment, options) => { - const externalHTML = deps.unified + let externalHTML: any = deps.unified .unified() - .use(deps.rehypeParse.default, { fragment: true }) - .use(simplifyBlocks, { + .use(deps.rehypeParse.default, { fragment: true }); + if (options.simplifyBlocks !== false) { + externalHTML = externalHTML.use(simplifyBlocks, { orderedListItemBlockTypes: new Set(["numberedListItem"]), unorderedListItemBlockTypes: new Set([ "bulletListItem", "checkListItem", ]), - }) + }); + } + externalHTML = externalHTML .use(deps.rehypeStringify.default) .processSync(serializeProseMirrorFragment(fragment, serializer, options)); diff --git a/packages/core/src/api/exporters/html/htmlConversion.test.ts b/packages/core/src/api/exporters/html/htmlConversion.test.ts index a08367f00..850158902 100644 --- a/packages/core/src/api/exporters/html/htmlConversion.test.ts +++ b/packages/core/src/api/exporters/html/htmlConversion.test.ts @@ -1,12 +1,11 @@ -import { TextSelection } from "prosemirror-state"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; import { addIdsToBlocks, partialBlocksToBlocksForTesting } from "../../.."; import { PartialBlock } from "../../../blocks/defaultBlocks"; -import { BlockSchema } from "../../../schema/blocks/types"; -import { InlineContentSchema } from "../../../schema/inlineContent/types"; -import { StyleSchema } from "../../../schema/styles/types"; +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; +import { BlockSchema } from "../../../schema"; +import { InlineContentSchema } from "../../../schema"; +import { StyleSchema } from "../../../schema"; import { initializeESMDependencies } from "../../../util/esmDependencies"; import { customBlocksTestCases } from "../../testUtil/cases/customBlocks"; import { customInlineContentTestCases } from "../../testUtil/cases/customInlineContent"; @@ -106,145 +105,3 @@ describe("Test HTML conversion", () => { }); } }); - -// Fragments created from ProseMirror selections don't always conform to the -// schema. This is because ProseMirror preserves the full ancestry of selected -// nodes, but not the siblings of ancestor nodes. These tests are to verify that -// Fragments like this are exported to HTML properly, as they can't be created -// from Block objects like all the other test cases (Block object conversions -// always conform to the schema). -describe("Test ProseMirror fragment edge case conversion", () => { - let editor: BlockNoteEditor; - const div = document.createElement("div"); - beforeEach(() => { - editor = BlockNoteEditor.create(); - editor.mount(div); - }); - - afterEach(() => { - editor.mount(undefined); - editor._tiptapEditor.destroy(); - editor = undefined as any; - - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; - }); - - // When the selection starts in a nested block, the Fragment from it omits the - // `blockContent` node of the parent `blockContainer` if it's not also - // included in the selection. In the schema, `blockContainer` nodes should - // contain a single `blockContent` node, so this edge case needs to be tested. - describe("No block content", () => { - const blocks: PartialBlock[] = [ - { - type: "paragraph", - content: "Paragraph 1", - children: [ - { - type: "paragraph", - content: "Nested Paragraph 1", - }, - { - type: "paragraph", - content: "Nested Paragraph 2", - }, - { - type: "paragraph", - content: "Nested Paragraph 3", - }, - ], - }, - { - type: "paragraph", - content: "Paragraph 2", - children: [ - { - type: "paragraph", - content: "Nested Paragraph 1", - }, - { - type: "paragraph", - content: "Nested Paragraph 2", - }, - { - type: "paragraph", - content: "Nested Paragraph 3", - }, - ], - }, - ]; - - beforeEach(() => { - editor.replaceBlocks(editor.document, blocks); - }); - - it("Selection within a block's children", async () => { - // Selection starts and ends within the first block's children. - editor.dispatch( - editor._tiptapEditor.state.tr.setSelection( - TextSelection.create(editor._tiptapEditor.state.doc, 18, 80) - ) - ); - - const copiedFragment = - editor._tiptapEditor.state.selection.content().content; - - await initializeESMDependencies(); - const exporter = createExternalHTMLExporter(editor.pmSchema, editor); - const externalHTML = exporter.exportProseMirrorFragment( - copiedFragment, - {} - ); - expect(externalHTML).toMatchFileSnapshot( - "./__snapshots_fragment_edge_cases__/" + - "selectionWithinBlockChildren.html" - ); - }); - - it("Selection leaves a block's children", async () => { - // Selection starts and ends within the first block's children and ends - // outside, at a shallower nesting level in the second block. - editor.dispatch( - editor._tiptapEditor.state.tr.setSelection( - TextSelection.create(editor._tiptapEditor.state.doc, 18, 97) - ) - ); - - const copiedFragment = - editor._tiptapEditor.state.selection.content().content; - - await initializeESMDependencies(); - const exporter = createExternalHTMLExporter(editor.pmSchema, editor); - const externalHTML = exporter.exportProseMirrorFragment( - copiedFragment, - {} - ); - expect(externalHTML).toMatchFileSnapshot( - "./__snapshots_fragment_edge_cases__/" + - "selectionLeavesBlockChildren.html" - ); - }); - - it("Selection spans multiple blocks' children", async () => { - // Selection starts and ends within the first block's children and ends - // within the second block's children. - editor.dispatch( - editor._tiptapEditor.state.tr.setSelection( - TextSelection.create(editor._tiptapEditor.state.doc, 18, 163) - ) - ); - - const copiedFragment = - editor._tiptapEditor.state.selection.content().content; - await initializeESMDependencies(); - const exporter = createExternalHTMLExporter(editor.pmSchema, editor); - const externalHTML = exporter.exportProseMirrorFragment( - copiedFragment, - {} - ); - expect(externalHTML).toMatchFileSnapshot( - "./__snapshots_fragment_edge_cases__/" + - "selectionSpansBlocksChildren.html" - ); - }); - }); -}); diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts index d1357774b..923319067 100644 --- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -7,7 +7,6 @@ import { serializeNodeInner, serializeProseMirrorFragment, } from "./util/sharedHTMLConversion"; - // Used to serialize BlockNote blocks and ProseMirror nodes to HTML without // losing data. Blocks are exported using the `toInternalHTML` method in their // `blockSpec`. @@ -48,7 +47,11 @@ export const createInternalHTMLSerializer = < schema: Schema, editor: BlockNoteEditor ): InternalHTMLSerializer => { - const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { + // 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 } diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json b/packages/core/src/api/parsers/html/__snapshots__/list-test.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json rename to packages/core/src/api/parsers/html/__snapshots__/list-test.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json b/packages/core/src/api/parsers/html/__snapshots__/parse-basic-block-types.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json rename to packages/core/src/api/parsers/html/__snapshots__/parse-basic-block-types.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json b/packages/core/src/api/parsers/html/__snapshots__/parse-div-with-inline-content.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json rename to packages/core/src/api/parsers/html/__snapshots__/parse-div-with-inline-content.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json b/packages/core/src/api/parsers/html/__snapshots__/parse-divs.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json rename to packages/core/src/api/parsers/html/__snapshots__/parse-divs.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json b/packages/core/src/api/parsers/html/__snapshots__/parse-fake-image-caption.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json rename to packages/core/src/api/parsers/html/__snapshots__/parse-fake-image-caption.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-image-in-paragraph.json b/packages/core/src/api/parsers/html/__snapshots__/parse-image-in-paragraph.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-image-in-paragraph.json rename to packages/core/src/api/parsers/html/__snapshots__/parse-image-in-paragraph.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json b/packages/core/src/api/parsers/html/__snapshots__/parse-mixed-nested-lists.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json rename to packages/core/src/api/parsers/html/__snapshots__/parse-mixed-nested-lists.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json b/packages/core/src/api/parsers/html/__snapshots__/parse-nested-lists-with-paragraphs.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json rename to packages/core/src/api/parsers/html/__snapshots__/parse-nested-lists-with-paragraphs.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json b/packages/core/src/api/parsers/html/__snapshots__/parse-nested-lists.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json rename to packages/core/src/api/parsers/html/__snapshots__/parse-nested-lists.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-notion-html.json b/packages/core/src/api/parsers/html/__snapshots__/parse-notion-html.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-notion-html.json rename to packages/core/src/api/parsers/html/__snapshots__/parse-notion-html.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json b/packages/core/src/api/parsers/html/__snapshots__/parse-two-divs.json similarity index 100% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json rename to packages/core/src/api/parsers/html/__snapshots__/parse-two-divs.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-google-docs-html.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-google-docs-html.json deleted file mode 100644 index c45e54ef9..000000000 --- a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-google-docs-html.json +++ /dev/null @@ -1,476 +0,0 @@ -[ - { - "id": "1", - "type": "heading", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left", - "level": 1 - }, - "content": [ - { - "type": "text", - "text": "Heading 1", - "styles": { - "bold": true - } - } - ], - "children": [] - }, - { - "id": "2", - "type": "heading", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left", - "level": 2 - }, - "content": [ - { - "type": "text", - "text": "Heading 2", - "styles": { - "bold": true - } - } - ], - "children": [] - }, - { - "id": "3", - "type": "heading", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left", - "level": 3 - }, - "content": [ - { - "type": "text", - "text": "Heading 3", - "styles": { - "bold": true - } - } - ], - "children": [] - }, - { - "id": "4", - "type": "paragraph", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Paragraph 1", - "styles": {} - } - ], - "children": [] - }, - { - "id": "5", - "type": "paragraph", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Paragraph 2", - "styles": {} - } - ], - "children": [] - }, - { - "id": "6", - "type": "paragraph", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Paragraph 3", - "styles": {} - } - ], - "children": [] - }, - { - "id": "7", - "type": "paragraph", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Paragraph With \nHard Break", - "styles": {} - } - ], - "children": [] - }, - { - "id": "8", - "type": "paragraph", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Bold", - "styles": { - "bold": true - } - }, - { - "type": "text", - "text": " ", - "styles": {} - }, - { - "type": "text", - "text": "Italic", - "styles": { - "italic": true - } - }, - { - "type": "text", - "text": " Underline ", - "styles": {} - }, - { - "type": "text", - "text": "Strikethrough", - "styles": { - "strike": true - } - }, - { - "type": "text", - "text": " ", - "styles": {} - }, - { - "type": "text", - "text": "All", - "styles": { - "bold": true, - "italic": true, - "strike": true - } - } - ], - "children": [] - }, - { - "id": "9", - "type": "bulletListItem", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Bullet List Item 1", - "styles": {} - } - ], - "children": [ - { - "id": "10", - "type": "bulletListItem", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Nested Bullet List Item 1", - "styles": {} - } - ], - "children": [ - { - "id": "11", - "type": "numberedListItem", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Nested Numbered List Item 1", - "styles": {} - } - ], - "children": [] - }, - { - "id": "12", - "type": "numberedListItem", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Nested Numbered List Item 2", - "styles": {} - } - ], - "children": [] - } - ] - }, - { - "id": "13", - "type": "bulletListItem", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Nested Bullet List Item 2", - "styles": {} - } - ], - "children": [] - } - ] - }, - { - "id": "14", - "type": "bulletListItem", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Bullet List Item 2", - "styles": {} - } - ], - "children": [] - }, - { - "id": "15", - "type": "numberedListItem", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Numbered List Item 1", - "styles": {} - } - ], - "children": [] - }, - { - "id": "16", - "type": "numberedListItem", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Numbered List Item 2", - "styles": {} - } - ], - "children": [] - }, - { - "id": "17", - "type": "paragraph", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [], - "children": [] - }, - { - "id": "18", - "type": "paragraph", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "\n", - "styles": {} - } - ], - "children": [] - }, - { - "id": "19", - "type": "table", - "props": { - "textColor": "default", - "backgroundColor": "default" - }, - "content": { - "type": "tableContent", - "rows": [ - { - "cells": [ - [ - { - "type": "text", - "text": "Cell 1", - "styles": {} - } - ], - [ - { - "type": "text", - "text": "Cell 2", - "styles": {} - } - ], - [ - { - "type": "text", - "text": "Cell 3", - "styles": {} - } - ] - ] - }, - { - "cells": [ - [ - { - "type": "text", - "text": "Cell 4", - "styles": {} - } - ], - [ - { - "type": "text", - "text": "Cell 5", - "styles": {} - } - ], - [ - { - "type": "text", - "text": "Cell 6", - "styles": {} - } - ] - ] - }, - { - "cells": [ - [ - { - "type": "text", - "text": "Cell 7", - "styles": {} - } - ], - [ - { - "type": "text", - "text": "Cell 8", - "styles": {} - } - ], - [ - { - "type": "text", - "text": "Cell 9", - "styles": {} - } - ] - ] - } - ] - }, - "children": [] - }, - { - "id": "20", - "type": "paragraph", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "Paragraph", - "styles": {} - } - ], - "children": [] - }, - { - "id": "21", - "type": "paragraph", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "\n", - "styles": {} - } - ], - "children": [] - } -] \ No newline at end of file diff --git a/packages/core/src/api/parsers/html/parseHTML.test.ts b/packages/core/src/api/parsers/html/parseHTML.test.ts index 06ff826ac..389d1b745 100644 --- a/packages/core/src/api/parsers/html/parseHTML.test.ts +++ b/packages/core/src/api/parsers/html/parseHTML.test.ts @@ -1,3 +1,4 @@ +import * as pmView from "prosemirror-view"; import { describe, expect, it } from "vitest"; import { BlockNoteEditor } from "../../.."; import { nestedListsToBlockNoteStructure } from "./util/nestedLists"; @@ -6,16 +7,12 @@ async function parseHTMLAndCompareSnapshots( html: string, snapshotName: string ) { - // use a dynamic import because we want to access - // __parseFromClipboard which is not exposed in types - const view: any = await import("prosemirror-view"); - const editor = BlockNoteEditor.create(); const div = document.createElement("div"); editor.mount(div); const blocks = await editor.tryParseHTMLToBlocks(html); - const snapshotPath = "./__snapshots__/paste/" + snapshotName + ".json"; + const snapshotPath = "./__snapshots__/" + snapshotName + ".json"; expect(JSON.stringify(blocks, undefined, 2)).toMatchFileSnapshot( snapshotPath ); @@ -34,7 +31,7 @@ async function parseHTMLAndCompareSnapshots( (window as any).__TEST_OPTIONS.mockID = 0; // reset id counter const htmlNode = nestedListsToBlockNoteStructure(html); - const slice = view.__parseFromClipboard( + const slice = (pmView as any).__parseFromClipboard( editor.prosemirrorView, "", htmlNode.innerHTML, diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/pasted/complex.json b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/complex.json new file mode 100644 index 000000000..e0d619a9e --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/complex.json @@ -0,0 +1,319 @@ +[ + { + "id": "19", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "# Heading 1", + "styles": {} + } + ], + "children": [] + }, + { + "id": "20", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "## Heading 2", + "styles": {} + } + ], + "children": [] + }, + { + "id": "21", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "### Heading 3", + "styles": {} + } + ], + "children": [] + }, + { + "id": "22", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "23", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "P**ara***grap*h", + "styles": {} + } + ], + "children": [] + }, + { + "id": "24", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "P*ara*~~grap~~h", + "styles": {} + } + ], + "children": [] + }, + { + "id": "25", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "* Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "26", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "* Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "27", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " * Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "28", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " * Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "29", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "30", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " 1. Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "31", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " 2. Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "32", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " 3. Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "33", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " 1. Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "34", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " * Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "35", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " * Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "36", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "* Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "37", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/pasted/issue-226-1.json b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/issue-226-1.json new file mode 100644 index 000000000..3c17433c1 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/issue-226-1.json @@ -0,0 +1,81 @@ +[ + { + "id": "5", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "- 📝 item1", + "styles": {} + } + ], + "children": [] + }, + { + "id": "6", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "- ⚙️ item2", + "styles": {} + } + ], + "children": [] + }, + { + "id": "7", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "- 🔗 item3", + "styles": {} + } + ], + "children": [] + }, + { + "id": "8", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "# h1", + "styles": {} + } + ], + "children": [] + }, + { + "id": "9", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/issue-226-2.json similarity index 51% rename from packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json rename to packages/core/src/api/parsers/markdown/__snapshots__/pasted/issue-226-2.json index ae11e36cb..3ee19cf3e 100644 --- a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json +++ b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/issue-226-2.json @@ -1,6 +1,6 @@ [ { - "id": "1", + "id": "9", "type": "paragraph", "props": { "textColor": "default", @@ -10,14 +10,14 @@ "content": [ { "type": "text", - "text": "Outer 1 Div Before", + "text": "* a", "styles": {} } ], "children": [] }, { - "id": "2", + "id": "10", "type": "paragraph", "props": { "textColor": "default", @@ -27,14 +27,14 @@ "content": [ { "type": "text", - "text": " Outer 2 Div Before", + "text": "* b", "styles": {} } ], "children": [] }, { - "id": "3", + "id": "11", "type": "paragraph", "props": { "textColor": "default", @@ -44,14 +44,14 @@ "content": [ { "type": "text", - "text": " Outer 3 Div Before", + "text": "* c", "styles": {} } ], "children": [] }, { - "id": "4", + "id": "12", "type": "paragraph", "props": { "textColor": "default", @@ -61,68 +61,64 @@ "content": [ { "type": "text", - "text": " Outer 4 Div Before", + "text": "* d", "styles": {} } ], "children": [] }, { - "id": "5", - "type": "heading", + "id": "13", + "type": "paragraph", "props": { "textColor": "default", "backgroundColor": "default", - "textAlignment": "left", - "level": 1 + "textAlignment": "left" }, "content": [ { "type": "text", - "text": "Heading 1", + "text": "anything", "styles": {} } ], "children": [] }, { - "id": "6", - "type": "heading", + "id": "14", + "type": "paragraph", "props": { "textColor": "default", "backgroundColor": "default", - "textAlignment": "left", - "level": 2 + "textAlignment": "left" }, "content": [ { "type": "text", - "text": "Heading 2", + "text": "[a link](", "styles": {} - } - ], - "children": [] - }, - { - "id": "7", - "type": "heading", - "props": { - "textColor": "default", - "backgroundColor": "default", - "textAlignment": "left", - "level": 3 - }, - "content": [ + }, + { + "type": "link", + "href": "http://example.com", + "content": [ + { + "type": "text", + "text": "http://example.com", + "styles": {} + } + ] + }, { "type": "text", - "text": "Heading 3", + "text": ")", "styles": {} } ], "children": [] }, { - "id": "8", + "id": "15", "type": "paragraph", "props": { "textColor": "default", @@ -132,26 +128,14 @@ "content": [ { "type": "text", - "text": "Paragraph", + "text": "* another", "styles": {} } ], "children": [] }, { - "id": "9", - "type": "image", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "url": "exampleURL", - "caption": "Image Caption", - "width": 512 - }, - "children": [] - }, - { - "id": "10", + "id": "16", "type": "paragraph", "props": { "textColor": "default", @@ -161,80 +145,21 @@ "content": [ { "type": "text", - "text": "Bold", - "styles": { - "bold": true - } - }, - { - "type": "text", - "text": " ", + "text": "* list", "styles": {} - }, - { - "type": "text", - "text": "Italic", - "styles": { - "italic": true - } - }, - { - "type": "text", - "text": " ", - "styles": {} - }, - { - "type": "text", - "text": "Underline", - "styles": { - "underline": true - } - }, - { - "type": "text", - "text": " ", - "styles": {} - }, - { - "type": "text", - "text": "Strikethrough", - "styles": { - "strike": true - } - }, - { - "type": "text", - "text": " ", - "styles": {} - }, - { - "type": "text", - "text": "All", - "styles": { - "bold": true, - "italic": true, - "underline": true, - "strike": true - } } ], "children": [] }, { - "id": "11", + "id": "17", "type": "paragraph", "props": { "textColor": "default", "backgroundColor": "default", "textAlignment": "left" }, - "content": [ - { - "type": "text", - "text": " Outer 4 Div After Outer 3 Div After Outer 2 Div After Outer 1 Div After", - "styles": {} - } - ], + "content": [], "children": [] } ] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/pasted/nested.json b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/nested.json new file mode 100644 index 000000000..84e352628 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/nested.json @@ -0,0 +1,81 @@ +[ + { + "id": "5", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "# Heading", + "styles": {} + } + ], + "children": [] + }, + { + "id": "6", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "7", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "* Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "8", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " 1. Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "9", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/pasted/non-nested.json b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/non-nested.json new file mode 100644 index 000000000..f72ab28fa --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/non-nested.json @@ -0,0 +1,81 @@ +[ + { + "id": "5", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "# Heading", + "styles": {} + } + ], + "children": [] + }, + { + "id": "6", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "7", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "* Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "8", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "1. Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "9", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/pasted/styled.json b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/styled.json new file mode 100644 index 000000000..5d3c67820 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/styled.json @@ -0,0 +1,61 @@ +[ + { + "id": "2", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bold", + "styles": { + "bold": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Italic", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Strikethrough", + "styles": { + "strike": true + } + }, + { + "type": "text", + "text": " ***Multiple***", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/parseMarkdown.test.ts b/packages/core/src/api/parsers/markdown/parseMarkdown.test.ts index b8ef953af..16e12b0dc 100644 --- a/packages/core/src/api/parsers/markdown/parseMarkdown.test.ts +++ b/packages/core/src/api/parsers/markdown/parseMarkdown.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { BlockNoteEditor } from "../../.."; +import { doPaste } from "../../testUtil/paste"; async function parseMarkdownAndCompareSnapshots( md: string, @@ -14,6 +15,20 @@ async function parseMarkdownAndCompareSnapshots( expect(JSON.stringify(blocks, undefined, 2)).toMatchFileSnapshot( snapshotPath ); + + doPaste( + editor._tiptapEditor.view, + md, + null, + true, + new ClipboardEvent("paste") + ); + + const pastedSnapshotPath = "./__snapshots__/pasted/" + snapshotName + ".json"; + expect(JSON.stringify(editor.document, undefined, 2)).toMatchFileSnapshot( + pastedSnapshotPath + ); + editor.mount(undefined); } diff --git a/packages/core/src/api/testUtil/paste.ts b/packages/core/src/api/testUtil/paste.ts new file mode 100644 index 000000000..a25874313 --- /dev/null +++ b/packages/core/src/api/testUtil/paste.ts @@ -0,0 +1,46 @@ +import { Slice } from "@tiptap/pm/model"; +import { EditorView } from "@tiptap/pm/view"; +import * as pmView from "@tiptap/pm/view"; + +function sliceSingleNode(slice: Slice) { + return slice.openStart === 0 && + slice.openEnd === 0 && + slice.content.childCount === 1 + ? slice.content.firstChild + : null; +} + +// This function is a copy of the `doPaste` function from `@tiptap/pm/view`, +// but made to work in a JSDOM environment. To do this, the `tr.scrollIntoView` +// call has been removed. +// https://github.com/ProseMirror/prosemirror-view/blob/17b508f618c944c54776f8ddac45edcb49970796/src/input.ts#L624 +export function doPaste( + view: EditorView, + text: string, + html: string | null, + preferPlain: boolean, + event: ClipboardEvent +) { + const slice = (pmView as any).__parseFromClipboard( + view, + text, + html, + preferPlain, + view.state.selection.$from + ); + if ( + view.someProp("handlePaste", (f) => f(view, event, slice || Slice.empty)) + ) { + return true; + } + if (!slice) { + return false; + } + + const singleNode = sliceSingleNode(slice); + const tr = singleNode + ? view.state.tr.replaceSelectionWith(singleNode, preferPlain) + : view.state.tr.replaceSelection(slice); + view.dispatch(tr.setMeta("paste", true).setMeta("uiEvent", "paste")); + return true; +} diff --git a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts index e47a62b2d..1e45066cf 100644 --- a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts +++ b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts @@ -46,7 +46,6 @@ const TableParagraph = Node.create({ parseHTML() { return [ - { tag: "td" }, { tag: "p", getAttrs: (element) => { diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index fca81fb5a..e03163e53 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -11,9 +11,9 @@ import { History } from "@tiptap/extension-history"; import { Link } from "@tiptap/extension-link"; import { Text } from "@tiptap/extension-text"; import * as Y from "yjs"; -import { createCopyToClipboardExtension } from "../api/exporters/copyExtension"; -import { createDropFileExtension } from "../api/parsers/fileDropExtension"; -import { createPasteFromClipboardExtension } from "../api/parsers/pasteExtension"; +import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension"; +import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDropExtension"; +import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension"; import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension"; import { TextAlignmentExtension } from "../extensions/TextAlignment/TextAlignmentExtension"; import { TextColorExtension } from "../extensions/TextColor/TextColorExtension"; diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts index cea35c8e0..334a1aab8 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -1,4 +1,4 @@ -import { Fragment, Slice } from "@tiptap/pm/model"; +import { Fragment, Schema, Slice } from "@tiptap/pm/model"; import { EditorView } from "@tiptap/pm/view"; // helper function to remove a child from a fragment @@ -12,6 +12,37 @@ function removeChild(node: Fragment, n: number) { return Fragment.from(children); } +/** + * Wrap adjacent tableRow items in a table. + * + * This makes sure the content that we paste is always a table (and not a tableRow) + * A table works better for the remaing paste handling logic, as it's actually a blockContent node + */ +export function wrapTableRows(f: Fragment, schema: Schema) { + const newItems: any[] = []; + for (let i = 0; i < f.childCount; i++) { + if (f.child(i).type.name === "tableRow") { + if ( + newItems.length > 0 && + newItems[newItems.length - 1].type.name === "table" + ) { + // append to existing table + const prevTable = newItems[newItems.length - 1]; + const newTable = prevTable.copy(prevTable.content.addToEnd(f.child(i))); + newItems[newItems.length - 1] = newTable; + } else { + // create new table to wrap tableRow with + const newTable = schema.nodes.table.create(undefined, f.child(i)); + newItems.push(newTable); + } + } else { + newItems.push(f.child(i)); + } + } + f = Fragment.from(newItems); + return f; +} + /** * fix for https://github.com/ProseMirror/prosemirror/issues/1430#issuecomment-1822570821 * @@ -23,6 +54,8 @@ function removeChild(node: Fragment, n: number) { */ export function transformPasted(slice: Slice, view: EditorView) { let f = Fragment.from(slice.content); + f = wrapTableRows(f, view.state.schema); + for (let i = 0; i < f.childCount; i++) { if (f.child(i).type.spec.group === "blockContent") { const content = [f.child(i)]; @@ -54,6 +87,5 @@ export function transformPasted(slice: Slice, view: EditorView) { f = f.replaceChild(i, container); } } - return new Slice(f, slice.openStart, slice.openEnd); } diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index 6bc5cc7cd..bc7b310b5 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -2,9 +2,9 @@ import { PluginView } from "@tiptap/pm/state"; import { Node } from "prosemirror-model"; import { NodeSelection, Plugin, PluginKey, Selection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; +import * as pmView from "prosemirror-view"; import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter"; -import { createInternalHTMLSerializer } from "../../api/exporters/html/internalHTMLSerializer"; import { cleanHTMLToMarkdown } from "../../api/exporters/markdown/markdownExporter"; import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; import { Block } from "../../blocks/defaultBlocks"; @@ -227,11 +227,10 @@ function dragStart< const selectedSlice = view.state.selection.content(); const schema = editor.pmSchema; - const internalHTMLSerializer = createInternalHTMLSerializer(schema, editor); - const internalHTML = internalHTMLSerializer.serializeProseMirrorFragment( - selectedSlice.content, - {} - ); + const clipboardHML = (pmView as any).__serializeForClipboard( + view, + selectedSlice + ).dom.innerHTML; const externalHTMLExporter = createExternalHTMLExporter(schema, editor); const externalHTML = externalHTMLExporter.exportProseMirrorFragment( @@ -242,7 +241,7 @@ function dragStart< const plainText = cleanHTMLToMarkdown(externalHTML); e.dataTransfer.clearData(); - e.dataTransfer.setData("blocknote/html", internalHTML); + e.dataTransfer.setData("blocknote/html", clipboardHML); e.dataTransfer.setData("text/html", externalHTML); e.dataTransfer.setData("text/plain", plainText); e.dataTransfer.effectAllowed = "move"; diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 1dac32dd6..b68b90c65 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -162,15 +162,24 @@ export function createBlockSpec< return getParseRules(blockConfig, blockImplementation.parse); }, - renderHTML() { - // renderHTML is not really used, as we always use a nodeView, and we use toExternalHTML / toInternalHTML for serialization - // There's an edge case when this gets called nevertheless; before the nodeviews have been mounted - // this is why we implement it with a temporary placeholder + renderHTML({ HTMLAttributes }) { + // renderHTML is used for copy/pasting content from the editor back into + // the editor, so we need to make sure the `blockContent` element is + // structured correctly as this is what's used for parsing blocks. We + // just render a placeholder div inside as the `blockContent` element + // already has all the information needed for proper parsing. const div = document.createElement("div"); div.setAttribute("data-tmp-placeholder", "true"); - return { - dom: div, - }; + return wrapInBlockStructure( + { + dom: div, + }, + blockConfig.type, + {}, + blockConfig.propSchema, + blockConfig.isFileBlock, + HTMLAttributes + ); }, addNodeView() { diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 687f93afe..1122527a0 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -17,6 +17,7 @@ import { PropSchema, propsToAttributes, StyleSchema, + wrapInBlockStructure, } from "@blocknote/core"; import { NodeView, @@ -130,15 +131,24 @@ export function createReactBlockSpec< return getParseRules(blockConfig, blockImplementation.parse); }, - renderHTML() { - // renderHTML is not really used, as we always use a nodeView, and we use toExternalHTML / toInternalHTML for serialization - // There's an edge case when this gets called nevertheless; before the nodeviews have been mounted - // this is why we implement it with a temporary placeholder + renderHTML({ HTMLAttributes }) { + // renderHTML is used for copy/pasting content from the editor back into + // the editor, so we need to make sure the `blockContent` element is + // structured correctly as this is what's used for parsing blocks. We + // just render a placeholder div inside as the `blockContent` element + // already has all the information needed for proper parsing. const div = document.createElement("div"); div.setAttribute("data-tmp-placeholder", "true"); - return { - dom: div, - }; + return wrapInBlockStructure( + { + dom: div, + }, + blockConfig.type, + {}, + blockConfig.propSchema, + blockConfig.isFileBlock, + HTMLAttributes + ); }, addNodeView() {