From b1d660ebdd97d01c1a8262ba268d84512ce04e0d Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 22 Aug 2024 22:44:16 +0200 Subject: [PATCH 1/3] make unified imports dynamic to support loading from non esm projects --- .../core/src/api/exporters/copyExtension.ts | 46 ++++++++++--------- .../exporters/html/externalHTMLExporter.ts | 18 ++++---- .../api/exporters/html/htmlConversion.test.ts | 23 +++++++--- .../exporters/markdown/markdownExporter.ts | 31 +++++++------ .../api/parsers/html/util/nestedLists.test.ts | 19 ++++---- packages/core/src/editor/BlockNoteEditor.ts | 2 +- .../src/extensions/SideMenu/SideMenuPlugin.ts | 9 ++-- .../react/src/test/htmlConversion.test.tsx | 2 +- .../src/context/ServerBlockNoteEditor.ts | 2 +- 9 files changed, 87 insertions(+), 65 deletions(-) diff --git a/packages/core/src/api/exporters/copyExtension.ts b/packages/core/src/api/exporters/copyExtension.ts index e3b29fa421..3b7be66b54 100644 --- a/packages/core/src/api/exporters/copyExtension.ts +++ b/packages/core/src/api/exporters/copyExtension.ts @@ -9,21 +9,21 @@ import { createExternalHTMLExporter } from "./html/externalHTMLExporter"; import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; import { cleanHTMLToMarkdown } from "./markdown/markdownExporter"; -function selectedFragmentToHTML< +async function selectedFragmentToHTML< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema >( view: EditorView, editor: BlockNoteEditor -): { +): Promise<{ internalHTML: string; externalHTML: string; plainText: string; -} { +}> { const selectedFragment = view.state.selection.content().content; - const internalHTMLSerializer = createInternalHTMLSerializer( + const internalHTMLSerializer = await createInternalHTMLSerializer( view.state.schema, editor ); @@ -32,7 +32,7 @@ function selectedFragmentToHTML< {} ); - const externalHTMLExporter = createExternalHTMLExporter( + const externalHTMLExporter = await createExternalHTMLExporter( view.state.schema, editor ); @@ -41,7 +41,7 @@ function selectedFragmentToHTML< {} ); - const plainText = cleanHTMLToMarkdown(externalHTML); + const plainText = await cleanHTMLToMarkdown(externalHTML); return { internalHTML, externalHTML, plainText }; } @@ -83,15 +83,16 @@ export const createCopyToClipboardExtension = < ); } - const { internalHTML, externalHTML, plainText } = - 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("text/html", externalHTML); - event.clipboardData!.setData("text/plain", plainText); + (async () => { + const { internalHTML, externalHTML, plainText } = + 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("text/html", externalHTML); + event.clipboardData!.setData("text/plain", plainText); + })(); // Prevent default PM handler to be called return true; }, @@ -125,15 +126,16 @@ export const createCopyToClipboardExtension = < event.preventDefault(); event.dataTransfer!.clearData(); - const { internalHTML, externalHTML, plainText } = - 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("text/html", externalHTML); - event.dataTransfer!.setData("text/plain", plainText); + (async () => { + const { internalHTML, externalHTML, plainText } = + 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("text/html", externalHTML); + event.dataTransfer!.setData("text/plain", plainText); + })(); // Prevent default PM handler to be called return true; }, diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts index cbeb0a83d6..7e21db1a54 100644 --- a/packages/core/src/api/exporters/html/externalHTMLExporter.ts +++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts @@ -1,7 +1,4 @@ import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; -import rehypeParse from "rehype-parse"; -import rehypeStringify from "rehype-stringify"; -import { unified } from "unified"; import { PartialBlock } from "../../../blocks/defaultBlocks"; import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; @@ -47,14 +44,18 @@ export interface ExternalHTMLExporter< ) => string; } -export const createExternalHTMLExporter = < +export const createExternalHTMLExporter = async < BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema >( schema: Schema, editor: BlockNoteEditor -): ExternalHTMLExporter => { +): Promise> => { + const rehypeParse = await import("rehype-parse"); + const rehypeStringify = await import("rehype-stringify"); + const unified = await import("unified"); + const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { serializeNodeInner: ( node: Node, @@ -79,8 +80,9 @@ 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 = unified() - .use(rehypeParse, { fragment: true }) + const externalHTML = unified + .unified() + .use(rehypeParse.default, { fragment: true }) .use(simplifyBlocks, { orderedListItemBlockTypes: new Set(["numberedListItem"]), unorderedListItemBlockTypes: new Set([ @@ -88,7 +90,7 @@ export const createExternalHTMLExporter = < "checkListItem", ]), }) - .use(rehypeStringify) + .use(rehypeStringify.default) .processSync(serializeProseMirrorFragment(fragment, serializer, options)); return externalHTML.value as string; diff --git a/packages/core/src/api/exporters/html/htmlConversion.test.ts b/packages/core/src/api/exporters/html/htmlConversion.test.ts index afb8a24bd3..a2ee38a041 100644 --- a/packages/core/src/api/exporters/html/htmlConversion.test.ts +++ b/packages/core/src/api/exporters/html/htmlConversion.test.ts @@ -45,7 +45,7 @@ async function convertToHTMLAndCompareSnapshots< expect(parsed).toStrictEqual(fullBlocks); // Create the "external" HTML, which is a cleaned up HTML representation, but lossy - const exporter = createExternalHTMLExporter(editor.pmSchema, editor); + const exporter = await createExternalHTMLExporter(editor.pmSchema, editor); const externalHTML = exporter.exportBlocks(blocks, {}); const externalHTMLSnapshotPath = "./__snapshots__/" + @@ -175,7 +175,7 @@ describe("Test ProseMirror fragment edge case conversion", () => { editor.replaceBlocks(editor.document, blocks); }); - it("Selection within a block's children", () => { + 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( @@ -186,7 +186,10 @@ describe("Test ProseMirror fragment edge case conversion", () => { const copiedFragment = editor._tiptapEditor.state.selection.content().content; - const exporter = createExternalHTMLExporter(editor.pmSchema, editor); + const exporter = await createExternalHTMLExporter( + editor.pmSchema, + editor + ); const externalHTML = exporter.exportProseMirrorFragment( copiedFragment, {} @@ -197,7 +200,7 @@ describe("Test ProseMirror fragment edge case conversion", () => { ); }); - it("Selection leaves a block's children", () => { + 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( @@ -209,7 +212,10 @@ describe("Test ProseMirror fragment edge case conversion", () => { const copiedFragment = editor._tiptapEditor.state.selection.content().content; - const exporter = createExternalHTMLExporter(editor.pmSchema, editor); + const exporter = await createExternalHTMLExporter( + editor.pmSchema, + editor + ); const externalHTML = exporter.exportProseMirrorFragment( copiedFragment, {} @@ -220,7 +226,7 @@ describe("Test ProseMirror fragment edge case conversion", () => { ); }); - it("Selection spans multiple blocks' children", () => { + 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( @@ -231,7 +237,10 @@ describe("Test ProseMirror fragment edge case conversion", () => { const copiedFragment = editor._tiptapEditor.state.selection.content().content; - const exporter = createExternalHTMLExporter(editor.pmSchema, editor); + const exporter = await createExternalHTMLExporter( + editor.pmSchema, + editor + ); const externalHTML = exporter.exportProseMirrorFragment( copiedFragment, {} diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.ts b/packages/core/src/api/exporters/markdown/markdownExporter.ts index c9ec348c9e..51c6ddab95 100644 --- a/packages/core/src/api/exporters/markdown/markdownExporter.ts +++ b/packages/core/src/api/exporters/markdown/markdownExporter.ts @@ -1,9 +1,4 @@ import { Schema } from "prosemirror-model"; -import rehypeParse from "rehype-parse"; -import rehypeRemark from "rehype-remark"; -import remarkGfm from "remark-gfm"; -import remarkStringify from "remark-stringify"; -import { unified } from "unified"; import { PartialBlock } from "../../../blocks/defaultBlocks"; import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema"; @@ -11,20 +6,28 @@ import { createExternalHTMLExporter } from "../html/externalHTMLExporter"; import { removeUnderlines } from "./removeUnderlinesRehypePlugin"; import { addSpacesToCheckboxes } from "./util/addSpacesToCheckboxesRehypePlugin"; -export function cleanHTMLToMarkdown(cleanHTMLString: string) { - const markdownString = unified() - .use(rehypeParse, { fragment: true }) +export async function cleanHTMLToMarkdown(cleanHTMLString: string) { + const rehypeParse = await import("rehype-parse"); + + const unified = await import("unified"); + const rehypeRemark = await import("rehype-remark"); + const remarkGfm = await import("remark-gfm"); + const remarkStringify = await import("remark-stringify"); + + const markdownString = unified + .unified() + .use(rehypeParse.default, { fragment: true }) .use(removeUnderlines) .use(addSpacesToCheckboxes) - .use(rehypeRemark) - .use(remarkGfm) - .use(remarkStringify, { handlers: { text: (node) => node.value } }) + .use(rehypeRemark.default) + .use(remarkGfm.default) + .use(remarkStringify.default, { handlers: { text: (node) => node.value } }) .processSync(cleanHTMLString); return markdownString.value as string; } -export function blocksToMarkdown< +export async function blocksToMarkdown< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema @@ -33,8 +36,8 @@ export function blocksToMarkdown< schema: Schema, editor: BlockNoteEditor, options: { document?: Document } -): string { - const exporter = createExternalHTMLExporter(schema, editor); +): Promise { + const exporter = await createExternalHTMLExporter(schema, editor); const externalHTML = exporter.exportBlocks(blocks, options); return cleanHTMLToMarkdown(externalHTML); diff --git a/packages/core/src/api/parsers/html/util/nestedLists.test.ts b/packages/core/src/api/parsers/html/util/nestedLists.test.ts index 96b0e1e9d2..a29b500767 100644 --- a/packages/core/src/api/parsers/html/util/nestedLists.test.ts +++ b/packages/core/src/api/parsers/html/util/nestedLists.test.ts @@ -1,17 +1,20 @@ -import rehypeFormat from "rehype-format"; -import rehypeParse from "rehype-parse"; -import rehypeStringify from "rehype-stringify"; -import { unified } from "unified"; import { describe, expect, it } from "vitest"; import { nestedListsToBlockNoteStructure } from "./nestedLists"; async function testHTML(html: string) { + const rehypeParse = await import("rehype-parse"); + const rehypeStringify = await import("rehype-stringify"); + const rehypeFormat = await import("rehype-format"); + + const unified = await import("unified"); + const htmlNode = nestedListsToBlockNoteStructure(html); - const pretty = await unified() - .use(rehypeParse, { fragment: true }) - .use(rehypeFormat) - .use(rehypeStringify) + const pretty = await unified + .unified() + .use(rehypeParse.default, { fragment: true }) + .use(rehypeFormat.default) + .use(rehypeStringify.default) .process(htmlNode.innerHTML); expect(pretty.value).toMatchSnapshot(); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 24cdaf8a55..9a4abd159e 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -1016,7 +1016,7 @@ export class BlockNoteEditor< public async blocksToHTMLLossy( blocks: PartialBlock[] = this.document ): Promise { - const exporter = createExternalHTMLExporter(this.pmSchema, this); + const exporter = await createExternalHTMLExporter(this.pmSchema, this); return exporter.exportBlocks(blocks, {}); } diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index 24c200af1a..4de3662eed 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -164,7 +164,7 @@ function unsetDragImage(rootEl: Document | ShadowRoot) { } } -function dragStart< +async function dragStart< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema @@ -232,13 +232,16 @@ function dragStart< {} ); - const externalHTMLExporter = createExternalHTMLExporter(schema, editor); + const externalHTMLExporter = await createExternalHTMLExporter( + schema, + editor + ); const externalHTML = externalHTMLExporter.exportProseMirrorFragment( selectedSlice.content, {} ); - const plainText = cleanHTMLToMarkdown(externalHTML); + const plainText = await cleanHTMLToMarkdown(externalHTML); e.dataTransfer.clearData(); e.dataTransfer.setData("blocknote/html", internalHTML); diff --git a/packages/react/src/test/htmlConversion.test.tsx b/packages/react/src/test/htmlConversion.test.tsx index 79408bb2dd..de4d626207 100644 --- a/packages/react/src/test/htmlConversion.test.tsx +++ b/packages/react/src/test/htmlConversion.test.tsx @@ -54,7 +54,7 @@ async function convertToHTMLAndCompareSnapshots< expect(parsed).toStrictEqual(fullBlocks); // Create the "external" HTML, which is a cleaned up HTML representation, but lossy - const exporter = createExternalHTMLExporter(editor.pmSchema, editor); + const exporter = await createExternalHTMLExporter(editor.pmSchema, editor); const externalHTML = exporter.exportBlocks(blocks, {}); const externalHTMLSnapshotPath = "./__snapshots__/" + diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.ts b/packages/server-util/src/context/ServerBlockNoteEditor.ts index 1308574520..ecf376629f 100644 --- a/packages/server-util/src/context/ServerBlockNoteEditor.ts +++ b/packages/server-util/src/context/ServerBlockNoteEditor.ts @@ -222,7 +222,7 @@ export class ServerBlockNoteEditor< blocks: PartialBlock[] ): Promise { return this._withJSDOM(async () => { - const exporter = createExternalHTMLExporter( + const exporter = await createExternalHTMLExporter( this.editor.pmSchema, this.editor ); From 04f874ce202720c1d5065e3e598fb71f604d4c4e Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 23 Aug 2024 17:53:53 +0200 Subject: [PATCH 2/3] fix remaining imports --- .../exporters/html/externalHTMLExporter.ts | 4 +- .../html/util/simplifyBlocksRehypePlugin.ts | 262 +++++++++--------- .../exporters/markdown/markdownExporter.ts | 2 +- .../util/addSpacesToCheckboxesRehypePlugin.ts | 62 +++-- .../src/api/parsers/markdown/parseMarkdown.ts | 26 +- 5 files changed, 181 insertions(+), 175 deletions(-) diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts index 7e21db1a54..c0c3538f1b 100644 --- a/packages/core/src/api/exporters/html/externalHTMLExporter.ts +++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts @@ -55,7 +55,7 @@ export const createExternalHTMLExporter = async < const rehypeParse = await import("rehype-parse"); const rehypeStringify = await import("rehype-stringify"); const unified = await import("unified"); - + const simplifyPlugin = await simplifyBlocks(); const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { serializeNodeInner: ( node: Node, @@ -83,7 +83,7 @@ export const createExternalHTMLExporter = async < const externalHTML = unified .unified() .use(rehypeParse.default, { fragment: true }) - .use(simplifyBlocks, { + .use(simplifyPlugin, { orderedListItemBlockTypes: new Set(["numberedListItem"]), unorderedListItemBlockTypes: new Set([ "bulletListItem", diff --git a/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts index e42e2ad447..554c39022d 100644 --- a/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts +++ b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts @@ -1,5 +1,4 @@ import { Element as HASTElement, Parent as HASTParent } from "hast"; -import { fromDom } from "hast-util-from-dom"; type SimplifyBlocksOptions = { orderedListItemBlockTypes: Set; @@ -15,147 +14,150 @@ type SimplifyBlocksOptions = { * with HTML list structure. * @param options Options for specifying which block types represent ordered and unordered list items. */ -export function simplifyBlocks(options: SimplifyBlocksOptions) { - const listItemBlockTypes = new Set([ - ...options.orderedListItemBlockTypes, - ...options.unorderedListItemBlockTypes, - ]); - - const simplifyBlocksHelper = (tree: HASTParent) => { - // Checks whether blocks in the tree are wrapped by a parent `blockGroup` - // element, in which case the `blockGroup`'s children are lifted out, and it - // is removed. - if ( - tree.children.length === 1 && - (tree.children[0] as HASTElement).properties?.["dataNodeType"] === - "blockGroup" - ) { - const blockGroup = tree.children[0] as HASTElement; - tree.children.pop(); - tree.children.push(...blockGroup.children); - } - - let numChildElements = tree.children.length; - let activeList: HASTElement | undefined; - - for (let i = 0; i < numChildElements; i++) { - const blockOuter = tree.children[i] as HASTElement; - const blockContainer = blockOuter.children[0] as HASTElement; - const blockContent = blockContainer.children.find((child) => { - const properties = (child as HASTElement).properties; - const classNames = properties?.["className"] as string[] | undefined; - - return classNames?.includes("bn-block-content"); - }) as HASTElement | undefined; - const blockGroup = blockContainer.children.find((child) => { - const properties = (child as HASTElement).properties; - const classNames = properties?.["className"] as string[] | undefined; - - return classNames?.includes("bn-block-group"); - }) as HASTElement | undefined; - - // 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. This is because ProseMirror preserves the - // nesting hierarchy of the nested nodes, even if their ancestors aren't - // fully selected. In this case, we just lift the child `blockContainer` - // nodes up. - // NOTE: This only happens for the first `blockContainer`, since to get to - // any nested blocks later in the document, the selection must also - // include their parents. - if (!blockContent) { - tree.children.splice(i, 1, ...blockGroup!.children); - simplifyBlocksHelper(tree); - - return; +export async function simplifyBlocks() { + const fromDom = await import("hast-util-from-dom"); + return (options: SimplifyBlocksOptions) => { + const listItemBlockTypes = new Set([ + ...options.orderedListItemBlockTypes, + ...options.unorderedListItemBlockTypes, + ]); + + const simplifyBlocksHelper = (tree: HASTParent) => { + // Checks whether blocks in the tree are wrapped by a parent `blockGroup` + // element, in which case the `blockGroup`'s children are lifted out, and it + // is removed. + if ( + tree.children.length === 1 && + (tree.children[0] as HASTElement).properties?.["dataNodeType"] === + "blockGroup" + ) { + const blockGroup = tree.children[0] as HASTElement; + tree.children.pop(); + tree.children.push(...blockGroup.children); } - const isListItemBlock = listItemBlockTypes.has( - blockContent.properties!["dataContentType"] as string - ); - - const listItemBlockType = isListItemBlock - ? options.orderedListItemBlockTypes.has( - blockContent.properties!["dataContentType"] as string - ) - ? "ol" - : "ul" - : null; - - // Plugin runs recursively to process nested blocks. - if (blockGroup) { - simplifyBlocksHelper(blockGroup); - } + let numChildElements = tree.children.length; + let activeList: HASTElement | undefined; + + for (let i = 0; i < numChildElements; i++) { + const blockOuter = tree.children[i] as HASTElement; + const blockContainer = blockOuter.children[0] as HASTElement; + const blockContent = blockContainer.children.find((child) => { + const properties = (child as HASTElement).properties; + const classNames = properties?.["className"] as string[] | undefined; + + return classNames?.includes("bn-block-content"); + }) as HASTElement | undefined; + const blockGroup = blockContainer.children.find((child) => { + const properties = (child as HASTElement).properties; + const classNames = properties?.["className"] as string[] | undefined; + + return classNames?.includes("bn-block-group"); + }) as HASTElement | undefined; + + // 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. This is because ProseMirror preserves the + // nesting hierarchy of the nested nodes, even if their ancestors aren't + // fully selected. In this case, we just lift the child `blockContainer` + // nodes up. + // NOTE: This only happens for the first `blockContainer`, since to get to + // any nested blocks later in the document, the selection must also + // include their parents. + if (!blockContent) { + tree.children.splice(i, 1, ...blockGroup!.children); + simplifyBlocksHelper(tree); + + return; + } - // Checks that there is an active list, but the block can't be added to it as it's of a different type. - if (activeList && activeList.tagName !== listItemBlockType) { - // Blocks that were copied into the list are removed and the list is inserted in their place. - tree.children.splice( - i - activeList.children.length, - activeList.children.length, - activeList + const isListItemBlock = listItemBlockTypes.has( + blockContent.properties!["dataContentType"] as string ); - // Updates the current index and number of child elements. - const numElementsRemoved = activeList.children.length - 1; - i -= numElementsRemoved; - numChildElements -= numElementsRemoved; + const listItemBlockType = isListItemBlock + ? options.orderedListItemBlockTypes.has( + blockContent.properties!["dataContentType"] as string + ) + ? "ol" + : "ul" + : null; - activeList = undefined; - } + // Plugin runs recursively to process nested blocks. + if (blockGroup) { + simplifyBlocksHelper(blockGroup); + } - // Checks if the block represents a list item. - if (isListItemBlock) { - // Checks if a list isn't already active. We don't have to check if the block and the list are of the same - // type as this was already done earlier. - if (!activeList) { - // Creates a new list element to represent an active list. - activeList = fromDom( - document.createElement(listItemBlockType!) - ) as HASTElement; + // Checks that there is an active list, but the block can't be added to it as it's of a different type. + if (activeList && activeList.tagName !== listItemBlockType) { + // Blocks that were copied into the list are removed and the list is inserted in their place. + tree.children.splice( + i - activeList.children.length, + activeList.children.length, + activeList + ); + + // Updates the current index and number of child elements. + const numElementsRemoved = activeList.children.length - 1; + i -= numElementsRemoved; + numChildElements -= numElementsRemoved; + + activeList = undefined; } - // Creates a new list item element to represent the block. - const listItemElement = fromDom( - document.createElement("li") - ) as HASTElement; + // Checks if the block represents a list item. + if (isListItemBlock) { + // Checks if a list isn't already active. We don't have to check if the block and the list are of the same + // type as this was already done earlier. + if (!activeList) { + // Creates a new list element to represent an active list. + activeList = fromDom.fromDom( + document.createElement(listItemBlockType!) + ) as HASTElement; + } + + // Creates a new list item element to represent the block. + const listItemElement = fromDom.fromDom( + document.createElement("li") + ) as HASTElement; - // Adds only the content inside the block to the active list. - listItemElement.children.push(...blockContent.children); - // Nested blocks have already been processed in the recursive function call, so the resulting elements are - // also added to the active list. - if (blockGroup) { - listItemElement.children.push(...blockGroup.children); + // Adds only the content inside the block to the active list. + listItemElement.children.push(...blockContent.children); + // Nested blocks have already been processed in the recursive function call, so the resulting elements are + // also added to the active list. + if (blockGroup) { + listItemElement.children.push(...blockGroup.children); + } + + // Adds the list item representing the block to the active list. + activeList.children.push(listItemElement); + } else if (blockGroup) { + // Lifts all children out of the current block, as only list items should allow nesting. + tree.children.splice(i + 1, 0, ...blockGroup.children); + // Replaces the block with only the content inside it. + tree.children[i] = blockContent.children[0]; + + // Updates the current index and number of child elements. + const numElementsAdded = blockGroup.children.length; + i += numElementsAdded; + numChildElements += numElementsAdded; + } else { + // Replaces the block with only the content inside it. + tree.children[i] = blockContent.children[0]; } + } - // Adds the list item representing the block to the active list. - activeList.children.push(listItemElement); - } else if (blockGroup) { - // Lifts all children out of the current block, as only list items should allow nesting. - tree.children.splice(i + 1, 0, ...blockGroup.children); - // Replaces the block with only the content inside it. - tree.children[i] = blockContent.children[0]; - - // Updates the current index and number of child elements. - const numElementsAdded = blockGroup.children.length; - i += numElementsAdded; - numChildElements += numElementsAdded; - } else { - // Replaces the block with only the content inside it. - tree.children[i] = blockContent.children[0]; + // Since the active list is only inserted after encountering a block which can't be added to it, there are cases + // where it remains un-inserted after processing all blocks, which are handled here. + if (activeList) { + tree.children.splice( + numChildElements - activeList.children.length, + activeList.children.length, + activeList + ); } - } - - // Since the active list is only inserted after encountering a block which can't be added to it, there are cases - // where it remains un-inserted after processing all blocks, which are handled here. - if (activeList) { - tree.children.splice( - numChildElements - activeList.children.length, - activeList.children.length, - activeList - ); - } - }; + }; - return simplifyBlocksHelper; + return simplifyBlocksHelper; + }; } diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.ts b/packages/core/src/api/exporters/markdown/markdownExporter.ts index 51c6ddab95..67459f8e68 100644 --- a/packages/core/src/api/exporters/markdown/markdownExporter.ts +++ b/packages/core/src/api/exporters/markdown/markdownExporter.ts @@ -18,7 +18,7 @@ export async function cleanHTMLToMarkdown(cleanHTMLString: string) { .unified() .use(rehypeParse.default, { fragment: true }) .use(removeUnderlines) - .use(addSpacesToCheckboxes) + .use(await addSpacesToCheckboxes()) .use(rehypeRemark.default) .use(remarkGfm.default) .use(remarkStringify.default, { handlers: { text: (node) => node.value } }) diff --git a/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts b/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts index bdfe2f6704..6ff34ad3cd 100644 --- a/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts +++ b/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts @@ -1,42 +1,44 @@ import { Element as HASTElement, Parent as HASTParent } from "hast"; -import { fromDom } from "hast-util-from-dom"; /** * Rehype plugin which adds a space after each checkbox input element. This is * because remark doesn't add any spaces between the checkbox input and the text * itself, but these are needed for correct Markdown syntax. */ -export function addSpacesToCheckboxes() { - const helper = (tree: HASTParent) => { - if (tree.children && "length" in tree.children && tree.children.length) { - for (let i = tree.children.length - 1; i >= 0; i--) { - const child = tree.children[i]; - const nextChild = - i + 1 < tree.children.length ? tree.children[i + 1] : undefined; +export async function addSpacesToCheckboxes() { + const fromDom = await import("hast-util-from-dom"); + return () => { + const helper = (tree: HASTParent) => { + if (tree.children && "length" in tree.children && tree.children.length) { + for (let i = tree.children.length - 1; i >= 0; i--) { + const child = tree.children[i]; + const nextChild = + i + 1 < tree.children.length ? tree.children[i + 1] : undefined; - // Checks for paragraph element after checkbox input element. - if ( - child.type === "element" && - child.tagName === "input" && - child.properties?.type === "checkbox" && - nextChild?.type === "element" && - nextChild.tagName === "p" - ) { - // Converts paragraph to span, otherwise remark will think it needs to - // be on a new line. - nextChild.tagName = "span"; - // Adds a space after the checkbox input element. - nextChild.children.splice( - 0, - 0, - fromDom(document.createTextNode(" ")) as HASTElement - ); - } else { - helper(child as HASTParent); + // Checks for paragraph element after checkbox input element. + if ( + child.type === "element" && + child.tagName === "input" && + child.properties?.type === "checkbox" && + nextChild?.type === "element" && + nextChild.tagName === "p" + ) { + // Converts paragraph to span, otherwise remark will think it needs to + // be on a new line. + nextChild.tagName = "span"; + // Adds a space after the checkbox input element. + nextChild.children.splice( + 0, + 0, + fromDom.fromDom(document.createTextNode(" ")) as HASTElement + ); + } else { + helper(child as HASTParent); + } } } - } - }; + }; - return helper; + return helper; + }; } diff --git a/packages/core/src/api/parsers/markdown/parseMarkdown.ts b/packages/core/src/api/parsers/markdown/parseMarkdown.ts index c3418fc5e3..3c45ea74ce 100644 --- a/packages/core/src/api/parsers/markdown/parseMarkdown.ts +++ b/packages/core/src/api/parsers/markdown/parseMarkdown.ts @@ -1,9 +1,5 @@ import { Schema } from "prosemirror-model"; -import rehypeStringify from "rehype-stringify"; -import remarkGfm from "remark-gfm"; -import remarkParse from "remark-parse"; -import remarkRehype, { defaultHandlers } from "remark-rehype"; -import { unified } from "unified"; + import { Block } from "../../../blocks/defaultBlocks"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema"; import { HTMLToBlocks } from "../html/parseHTML"; @@ -47,7 +43,7 @@ function code(state: any, node: any) { return result; } -export function markdownToBlocks< +export async function markdownToBlocks< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema @@ -58,16 +54,22 @@ export function markdownToBlocks< styleSchema: S, pmSchema: Schema ): Promise[]> { - const htmlString = unified() - .use(remarkParse) - .use(remarkGfm) - .use(remarkRehype, { + const remarkParse = await import("remark-parse"); + const remarkGfm = await import("remark-gfm"); + const remarkRehype = await import("remark-rehype"); + const rehypeStringify = await import("rehype-stringify"); + const unified = await import("unified"); + const htmlString = unified + .unified() + .use(remarkParse.default) + .use(remarkGfm.default) + .use(remarkRehype.default, { handlers: { - ...(defaultHandlers as any), + ...(remarkRehype.defaultHandlers as any), code, }, }) - .use(rehypeStringify) + .use(rehypeStringify.default) .processSync(markdown); return HTMLToBlocks( From b31d9c18fe6663a6573d4c941156fbbab8b72a7d Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 23 Aug 2024 18:56:28 +0200 Subject: [PATCH 3/3] initializeESMDependencies --- .../core/src/api/exporters/copyExtension.ts | 4 +- .../exporters/html/externalHTMLExporter.ts | 27 +- .../api/exporters/html/htmlConversion.test.ts | 22 +- .../html/util/simplifyBlocksRehypePlugin.ts | 270 +++++++++--------- .../exporters/markdown/markdownExporter.ts | 36 ++- .../util/addSpacesToCheckboxesRehypePlugin.ts | 72 ++--- .../api/parsers/html/util/nestedLists.test.ts | 15 +- .../src/api/parsers/markdown/parseMarkdown.ts | 19 +- packages/core/src/editor/BlockNoteEditor.ts | 4 +- .../src/extensions/SideMenu/SideMenuPlugin.ts | 11 +- packages/core/src/index.ts | 13 +- packages/core/src/util/esmDependencies.ts | 51 ++++ .../react/src/test/htmlConversion.test.tsx | 5 +- .../src/context/ServerBlockNoteEditor.ts | 5 +- 14 files changed, 318 insertions(+), 236 deletions(-) create mode 100644 packages/core/src/util/esmDependencies.ts diff --git a/packages/core/src/api/exporters/copyExtension.ts b/packages/core/src/api/exporters/copyExtension.ts index 3b7be66b54..9aa2ac6779 100644 --- a/packages/core/src/api/exporters/copyExtension.ts +++ b/packages/core/src/api/exporters/copyExtension.ts @@ -5,6 +5,7 @@ import { NodeSelection, Plugin } from "prosemirror-state"; 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"; @@ -32,7 +33,8 @@ async function selectedFragmentToHTML< {} ); - const externalHTMLExporter = await createExternalHTMLExporter( + await initializeESMDependencies(); + const externalHTMLExporter = createExternalHTMLExporter( view.state.schema, editor ); diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts index c0c3538f1b..beb81487c7 100644 --- a/packages/core/src/api/exporters/html/externalHTMLExporter.ts +++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts @@ -3,6 +3,7 @@ import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; import { PartialBlock } from "../../../blocks/defaultBlocks"; import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema"; +import { esmDependencies } from "../../../util/esmDependencies"; import { blockToNode } from "../../nodeConversions/nodeConversions"; import { serializeNodeInner, @@ -44,18 +45,24 @@ export interface ExternalHTMLExporter< ) => string; } -export const createExternalHTMLExporter = async < +// Needs to be sync because it's used in drag handler event (SideMenuPlugin) +// Ideally, call `await initializeESMDependencies()` before calling this function +export const createExternalHTMLExporter = < BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema >( schema: Schema, editor: BlockNoteEditor -): Promise> => { - const rehypeParse = await import("rehype-parse"); - const rehypeStringify = await import("rehype-stringify"); - const unified = await import("unified"); - const simplifyPlugin = await simplifyBlocks(); +): ExternalHTMLExporter => { + const deps = esmDependencies; + + if (!deps) { + throw new Error( + "External HTML exporter requires ESM dependencies to be initialized" + ); + } + const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { serializeNodeInner: ( node: Node, @@ -80,17 +87,17 @@ export const createExternalHTMLExporter = async < // but additionally runs it through the `simplifyBlocks` rehype plugin to // convert the internal HTML to external. serializer.exportProseMirrorFragment = (fragment, options) => { - const externalHTML = unified + const externalHTML = deps.unified .unified() - .use(rehypeParse.default, { fragment: true }) - .use(simplifyPlugin, { + .use(deps.rehypeParse.default, { fragment: true }) + .use(simplifyBlocks, { orderedListItemBlockTypes: new Set(["numberedListItem"]), unorderedListItemBlockTypes: new Set([ "bulletListItem", "checkListItem", ]), }) - .use(rehypeStringify.default) + .use(deps.rehypeStringify.default) .processSync(serializeProseMirrorFragment(fragment, serializer, options)); return externalHTML.value as string; diff --git a/packages/core/src/api/exporters/html/htmlConversion.test.ts b/packages/core/src/api/exporters/html/htmlConversion.test.ts index a2ee38a041..a08367f004 100644 --- a/packages/core/src/api/exporters/html/htmlConversion.test.ts +++ b/packages/core/src/api/exporters/html/htmlConversion.test.ts @@ -7,6 +7,7 @@ import { PartialBlock } from "../../../blocks/defaultBlocks"; import { BlockSchema } from "../../../schema/blocks/types"; import { InlineContentSchema } from "../../../schema/inlineContent/types"; import { StyleSchema } from "../../../schema/styles/types"; +import { initializeESMDependencies } from "../../../util/esmDependencies"; import { customBlocksTestCases } from "../../testUtil/cases/customBlocks"; import { customInlineContentTestCases } from "../../testUtil/cases/customInlineContent"; import { customStylesTestCases } from "../../testUtil/cases/customStyles"; @@ -44,8 +45,9 @@ async function convertToHTMLAndCompareSnapshots< expect(parsed).toStrictEqual(fullBlocks); + await initializeESMDependencies(); // Create the "external" HTML, which is a cleaned up HTML representation, but lossy - const exporter = await createExternalHTMLExporter(editor.pmSchema, editor); + const exporter = createExternalHTMLExporter(editor.pmSchema, editor); const externalHTML = exporter.exportBlocks(blocks, {}); const externalHTMLSnapshotPath = "./__snapshots__/" + @@ -186,10 +188,8 @@ describe("Test ProseMirror fragment edge case conversion", () => { const copiedFragment = editor._tiptapEditor.state.selection.content().content; - const exporter = await createExternalHTMLExporter( - editor.pmSchema, - editor - ); + await initializeESMDependencies(); + const exporter = createExternalHTMLExporter(editor.pmSchema, editor); const externalHTML = exporter.exportProseMirrorFragment( copiedFragment, {} @@ -212,10 +212,8 @@ describe("Test ProseMirror fragment edge case conversion", () => { const copiedFragment = editor._tiptapEditor.state.selection.content().content; - const exporter = await createExternalHTMLExporter( - editor.pmSchema, - editor - ); + await initializeESMDependencies(); + const exporter = createExternalHTMLExporter(editor.pmSchema, editor); const externalHTML = exporter.exportProseMirrorFragment( copiedFragment, {} @@ -237,10 +235,8 @@ describe("Test ProseMirror fragment edge case conversion", () => { const copiedFragment = editor._tiptapEditor.state.selection.content().content; - const exporter = await createExternalHTMLExporter( - editor.pmSchema, - editor - ); + await initializeESMDependencies(); + const exporter = createExternalHTMLExporter(editor.pmSchema, editor); const externalHTML = exporter.exportProseMirrorFragment( copiedFragment, {} diff --git a/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts index 554c39022d..70d2869a12 100644 --- a/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts +++ b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts @@ -1,4 +1,5 @@ import { Element as HASTElement, Parent as HASTParent } from "hast"; +import { esmDependencies } from "../../../../util/esmDependencies"; type SimplifyBlocksOptions = { orderedListItemBlockTypes: Set; @@ -14,150 +15,155 @@ type SimplifyBlocksOptions = { * with HTML list structure. * @param options Options for specifying which block types represent ordered and unordered list items. */ -export async function simplifyBlocks() { - const fromDom = await import("hast-util-from-dom"); - return (options: SimplifyBlocksOptions) => { - const listItemBlockTypes = new Set([ - ...options.orderedListItemBlockTypes, - ...options.unorderedListItemBlockTypes, - ]); - - const simplifyBlocksHelper = (tree: HASTParent) => { - // Checks whether blocks in the tree are wrapped by a parent `blockGroup` - // element, in which case the `blockGroup`'s children are lifted out, and it - // is removed. - if ( - tree.children.length === 1 && - (tree.children[0] as HASTElement).properties?.["dataNodeType"] === - "blockGroup" - ) { - const blockGroup = tree.children[0] as HASTElement; - tree.children.pop(); - tree.children.push(...blockGroup.children); +export function simplifyBlocks(options: SimplifyBlocksOptions) { + const deps = esmDependencies; + + if (!deps) { + throw new Error( + "simplifyBlocks requires ESM dependencies to be initialized" + ); + } + + const listItemBlockTypes = new Set([ + ...options.orderedListItemBlockTypes, + ...options.unorderedListItemBlockTypes, + ]); + + const simplifyBlocksHelper = (tree: HASTParent) => { + // Checks whether blocks in the tree are wrapped by a parent `blockGroup` + // element, in which case the `blockGroup`'s children are lifted out, and it + // is removed. + if ( + tree.children.length === 1 && + (tree.children[0] as HASTElement).properties?.["dataNodeType"] === + "blockGroup" + ) { + const blockGroup = tree.children[0] as HASTElement; + tree.children.pop(); + tree.children.push(...blockGroup.children); + } + + let numChildElements = tree.children.length; + let activeList: HASTElement | undefined; + + for (let i = 0; i < numChildElements; i++) { + const blockOuter = tree.children[i] as HASTElement; + const blockContainer = blockOuter.children[0] as HASTElement; + const blockContent = blockContainer.children.find((child) => { + const properties = (child as HASTElement).properties; + const classNames = properties?.["className"] as string[] | undefined; + + return classNames?.includes("bn-block-content"); + }) as HASTElement | undefined; + const blockGroup = blockContainer.children.find((child) => { + const properties = (child as HASTElement).properties; + const classNames = properties?.["className"] as string[] | undefined; + + return classNames?.includes("bn-block-group"); + }) as HASTElement | undefined; + + // 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. This is because ProseMirror preserves the + // nesting hierarchy of the nested nodes, even if their ancestors aren't + // fully selected. In this case, we just lift the child `blockContainer` + // nodes up. + // NOTE: This only happens for the first `blockContainer`, since to get to + // any nested blocks later in the document, the selection must also + // include their parents. + if (!blockContent) { + tree.children.splice(i, 1, ...blockGroup!.children); + simplifyBlocksHelper(tree); + + return; } - let numChildElements = tree.children.length; - let activeList: HASTElement | undefined; - - for (let i = 0; i < numChildElements; i++) { - const blockOuter = tree.children[i] as HASTElement; - const blockContainer = blockOuter.children[0] as HASTElement; - const blockContent = blockContainer.children.find((child) => { - const properties = (child as HASTElement).properties; - const classNames = properties?.["className"] as string[] | undefined; - - return classNames?.includes("bn-block-content"); - }) as HASTElement | undefined; - const blockGroup = blockContainer.children.find((child) => { - const properties = (child as HASTElement).properties; - const classNames = properties?.["className"] as string[] | undefined; - - return classNames?.includes("bn-block-group"); - }) as HASTElement | undefined; - - // 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. This is because ProseMirror preserves the - // nesting hierarchy of the nested nodes, even if their ancestors aren't - // fully selected. In this case, we just lift the child `blockContainer` - // nodes up. - // NOTE: This only happens for the first `blockContainer`, since to get to - // any nested blocks later in the document, the selection must also - // include their parents. - if (!blockContent) { - tree.children.splice(i, 1, ...blockGroup!.children); - simplifyBlocksHelper(tree); - - return; - } + const isListItemBlock = listItemBlockTypes.has( + blockContent.properties!["dataContentType"] as string + ); + + const listItemBlockType = isListItemBlock + ? options.orderedListItemBlockTypes.has( + blockContent.properties!["dataContentType"] as string + ) + ? "ol" + : "ul" + : null; + + // Plugin runs recursively to process nested blocks. + if (blockGroup) { + simplifyBlocksHelper(blockGroup); + } - const isListItemBlock = listItemBlockTypes.has( - blockContent.properties!["dataContentType"] as string + // Checks that there is an active list, but the block can't be added to it as it's of a different type. + if (activeList && activeList.tagName !== listItemBlockType) { + // Blocks that were copied into the list are removed and the list is inserted in their place. + tree.children.splice( + i - activeList.children.length, + activeList.children.length, + activeList ); - const listItemBlockType = isListItemBlock - ? options.orderedListItemBlockTypes.has( - blockContent.properties!["dataContentType"] as string - ) - ? "ol" - : "ul" - : null; + // Updates the current index and number of child elements. + const numElementsRemoved = activeList.children.length - 1; + i -= numElementsRemoved; + numChildElements -= numElementsRemoved; - // Plugin runs recursively to process nested blocks. - if (blockGroup) { - simplifyBlocksHelper(blockGroup); - } + activeList = undefined; + } - // Checks that there is an active list, but the block can't be added to it as it's of a different type. - if (activeList && activeList.tagName !== listItemBlockType) { - // Blocks that were copied into the list are removed and the list is inserted in their place. - tree.children.splice( - i - activeList.children.length, - activeList.children.length, - activeList - ); - - // Updates the current index and number of child elements. - const numElementsRemoved = activeList.children.length - 1; - i -= numElementsRemoved; - numChildElements -= numElementsRemoved; - - activeList = undefined; + // Checks if the block represents a list item. + if (isListItemBlock) { + // Checks if a list isn't already active. We don't have to check if the block and the list are of the same + // type as this was already done earlier. + if (!activeList) { + // Creates a new list element to represent an active list. + activeList = deps.hastUtilFromDom.fromDom( + document.createElement(listItemBlockType!) + ) as HASTElement; } - // Checks if the block represents a list item. - if (isListItemBlock) { - // Checks if a list isn't already active. We don't have to check if the block and the list are of the same - // type as this was already done earlier. - if (!activeList) { - // Creates a new list element to represent an active list. - activeList = fromDom.fromDom( - document.createElement(listItemBlockType!) - ) as HASTElement; - } - - // Creates a new list item element to represent the block. - const listItemElement = fromDom.fromDom( - document.createElement("li") - ) as HASTElement; + // Creates a new list item element to represent the block. + const listItemElement = deps.hastUtilFromDom.fromDom( + document.createElement("li") + ) as HASTElement; - // Adds only the content inside the block to the active list. - listItemElement.children.push(...blockContent.children); - // Nested blocks have already been processed in the recursive function call, so the resulting elements are - // also added to the active list. - if (blockGroup) { - listItemElement.children.push(...blockGroup.children); - } - - // Adds the list item representing the block to the active list. - activeList.children.push(listItemElement); - } else if (blockGroup) { - // Lifts all children out of the current block, as only list items should allow nesting. - tree.children.splice(i + 1, 0, ...blockGroup.children); - // Replaces the block with only the content inside it. - tree.children[i] = blockContent.children[0]; - - // Updates the current index and number of child elements. - const numElementsAdded = blockGroup.children.length; - i += numElementsAdded; - numChildElements += numElementsAdded; - } else { - // Replaces the block with only the content inside it. - tree.children[i] = blockContent.children[0]; + // Adds only the content inside the block to the active list. + listItemElement.children.push(...blockContent.children); + // Nested blocks have already been processed in the recursive function call, so the resulting elements are + // also added to the active list. + if (blockGroup) { + listItemElement.children.push(...blockGroup.children); } - } - // Since the active list is only inserted after encountering a block which can't be added to it, there are cases - // where it remains un-inserted after processing all blocks, which are handled here. - if (activeList) { - tree.children.splice( - numChildElements - activeList.children.length, - activeList.children.length, - activeList - ); + // Adds the list item representing the block to the active list. + activeList.children.push(listItemElement); + } else if (blockGroup) { + // Lifts all children out of the current block, as only list items should allow nesting. + tree.children.splice(i + 1, 0, ...blockGroup.children); + // Replaces the block with only the content inside it. + tree.children[i] = blockContent.children[0]; + + // Updates the current index and number of child elements. + const numElementsAdded = blockGroup.children.length; + i += numElementsAdded; + numChildElements += numElementsAdded; + } else { + // Replaces the block with only the content inside it. + tree.children[i] = blockContent.children[0]; } - }; - - return simplifyBlocksHelper; + } + + // Since the active list is only inserted after encountering a block which can't be added to it, there are cases + // where it remains un-inserted after processing all blocks, which are handled here. + if (activeList) { + tree.children.splice( + numChildElements - activeList.children.length, + activeList.children.length, + activeList + ); + } }; + + return simplifyBlocksHelper; } diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.ts b/packages/core/src/api/exporters/markdown/markdownExporter.ts index 67459f8e68..655ac165a3 100644 --- a/packages/core/src/api/exporters/markdown/markdownExporter.ts +++ b/packages/core/src/api/exporters/markdown/markdownExporter.ts @@ -2,26 +2,35 @@ import { Schema } from "prosemirror-model"; import { PartialBlock } from "../../../blocks/defaultBlocks"; import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema"; +import { + esmDependencies, + initializeESMDependencies, +} from "../../../util/esmDependencies"; import { createExternalHTMLExporter } from "../html/externalHTMLExporter"; import { removeUnderlines } from "./removeUnderlinesRehypePlugin"; import { addSpacesToCheckboxes } from "./util/addSpacesToCheckboxesRehypePlugin"; -export async function cleanHTMLToMarkdown(cleanHTMLString: string) { - const rehypeParse = await import("rehype-parse"); +// Needs to be sync because it's used in drag handler event (SideMenuPlugin) +// Ideally, call `await initializeESMDependencies()` before calling this function +export function cleanHTMLToMarkdown(cleanHTMLString: string) { + const deps = esmDependencies; - const unified = await import("unified"); - const rehypeRemark = await import("rehype-remark"); - const remarkGfm = await import("remark-gfm"); - const remarkStringify = await import("remark-stringify"); + if (!deps) { + throw new Error( + "cleanHTMLToMarkdown requires ESM dependencies to be initialized" + ); + } - const markdownString = unified + const markdownString = deps.unified .unified() - .use(rehypeParse.default, { fragment: true }) + .use(deps.rehypeParse.default, { fragment: true }) .use(removeUnderlines) - .use(await addSpacesToCheckboxes()) - .use(rehypeRemark.default) - .use(remarkGfm.default) - .use(remarkStringify.default, { handlers: { text: (node) => node.value } }) + .use(addSpacesToCheckboxes) + .use(deps.rehypeRemark.default) + .use(deps.remarkGfm.default) + .use(deps.remarkStringify.default, { + handlers: { text: (node) => node.value }, + }) .processSync(cleanHTMLString); return markdownString.value as string; @@ -37,7 +46,8 @@ export async function blocksToMarkdown< editor: BlockNoteEditor, options: { document?: Document } ): Promise { - const exporter = await createExternalHTMLExporter(schema, editor); + await initializeESMDependencies(); + const exporter = createExternalHTMLExporter(schema, editor); const externalHTML = exporter.exportBlocks(blocks, options); return cleanHTMLToMarkdown(externalHTML); diff --git a/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts b/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts index 6ff34ad3cd..1c9f5df2ab 100644 --- a/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts +++ b/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts @@ -1,44 +1,52 @@ import { Element as HASTElement, Parent as HASTParent } from "hast"; +import { esmDependencies } from "../../../../util/esmDependencies"; /** * Rehype plugin which adds a space after each checkbox input element. This is * because remark doesn't add any spaces between the checkbox input and the text * itself, but these are needed for correct Markdown syntax. */ -export async function addSpacesToCheckboxes() { - const fromDom = await import("hast-util-from-dom"); - return () => { - const helper = (tree: HASTParent) => { - if (tree.children && "length" in tree.children && tree.children.length) { - for (let i = tree.children.length - 1; i >= 0; i--) { - const child = tree.children[i]; - const nextChild = - i + 1 < tree.children.length ? tree.children[i + 1] : undefined; +export function addSpacesToCheckboxes() { + const deps = esmDependencies; - // Checks for paragraph element after checkbox input element. - if ( - child.type === "element" && - child.tagName === "input" && - child.properties?.type === "checkbox" && - nextChild?.type === "element" && - nextChild.tagName === "p" - ) { - // Converts paragraph to span, otherwise remark will think it needs to - // be on a new line. - nextChild.tagName = "span"; - // Adds a space after the checkbox input element. - nextChild.children.splice( - 0, - 0, - fromDom.fromDom(document.createTextNode(" ")) as HASTElement - ); - } else { - helper(child as HASTParent); - } + if (!deps) { + throw new Error( + "simplifyBlocks requires ESM dependencies to be initialized" + ); + } + + const helper = (tree: HASTParent) => { + if (tree.children && "length" in tree.children && tree.children.length) { + for (let i = tree.children.length - 1; i >= 0; i--) { + const child = tree.children[i]; + const nextChild = + i + 1 < tree.children.length ? tree.children[i + 1] : undefined; + + // Checks for paragraph element after checkbox input element. + if ( + child.type === "element" && + child.tagName === "input" && + child.properties?.type === "checkbox" && + nextChild?.type === "element" && + nextChild.tagName === "p" + ) { + // Converts paragraph to span, otherwise remark will think it needs to + // be on a new line. + nextChild.tagName = "span"; + // Adds a space after the checkbox input element. + nextChild.children.splice( + 0, + 0, + deps.hastUtilFromDom.fromDom( + document.createTextNode(" ") + ) as HASTElement + ); + } else { + helper(child as HASTParent); } } - }; - - return helper; + } }; + + return helper; } diff --git a/packages/core/src/api/parsers/html/util/nestedLists.test.ts b/packages/core/src/api/parsers/html/util/nestedLists.test.ts index a29b500767..52d24ad88d 100644 --- a/packages/core/src/api/parsers/html/util/nestedLists.test.ts +++ b/packages/core/src/api/parsers/html/util/nestedLists.test.ts @@ -1,20 +1,17 @@ import { describe, expect, it } from "vitest"; +import { initializeESMDependencies } from "../../../../util/esmDependencies"; import { nestedListsToBlockNoteStructure } from "./nestedLists"; async function testHTML(html: string) { - const rehypeParse = await import("rehype-parse"); - const rehypeStringify = await import("rehype-stringify"); - const rehypeFormat = await import("rehype-format"); - - const unified = await import("unified"); + const deps = await initializeESMDependencies(); const htmlNode = nestedListsToBlockNoteStructure(html); - const pretty = await unified + const pretty = await deps.unified .unified() - .use(rehypeParse.default, { fragment: true }) - .use(rehypeFormat.default) - .use(rehypeStringify.default) + .use(deps.rehypeParse.default, { fragment: true }) + .use(deps.rehypeFormat.default) + .use(deps.rehypeStringify.default) .process(htmlNode.innerHTML); expect(pretty.value).toMatchSnapshot(); diff --git a/packages/core/src/api/parsers/markdown/parseMarkdown.ts b/packages/core/src/api/parsers/markdown/parseMarkdown.ts index 3c45ea74ce..868aff0a0a 100644 --- a/packages/core/src/api/parsers/markdown/parseMarkdown.ts +++ b/packages/core/src/api/parsers/markdown/parseMarkdown.ts @@ -2,6 +2,7 @@ import { Schema } from "prosemirror-model"; import { Block } from "../../../blocks/defaultBlocks"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema"; +import { initializeESMDependencies } from "../../../util/esmDependencies"; import { HTMLToBlocks } from "../html/parseHTML"; // modified version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js @@ -54,22 +55,18 @@ export async function markdownToBlocks< styleSchema: S, pmSchema: Schema ): Promise[]> { - const remarkParse = await import("remark-parse"); - const remarkGfm = await import("remark-gfm"); - const remarkRehype = await import("remark-rehype"); - const rehypeStringify = await import("rehype-stringify"); - const unified = await import("unified"); - const htmlString = unified + const deps = await initializeESMDependencies(); + const htmlString = deps.unified .unified() - .use(remarkParse.default) - .use(remarkGfm.default) - .use(remarkRehype.default, { + .use(deps.remarkParse.default) + .use(deps.remarkGfm.default) + .use(deps.remarkRehype.default, { handlers: { - ...(remarkRehype.defaultHandlers as any), + ...(deps.remarkRehype.defaultHandlers as any), code, }, }) - .use(rehypeStringify.default) + .use(deps.rehypeStringify.default) .processSync(markdown); return HTMLToBlocks( diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 9a4abd159e..b0780d920c 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -68,6 +68,7 @@ import { en } from "../i18n/locales"; import { Transaction } from "@tiptap/pm/state"; import { createInternalHTMLSerializer } from "../api/exporters/html/internalHTMLSerializer"; import "../style.css"; +import { initializeESMDependencies } from "../util/esmDependencies"; export type BlockNoteEditorOptions< BSchema extends BlockSchema, @@ -1016,7 +1017,8 @@ export class BlockNoteEditor< public async blocksToHTMLLossy( blocks: PartialBlock[] = this.document ): Promise { - const exporter = await createExternalHTMLExporter(this.pmSchema, this); + await initializeESMDependencies(); + const exporter = createExternalHTMLExporter(this.pmSchema, this); return exporter.exportBlocks(blocks, {}); } diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index 4de3662eed..da6b91eed3 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -12,6 +12,7 @@ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; import { EventEmitter } from "../../util/EventEmitter"; +import { initializeESMDependencies } from "../../util/esmDependencies"; import { MultipleNodeSelection } from "./MultipleNodeSelection"; let dragImageElement: Element | undefined; @@ -164,7 +165,7 @@ function unsetDragImage(rootEl: Document | ShadowRoot) { } } -async function dragStart< +function dragStart< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema @@ -232,16 +233,13 @@ async function dragStart< {} ); - const externalHTMLExporter = await createExternalHTMLExporter( - schema, - editor - ); + const externalHTMLExporter = createExternalHTMLExporter(schema, editor); const externalHTML = externalHTMLExporter.exportProseMirrorFragment( selectedSlice.content, {} ); - const plainText = await cleanHTMLToMarkdown(externalHTML); + const plainText = cleanHTMLToMarkdown(externalHTML); e.dataTransfer.clearData(); e.dataTransfer.setData("blocknote/html", internalHTML); @@ -305,6 +303,7 @@ export class SideMenuView< "dragover", this.onDragOver as EventListener ); + initializeESMDependencies(); this.pmView.dom.addEventListener("dragstart", this.onDragStart); // Shows or updates menu position whenever the cursor moves, if the menu isn't frozen. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cc561c7d62..9f352e54ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,16 +1,16 @@ import * as locales from "./i18n/locales"; export * from "./api/exporters/html/externalHTMLExporter"; export * from "./api/exporters/html/internalHTMLSerializer"; -export * from "./api/testUtil"; export * from "./api/getCurrentBlockContentType"; -export * from "./blocks/defaultBlockHelpers"; +export * from "./api/testUtil"; export * from "./blocks/AudioBlockContent/AudioBlockContent"; export * from "./blocks/FileBlockContent/FileBlockContent"; -export * from "./blocks/ImageBlockContent/ImageBlockContent"; -export * from "./blocks/VideoBlockContent/VideoBlockContent"; export * from "./blocks/FileBlockContent/fileBlockHelpers"; export * from "./blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY"; +export * from "./blocks/ImageBlockContent/ImageBlockContent"; export { parseImageElement } from "./blocks/ImageBlockContent/imageBlockHelpers"; +export * from "./blocks/VideoBlockContent/VideoBlockContent"; +export * from "./blocks/defaultBlockHelpers"; export * from "./blocks/defaultBlockTypeGuards"; export * from "./blocks/defaultBlocks"; export * from "./blocks/defaultProps"; @@ -23,15 +23,16 @@ export * from "./extensions/FilePanel/FilePanelPlugin"; export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; export * from "./extensions/LinkToolbar/LinkToolbarPlugin"; export * from "./extensions/SideMenu/SideMenuPlugin"; -export * from "./extensions/SuggestionMenu/DefaultSuggestionItem"; export * from "./extensions/SuggestionMenu/DefaultGridSuggestionItem"; +export * from "./extensions/SuggestionMenu/DefaultSuggestionItem"; export * from "./extensions/SuggestionMenu/SuggestionPlugin"; -export * from "./extensions/SuggestionMenu/getDefaultSlashMenuItems"; export * from "./extensions/SuggestionMenu/getDefaultEmojiPickerItems"; +export * from "./extensions/SuggestionMenu/getDefaultSlashMenuItems"; export * from "./extensions/TableHandles/TableHandlesPlugin"; export * from "./i18n/dictionary"; export * from "./schema"; export * from "./util/browser"; +export * from "./util/esmDependencies"; export * from "./util/string"; export * from "./util/typescript"; export { UnreachableCaseError, assertEmpty } from "./util/typescript"; diff --git a/packages/core/src/util/esmDependencies.ts b/packages/core/src/util/esmDependencies.ts new file mode 100644 index 0000000000..ce51497f5a --- /dev/null +++ b/packages/core/src/util/esmDependencies.ts @@ -0,0 +1,51 @@ +// some dependencies only export as ESM modules. This makes them incompatible with Node CJS. +// To work around this, we load these dependencies as dynamic imports in a function that initializes them. + +// (to reproduce this issue, run ts-node on a file that users server-util) +export let esmDependencies: + | undefined + | { + rehypeParse: typeof import("rehype-parse"); + rehypeStringify: typeof import("rehype-stringify"); + unified: typeof import("unified"); + hastUtilFromDom: typeof import("hast-util-from-dom"); + rehypeRemark: typeof import("rehype-remark"); + remarkGfm: typeof import("remark-gfm"); + remarkStringify: typeof import("remark-stringify"); + remarkParse: typeof import("remark-parse"); + remarkRehype: typeof import("remark-rehype"); + rehypeFormat: typeof import("rehype-format"); + }; + +export async function initializeESMDependencies() { + if (esmDependencies) { + return esmDependencies; + } + const vals = await Promise.all([ + import("rehype-parse"), + import("rehype-stringify"), + import("unified"), + import("hast-util-from-dom"), + import("rehype-remark"), + import("remark-gfm"), + import("remark-stringify"), + import("remark-parse"), + import("remark-rehype"), + import("rehype-format"), + ]); + + esmDependencies = { + rehypeParse: vals[0], + rehypeStringify: vals[1], + unified: vals[2], + hastUtilFromDom: vals[3], + rehypeRemark: vals[4], + remarkGfm: vals[5], + remarkStringify: vals[6], + remarkParse: vals[7], + remarkRehype: vals[8], + rehypeFormat: vals[9], + }; + + return esmDependencies; +} diff --git a/packages/react/src/test/htmlConversion.test.tsx b/packages/react/src/test/htmlConversion.test.tsx index de4d626207..918349a237 100644 --- a/packages/react/src/test/htmlConversion.test.tsx +++ b/packages/react/src/test/htmlConversion.test.tsx @@ -9,8 +9,10 @@ import { addIdsToBlocks, createExternalHTMLExporter, createInternalHTMLSerializer, + initializeESMDependencies, partialBlocksToBlocksForTesting, } from "@blocknote/core"; + import { flushSync } from "react-dom"; import { Root, createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -54,7 +56,8 @@ async function convertToHTMLAndCompareSnapshots< expect(parsed).toStrictEqual(fullBlocks); // Create the "external" HTML, which is a cleaned up HTML representation, but lossy - const exporter = await createExternalHTMLExporter(editor.pmSchema, editor); + await initializeESMDependencies(); + const exporter = createExternalHTMLExporter(editor.pmSchema, editor); const externalHTML = exporter.exportBlocks(blocks, {}); const externalHTMLSnapshotPath = "./__snapshots__/" + diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.ts b/packages/server-util/src/context/ServerBlockNoteEditor.ts index ecf376629f..eb3296214e 100644 --- a/packages/server-util/src/context/ServerBlockNoteEditor.ts +++ b/packages/server-util/src/context/ServerBlockNoteEditor.ts @@ -13,8 +13,10 @@ import { blocksToMarkdown, createExternalHTMLExporter, createInternalHTMLSerializer, + initializeESMDependencies, nodeToBlock, } from "@blocknote/core"; + import { BlockNoteViewRaw } from "@blocknote/react"; import { Node } from "@tiptap/pm/model"; import * as jsdom from "jsdom"; @@ -222,7 +224,8 @@ export class ServerBlockNoteEditor< blocks: PartialBlock[] ): Promise { return this._withJSDOM(async () => { - const exporter = await createExternalHTMLExporter( + await initializeESMDependencies(); + const exporter = createExternalHTMLExporter( this.editor.pmSchema, this.editor );