diff --git a/packages/core/src/api/clipboard/__snapshots__/tableAllCells.html b/packages/core/src/api/clipboard/__snapshots__/tableAllCells.html index 1de956c176..2f5ba018cf 100644 --- a/packages/core/src/api/clipboard/__snapshots__/tableAllCells.html +++ b/packages/core/src/api/clipboard/__snapshots__/tableAllCells.html @@ -1 +1 @@ -

Table Cell

Table Cell

Table Cell

Table Cell

\ No newline at end of file +

Table Cell

Table Cell

Table Cell

Table Cell

\ 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 index 58cf9e8927..df974c55ea 100644 --- a/packages/core/src/api/clipboard/clipboard.test.ts +++ b/packages/core/src/api/clipboard/clipboard.test.ts @@ -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 = { @@ -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), diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index ed3446ac3f..b27801ce2f 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -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"; @@ -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 -): 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, @@ -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 +): 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( + view, selectedFragment, - { simplifyBlocks: !isWithinBlockContent && !isWithinTable } + editor ); const markdown = cleanHTMLToMarkdown(externalHTML); diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts index 2d6a859d9d..4f2542f643 100644 --- a/packages/core/src/api/exporters/html/externalHTMLExporter.ts +++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts @@ -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"; @@ -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[], - 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 @@ -54,7 +38,7 @@ export const createExternalHTMLExporter = < >( schema: Schema, editor: BlockNoteEditor -): ExternalHTMLExporter => { +) => { const deps = esmDependencies; if (!deps) { @@ -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[], 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(["numberedListItem"]), + unorderedListItemBlockTypes: new Set([ + "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(["numberedListItem"]), - unorderedListItemBlockTypes: new Set([ - "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[], + options: { simplifyBlocks: boolean; document?: Document } + ) => { + const domFragment = serializeInlineContent( + editor, + inlineContent as any, + serializer, + true, + options + ); - serializer.exportBlocks = ( - blocks: PartialBlock[], - 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; }; diff --git a/packages/core/src/api/exporters/html/htmlConversion.test.ts b/packages/core/src/api/exporters/html/htmlConversion.test.ts index 850158902f..36291dfd3c 100644 --- a/packages/core/src/api/exporters/html/htmlConversion.test.ts +++ b/packages/core/src/api/exporters/html/htmlConversion.test.ts @@ -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"; @@ -25,6 +23,7 @@ async function convertToHTMLAndCompareSnapshots< snapshotName: string ) { addIdsToBlocks(blocks); + const serializer = createInternalHTMLSerializer(editor.pmSchema, editor); const internalHTML = serializer.serializeBlocks(blocks, {}); const internalHTMLSnapshotPath = diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts index 9233190674..7d63b42312 100644 --- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -1,12 +1,8 @@ -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 { blockToNode } from "../../nodeConversions/nodeConversions"; -import { - serializeNodeInner, - serializeProseMirrorFragment, -} from "./util/sharedHTMLConversion"; +import { serializeBlocks } 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`. @@ -16,29 +12,6 @@ import { // editor, including the `blockGroup` and `blockContainer` wrappers. This also // means that it can be converted back to the original blocks without any data // loss. -// -// The serializer has 2 main methods: -// `serializeFragment`: Serializes a ProseMirror fragment to HTML. This is -// mostly useful if you want to serialize a selection which may not start/end at -// the start/end of a block. -// `serializeBlocks`: Serializes an array of blocks to HTML. -export interface InternalHTMLSerializer< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema -> { - // TODO: Ideally we would expand the BlockNote API to support partial - // selections so we don't need this. - serializeProseMirrorFragment: ( - fragment: Fragment, - options: { document?: Document } - ) => string; - serializeBlocks: ( - blocks: PartialBlock[], - options: { document?: Document } - ) => string; -} - export const createInternalHTMLSerializer = < BSchema extends BlockSchema, I extends InlineContentSchema, @@ -46,49 +19,16 @@ export const createInternalHTMLSerializer = < >( schema: Schema, editor: BlockNoteEditor -): InternalHTMLSerializer => { - // 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; +) => { + const serializer = DOMSerializer.fromSchema(schema); + + return { serializeBlocks: ( blocks: PartialBlock[], options: { document?: Document } - ) => string; - serializeProseMirrorFragment: ( - fragment: Fragment, - options?: { document?: Document | undefined } | undefined, - target?: HTMLElement | DocumentFragment | undefined - ) => string; + ) => { + return serializeBlocks(editor, blocks, serializer, false, options) + .outerHTML; + }, }; - - serializer.serializeNodeInner = ( - node: Node, - options: { document?: Document } - ) => serializeNodeInner(node, options, serializer, editor, false); - - serializer.serializeProseMirrorFragment = (fragment: Fragment, options) => - serializeProseMirrorFragment(fragment, serializer, options); - - serializer.serializeBlocks = ( - blocks: PartialBlock[], - options - ) => { - const nodes = blocks.map((block) => - blockToNode(block, schema, editor.schema.styleSchema) - ); - const blockGroup = schema.nodes["blockGroup"].create(null, nodes); - - return serializer.serializeProseMirrorFragment( - Fragment.from(blockGroup), - options - ); - }; - - return serializer; }; diff --git a/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts index 3e0c6f9cf2..7fe568ba2d 100644 --- a/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts +++ b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts @@ -1,130 +1,159 @@ -import { DOMSerializer, Fragment, Node } from "prosemirror-model"; +import { DOMSerializer, Fragment } from "prosemirror-model"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor"; + +import { PartialBlock } from "../../../../blocks/defaultBlocks"; import { BlockSchema, InlineContentSchema, StyleSchema, } from "../../../../schema"; -import { nodeToBlock } from "../../../nodeConversions/nodeConversions"; +import { UnreachableCaseError } from "../../../../util/typescript"; +import { + inlineContentToNodes, + tableContentToNodes, +} from "../../../nodeConversions/nodeConversions"; + +export function serializeInlineContent< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + blockContent: PartialBlock["content"], + serializer: DOMSerializer, + _toExternalHTML: boolean, // TODO, externalHTML for IC + options?: { document?: Document } +) { + let nodes: any; + + // TODO: reuse function from nodeconversions? + if (!blockContent) { + throw new Error("blockContent is required"); + } else if (typeof blockContent === "string") { + nodes = inlineContentToNodes( + [blockContent], + editor.pmSchema, + editor.schema.styleSchema + ); + } else if (Array.isArray(blockContent)) { + nodes = inlineContentToNodes( + blockContent, + editor.pmSchema, + editor.schema.styleSchema + ); + } else if (blockContent.type === "tableContent") { + nodes = tableContentToNodes( + blockContent, + editor.pmSchema, + editor.schema.styleSchema + ); + } else { + throw new UnreachableCaseError(blockContent.type); + } + + // We call the prosemirror serializer here because it handles Marks and Inline Content nodes nicely. + // If we'd want to support custom serialization or externalHTML for Inline Content, we'd have to implement + // a custom serializer here. + const dom = serializer.serializeFragment(Fragment.from(nodes), options); -function doc(options: { document?: Document }) { - return options.document || window.document; + return dom; } -// Used to implement `serializeNodeInner` for the `internalHTMLSerializer` and -// `externalHTMLExporter`. Changes how the content of `blockContainer` nodes is -// serialized vs the default `DOMSerializer` implementation. For the -// `blockContent` node, the `toInternalHTML` or `toExternalHTML` function of its -// corresponding block is used for serialization instead of the node's -// `renderHTML` method. -export const serializeNodeInner = < +function serializeBlock< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema >( - node: Node, - options: { document?: Document }, - serializer: DOMSerializer, editor: BlockNoteEditor, - toExternalHTML: boolean -) => { - if (!serializer.nodes[node.type.name]) { - throw new Error("Serializer is missing a node type: " + node.type.name); + block: PartialBlock, + serializer: DOMSerializer, + toExternalHTML: boolean, + options?: { document?: Document } +) { + const BC_NODE = editor.pmSchema.nodes["blockContainer"]; + + let props = block.props; + // set default props in case we were passed a partial block + if (!block.props) { + props = {}; + for (const [name, spec] of Object.entries( + editor.schema.blockSchema[block.type as any].propSchema + )) { + (props as any)[name] = spec.default; + } } - const { dom, contentDOM } = DOMSerializer.renderSpec( - doc(options), - serializer.nodes[node.type.name](node) - ); + const bc = BC_NODE.spec?.toDOM?.( + BC_NODE.create({ + id: block.id, + ...props, + }) + ) as { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; - if (contentDOM) { - if (node.isLeaf) { - throw new RangeError("Content hole not allowed in a leaf node spec"); - } + const impl = editor.blockImplementations[block.type as any].implementation; + const ret = toExternalHTML + ? impl.toExternalHTML({ ...block, props } as any, editor as any) + : impl.toInternalHTML({ ...block, props } as any, editor as any); - // Handles converting `blockContainer` nodes to HTML. - if (node.type.name === "blockContainer") { - const blockContentNode = - node.childCount > 0 && - node.firstChild!.type.spec.group === "blockContent" - ? node.firstChild! - : undefined; - const blockGroupNode = - node.childCount > 0 && node.lastChild!.type.spec.group === "blockGroup" - ? node.lastChild! - : undefined; - - // Converts `blockContent` node using the custom `blockSpec`'s - // `toExternalHTML` or `toInternalHTML` function. - // Note: While `blockContainer` nodes should always contain a - // `blockContent` node according to the schema, PM Fragments don't always - // conform to the schema. This is unintuitive but important as it occurs - // when copying only nested blocks. - if (blockContentNode !== undefined) { - const impl = - editor.blockImplementations[blockContentNode.type.name] - .implementation; - const toHTML = toExternalHTML - ? impl.toExternalHTML - : impl.toInternalHTML; - const blockContent = toHTML( - nodeToBlock( - node, - editor.schema.blockSchema, - editor.schema.inlineContentSchema, - editor.schema.styleSchema, - editor.blockCache - ), - editor as any - ); - - // Converts inline nodes in the `blockContent` node's content to HTML - // using their `renderHTML` methods. - if (blockContent.contentDOM !== undefined) { - if (node.isLeaf) { - throw new RangeError( - "Content hole not allowed in a leaf node spec" - ); - } - - blockContent.contentDOM.appendChild( - serializer.serializeFragment(blockContentNode.content, options) - ); - } - - contentDOM.appendChild(blockContent.dom); - } - - // Converts `blockGroup` node to HTML using its `renderHTML` method. - if (blockGroupNode !== undefined) { - serializer.serializeFragment( - Fragment.from(blockGroupNode), - options, - contentDOM - ); - } - } else { - // Converts the node normally, i.e. using its `renderHTML method`. - serializer.serializeFragment(node.content, options, contentDOM); - } + if (ret.contentDOM && block.content) { + const ic = serializeInlineContent( + editor, + block.content as any, // TODO + serializer, + toExternalHTML, + options + ); + ret.contentDOM.appendChild(ic); } - return dom as HTMLElement; -}; + bc.contentDOM?.appendChild(ret.dom); + + if (block.children && block.children.length > 0) { + bc.contentDOM?.appendChild( + serializeBlocks( + editor, + block.children, + serializer, + toExternalHTML, + options + ) + ); + } + return bc.dom; +} -// Used to implement `serializeProseMirrorFragment` for the -// `internalHTMLSerializer` and `externalHTMLExporter`. Does basically the same -// thing as `serializer.serializeFragment`, but takes fewer arguments and -// returns a string instead, to make it easier to use. -export const serializeProseMirrorFragment = ( - fragment: Fragment, +export const serializeBlocks = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + blocks: PartialBlock[], serializer: DOMSerializer, + toExternalHTML: boolean, options?: { document?: Document } ) => { - const internalHTML = serializer.serializeFragment(fragment, options); - const parent = document.createElement("div"); - parent.appendChild(internalHTML); + const BG_NODE = editor.pmSchema.nodes["blockGroup"]; + + const bg = BG_NODE.spec!.toDOM!(BG_NODE.create({})) as { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + + for (const block of blocks) { + const blockDOM = serializeBlock( + editor, + block, + serializer, + toExternalHTML, + options + ); + bg.contentDOM!.appendChild(blockDOM); + } - return parent.innerHTML; + return bg.dom; }; diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts new file mode 100644 index 0000000000..5b8518e233 --- /dev/null +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -0,0 +1,60 @@ +import { Fragment } from "@tiptap/pm/model"; +import { BlockNoteSchema } from "../../editor/BlockNoteSchema"; +import { + BlockNoDefaults, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../schema"; +import { nodeToBlock } from "./nodeConversions"; + +/** + * Converts all Blocks within a fragment to BlockNote blocks. + */ +export function fragmentToBlocks< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(fragment: Fragment, schema: BlockNoteSchema) { + // first convert selection to blocknote-style blocks, and then + // pass these to the exporter + const blocks: BlockNoDefaults[] = []; + fragment.descendants((node) => { + if (node.type.name === "blockContainer") { + if (node.firstChild?.type.name === "blockGroup") { + // selection started within a block group + // in this case the fragment starts with: + // + // + // + // + // + // + // + // instead of: + // + // + // + // + // + // + // + // + // so we don't need to serialize this block, just descend into the children of the blockGroup + return true; + } + blocks.push( + nodeToBlock( + node, + schema.blockSchema, + schema.inlineContentSchema, + schema.styleSchema + ) + ); + // don't descend into children, as they're already included in the block returned by nodeToBlock + return false; + } + return true; + }); + return blocks; +} diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts index 9d76b4e84c..81fcc44006 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts @@ -190,7 +190,7 @@ export function tableContentToNodes< return rowNodes; } -function blockOrInlineContentToContentNode( +export function blockOrInlineContentToContentNode( block: | PartialBlock | PartialCustomInlineContentFromConfig, @@ -267,7 +267,7 @@ export function blockToNode( /** * Converts an internal (prosemirror) table node contentto a BlockNote Tablecontent */ -function contentNodeToTableContent< +export function contentNodeToTableContent< I extends InlineContentSchema, S extends StyleSchema >(contentNode: Node, inlineContentSchema: I, styleSchema: S) { diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index bc7b310b5f..dfe29ac8f9 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -1,12 +1,13 @@ 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 { EditorView } from "prosemirror-view"; import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter"; import { cleanHTMLToMarkdown } from "../../api/exporters/markdown/markdownExporter"; import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; +import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks"; import { Block } from "../../blocks/defaultBlocks"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; @@ -227,21 +228,20 @@ function dragStart< const selectedSlice = view.state.selection.content(); const schema = editor.pmSchema; - const clipboardHML = (pmView as any).__serializeForClipboard( + const clipboardHTML = (pmView as any).__serializeForClipboard( view, selectedSlice ).dom.innerHTML; const externalHTMLExporter = createExternalHTMLExporter(schema, editor); - const externalHTML = externalHTMLExporter.exportProseMirrorFragment( - selectedSlice.content, - {} - ); + + const blocks = fragmentToBlocks(selectedSlice.content, editor.schema); + const externalHTML = externalHTMLExporter.exportBlocks(blocks, {}); const plainText = cleanHTMLToMarkdown(externalHTML); e.dataTransfer.clearData(); - e.dataTransfer.setData("blocknote/html", clipboardHML); + e.dataTransfer.setData("blocknote/html", clipboardHTML); e.dataTransfer.setData("text/html", externalHTML); e.dataTransfer.setData("text/plain", plainText); e.dataTransfer.effectAllowed = "move"; diff --git a/packages/server-util/src/context/react/ReactServer.test.tsx b/packages/server-util/src/context/react/ReactServer.test.tsx index d6613cf911..a250d4c7a3 100644 --- a/packages/server-util/src/context/react/ReactServer.test.tsx +++ b/packages/server-util/src/context/react/ReactServer.test.tsx @@ -58,6 +58,7 @@ describe("Test ServerBlockNoteEditor with React blocks", () => { }); const html = await editor.blocksToFullHTML([ { + id: "1", type: "simpleReactCustomParagraph", content: "React Custom Paragraph", }, @@ -77,6 +78,7 @@ describe("Test ServerBlockNoteEditor with React blocks", () => { async () => editor.blocksToFullHTML([ { + id: "1", type: "reactContextParagraph", content: "React Context Paragraph", },