diff --git a/package-lock.json b/package-lock.json index 3520c0102b..a14117e164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11710,6 +11710,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.0.tgz", + "integrity": "sha512-KlClZ3/Qy5UgvpvVvDomGhnQhNWH5INE8GwvSIQ9CWt1K0zbbXrl7eN5bWaafOZgtmO3jMPwUqmrmEwinhPq1w==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "dev": true, @@ -17500,6 +17509,156 @@ "jsesc": "bin/jsesc" } }, + "node_modules/rehype-format": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.0.tgz", + "integrity": "sha512-kM4II8krCHmUhxrlvzFSptvaWh280Fr7UGNJU5DCMuvmAwGCNmGfi9CvFAQK6JDjsNoRMWQStglK3zKJH685Wg==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "rehype-minify-whitespace": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/@types/hast": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.3.tgz", + "integrity": "sha512-2fYGlaDy/qyLlhidX42wAH0KBi2TCjKMH8CHmBXgRlJ3Y+OXTiqsPQ6IWarZKwF1JoUcAJdPogv1d4b0COTpmQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/rehype-format/node_modules/@types/unist": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", + "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" + }, + "node_modules/rehype-format/node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-is-body-ok-link": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.0.tgz", + "integrity": "sha512-VFHY5bo2nY8HiV6nir2ynmEB1XkxzuUffhEGeVx7orbu/B1KaGyeGgMZldvMVx5xWrDlLLG/kQ6YkJAMkBEx0w==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/rehype-minify-whitespace": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-minify-whitespace/-/rehype-minify-whitespace-6.0.0.tgz", + "integrity": "sha512-i9It4YHR0Sf3GsnlR5jFUKXRr9oayvEk9GKQUkwZv6hs70OH9q3OCZrq9PpLvIGKt3W+JxBOxCidNVpH/6rWdA==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-minify-whitespace": { "version": "5.0.1", "license": "MIT", @@ -20215,6 +20374,7 @@ "prosemirror-tables": "^1.3.4", "prosemirror-transform": "^1.7.2", "prosemirror-view": "^1.31.4", + "rehype-format": "^5.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", diff --git a/packages/core/package.json b/packages/core/package.json index 4ed2dc62aa..2984e7b9d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,6 +82,7 @@ "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", + "rehype-format":"^5.0.0", "remark-gfm": "^3.0.1", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index ed20941ced..7ba1ba709f 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -1,5 +1,5 @@ import { Editor, EditorOptions, Extension } from "@tiptap/core"; -import { Node } from "prosemirror-model"; +import { Fragment, Node, Slice } from "prosemirror-model"; // import "./blocknote.css"; import { Editor as TiptapEditor } from "@tiptap/core/dist/packages/core/src/Editor"; import * as Y from "yjs"; @@ -24,7 +24,6 @@ import { BlockSpecs, PartialBlock, } from "./extensions/Blocks/api/blocks/types"; -import { TextCursorPosition } from "./extensions/Blocks/api/cursorPositionTypes"; import { DefaultBlockSchema, DefaultInlineContentSchema, @@ -44,8 +43,14 @@ import { import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos"; import "prosemirror-tables/style/tables.css"; + +import { createExternalHTMLExporter } from "./api/exporters/html/externalHTMLExporter"; +import { blocksToMarkdown } from "./api/exporters/markdown/markdownExporter"; +import { HTMLToBlocks } from "./api/parsers/html/parseHTML"; +import { markdownToBlocks } from "./api/parsers/markdown/parseMarkdown"; import "./editor.css"; import { getBlockSchemaFromSpecs } from "./extensions/Blocks/api/blocks/internal"; +import { TextCursorPosition } from "./extensions/Blocks/api/cursorPositionTypes"; import { getInlineContentSchemaFromSpecs } from "./extensions/Blocks/api/inlineContent/internal"; import { InlineContentSchema, @@ -409,6 +414,50 @@ export class BlockNoteEditor< newOptions.domAttributes?.editor?.class || "" ), }, + transformPasted(slice, view) { + // helper function + function removeChild(node: Fragment, n: number) { + const children: any[] = []; + node.forEach((child, _, i) => { + if (i !== n) { + children.push(child); + } + }); + return Fragment.from(children); + } + + // fix for https://github.com/ProseMirror/prosemirror/issues/1430#issuecomment-1822570821 + let f = Fragment.from(slice.content); + for (let i = 0; i < f.childCount; i++) { + if (f.child(i).type.spec.group === "blockContent") { + const content = [f.child(i)]; + if (i + 1 < f.childCount) { + // when there is a blockGroup, it should be nested in the new blockcontainer + if (f.child(i + 1).type.spec.group === "blockGroup") { + const nestedChild = f + .child(i + 1) + .child(0) + .child(0); + + if ( + nestedChild.type.name === "bulletListItem" || + nestedChild.type.name === "numberedListItem" + ) { + content.push(f.child(i + 1)); + f = removeChild(f, i + 1); + } + } + } + const container = view.state.schema.nodes.blockContainer.create( + undefined, + content + ); + f = f.replaceChild(i, container); + } + } + + return new Slice(f, slice.openStart, slice.openEnd); + }, }, }; @@ -942,47 +991,71 @@ export class BlockNoteEditor< } // TODO: Fix when implementing HTML/Markdown import & export - // /** - // * Serializes blocks into an HTML string. To better conform to HTML standards, children of blocks which aren't list - // * items are un-nested in the output HTML. - // * @param blocks An array of blocks that should be serialized into HTML. - // * @returns The blocks, serialized as an HTML string. - // */ - // public async blocksToHTML(blocks: Block[]): Promise { - // return blocksToHTML(blocks, this._tiptapEditor.schema, this); - // } - // - // /** - // * Parses blocks from an HTML string. Tries to create `Block` objects out of any HTML block-level elements, and - // * `InlineNode` objects from any HTML inline elements, though not all element types are recognized. If BlockNote - // * doesn't recognize an HTML element's tag, it will parse it as a paragraph or plain text. - // * @param html The HTML string to parse blocks from. - // * @returns The blocks parsed from the HTML string. - // */ - // public async HTMLToBlocks(html: string): Promise[]> { - // return HTMLToBlocks(html, this.schema, this._tiptapEditor.schema); - // } - // - // /** - // * Serializes blocks into a Markdown string. The output is simplified as Markdown does not support all features of - // * BlockNote - children of blocks which aren't list items are un-nested and certain styles are removed. - // * @param blocks An array of blocks that should be serialized into Markdown. - // * @returns The blocks, serialized as a Markdown string. - // */ - // public async blocksToMarkdown(blocks: Block[]): Promise { - // return blocksToMarkdown(blocks, this._tiptapEditor.schema, this); - // } - // - // /** - // * Creates a list of blocks from a Markdown string. Tries to create `Block` and `InlineNode` objects based on - // * Markdown syntax, though not all symbols are recognized. If BlockNote doesn't recognize a symbol, it will parse it - // * as text. - // * @param markdown The Markdown string to parse blocks from. - // * @returns The blocks parsed from the Markdown string. - // */ - // public async markdownToBlocks(markdown: string): Promise[]> { - // return markdownToBlocks(markdown, this.schema, this._tiptapEditor.schema); - // } + /** + * Serializes blocks into an HTML string. To better conform to HTML standards, children of blocks which aren't list + * items are un-nested in the output HTML. + * @param blocks An array of blocks that should be serialized into HTML. + * @returns The blocks, serialized as an HTML string. + */ + public async blocksToHTMLLossy( + blocks = this.topLevelBlocks + ): Promise { + const exporter = createExternalHTMLExporter( + this._tiptapEditor.schema, + this + ); + return exporter.exportBlocks(blocks); + } + + /** + * Parses blocks from an HTML string. Tries to create `Block` objects out of any HTML block-level elements, and + * `InlineNode` objects from any HTML inline elements, though not all element types are recognized. If BlockNote + * doesn't recognize an HTML element's tag, it will parse it as a paragraph or plain text. + * @param html The HTML string to parse blocks from. + * @returns The blocks parsed from the HTML string. + */ + public async tryParseHTMLToBlocks( + html: string + ): Promise[]> { + return HTMLToBlocks( + html, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this._tiptapEditor.schema + ); + } + + /** + * Serializes blocks into a Markdown string. The output is simplified as Markdown does not support all features of + * BlockNote - children of blocks which aren't list items are un-nested and certain styles are removed. + * @param blocks An array of blocks that should be serialized into Markdown. + * @returns The blocks, serialized as a Markdown string. + */ + public async blocksToMarkdownLossy( + blocks = this.topLevelBlocks + ): Promise { + return blocksToMarkdown(blocks, this._tiptapEditor.schema, this); + } + + /** + * Creates a list of blocks from a Markdown string. Tries to create `Block` and `InlineNode` objects based on + * Markdown syntax, though not all symbols are recognized. If BlockNote doesn't recognize a symbol, it will parse it + * as text. + * @param markdown The Markdown string to parse blocks from. + * @returns The blocks parsed from the Markdown string. + */ + public async tryParseMarkdownToBlocks( + markdown: string + ): Promise[]> { + return markdownToBlocks( + markdown, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this._tiptapEditor.schema + ); + } /** * Updates the user info for the current user that's shown to other collaborators. diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/BlockNoteExtensions.ts index 7458c73754..e7c52358fd 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -11,7 +11,8 @@ import { History } from "@tiptap/extension-history"; import { Link } from "@tiptap/extension-link"; import { Text } from "@tiptap/extension-text"; import * as Y from "yjs"; -import { createClipboardHandlerExtension } from "./api/serialization/clipboardHandlerExtension"; +import { createCopyToClipboardExtension } from "./api/exporters/copyExtension"; +import { createPasteFromClipboardExtension } from "./api/parsers/pasteExtension"; import { BackgroundColorExtension } from "./extensions/BackgroundColor/BackgroundColorExtension"; import { BlockContainer, BlockGroup, Doc } from "./extensions/Blocks"; import { @@ -24,7 +25,6 @@ import { InlineContentSpecs, } from "./extensions/Blocks/api/inlineContent/types"; import { StyleSchema, StyleSpecs } from "./extensions/Blocks/api/styles/types"; -import { TableExtension } from "./extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension"; import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension"; import { TextAlignmentExtension } from "./extensions/TextAlignment/TextAlignmentExtension"; import { TextColorExtension } from "./extensions/TextColor/TextColorExtension"; @@ -99,7 +99,6 @@ export const getBlockNoteExtensions = < BlockGroup.configure({ domAttributes: opts.domAttributes, }), - TableExtension, ...Object.values(opts.inlineContentSpecs) .filter((a) => a.config !== "link" && a.config !== "text") .map((inlineContentSpec) => { @@ -111,8 +110,8 @@ export const getBlockNoteExtensions = < ...Object.values(opts.blockSpecs).flatMap((blockSpec) => { return [ // dependent nodes (e.g.: tablecell / row) - ...(blockSpec.implementation.requiredNodes || []).map((node) => - node.configure({ + ...(blockSpec.implementation.requiredExtensions || []).map((ext) => + ext.configure({ editor: opts.editor, domAttributes: opts.domAttributes, }) @@ -124,7 +123,8 @@ export const getBlockNoteExtensions = < }), ]; }), - createClipboardHandlerExtension(opts.editor), + createCopyToClipboardExtension(opts.editor), + createPasteFromClipboardExtension(opts.editor), Dropcursor.configure({ width: 5, color: "#ddeeff" }), // This needs to be at the bottom of this list, because Key events (such as enter, when selecting a /command), diff --git a/packages/core/src/api/serialization/clipboardHandlerExtension.ts b/packages/core/src/api/exporters/copyExtension.ts similarity index 72% rename from packages/core/src/api/serialization/clipboardHandlerExtension.ts rename to packages/core/src/api/exporters/copyExtension.ts index 5ca2c22f1d..4b580b1f86 100644 --- a/packages/core/src/api/serialization/clipboardHandlerExtension.ts +++ b/packages/core/src/api/exporters/copyExtension.ts @@ -5,17 +5,11 @@ import { BlockNoteEditor } from "../../BlockNoteEditor"; import { BlockSchema } from "../../extensions/Blocks/api/blocks/types"; import { InlineContentSchema } from "../../extensions/Blocks/api/inlineContent/types"; import { StyleSchema } from "../../extensions/Blocks/api/styles/types"; -import { markdown } from "../formatConversions/formatConversions"; import { createExternalHTMLExporter } from "./html/externalHTMLExporter"; import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; +import { cleanHTMLToMarkdown } from "./markdown/markdownExporter"; -const acceptedMIMETypes = [ - "blocknote/html", - "text/html", - "text/plain", -] as const; - -export const createClipboardHandlerExtension = < +export const createCopyToClipboardExtension = < BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema @@ -23,6 +17,7 @@ export const createClipboardHandlerExtension = < editor: BlockNoteEditor ) => Extension.create<{ editor: BlockNoteEditor }, undefined>({ + name: "copyToClipboard", addProseMirrorPlugins() { const tiptap = this.editor; const schema = this.editor.schema; @@ -56,7 +51,7 @@ export const createClipboardHandlerExtension = < selectedFragment ); - const plainText = markdown(externalHTML); + const plainText = cleanHTMLToMarkdown(externalHTML); // TODO: Writing to other MIME types not working in Safari for // some reason. @@ -67,26 +62,6 @@ export const createClipboardHandlerExtension = < // Prevent default PM handler to be called return true; }, - paste(_view, event) { - event.preventDefault(); - - let format: (typeof acceptedMIMETypes)[number] | null = null; - - for (const mimeType of acceptedMIMETypes) { - if (event.clipboardData!.types.includes(mimeType)) { - format = mimeType; - break; - } - } - - if (format !== null) { - editor._tiptapEditor.view.pasteHTML( - event.clipboardData!.getData(format!) - ); - } - - return true; - }, }, }, }), diff --git a/packages/core/src/api/serialization/html/__snapshots__/complex/misc/external.html b/packages/core/src/api/exporters/html/__snapshots__/complex/misc/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/complex/misc/external.html rename to packages/core/src/api/exporters/html/__snapshots__/complex/misc/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/complex/misc/internal.html b/packages/core/src/api/exporters/html/__snapshots__/complex/misc/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/complex/misc/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/complex/misc/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/basic/external.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/nested/external.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/nested/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/styled/external.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/styled/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/internal.html diff --git a/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html new file mode 100644 index 0000000000..49b9ce6858 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html @@ -0,0 +1 @@ +

This is text with a custom fontSize

\ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/fontSize/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html similarity index 58% rename from packages/core/src/api/serialization/html/__snapshots__/fontSize/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html index 3e2beaedd6..3fe864246c 100644 --- a/packages/core/src/api/serialization/html/__snapshots__/fontSize/basic/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html @@ -1 +1 @@ -

This is text with a custom fontSize

\ No newline at end of file +

This is text with a custom fontSize

\ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/basic/external.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/between-links/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/between-links/external.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/between-links/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/between-links/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/end/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/end/external.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/end/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/end/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/link/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/link/external.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/link/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/link/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/multiple/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/multiple/external.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/multiple/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/multiple/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/only/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/only/external.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/only/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/only/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/start/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/start/external.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/start/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/start/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/styles/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/styles/external.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/hardbreak/styles/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/hardbreak/styles/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/basic/external.html rename to packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/button/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/button/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/button/external.html rename to packages/core/src/api/exporters/html/__snapshots__/image/button/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/button/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/button/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/nested/external.html rename to packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/nested/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/link/adjacent/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/link/adjacent/external.html rename to packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/link/adjacent/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/link/adjacent/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/link/adjacent/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/link/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/basic/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/link/basic/external.html rename to packages/core/src/api/exporters/html/__snapshots__/link/basic/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/link/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/link/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/link/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/link/styled/external.html rename to packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/link/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/link/styled/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html diff --git a/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html new file mode 100644 index 0000000000..2e6f533ca1 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html @@ -0,0 +1 @@ +

I enjoy working with@Matthew

\ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/tag/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html similarity index 58% rename from packages/core/src/api/serialization/html/__snapshots__/tag/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html index dac5db0ca8..6ca7d81c2c 100644 --- a/packages/core/src/api/serialization/html/__snapshots__/tag/basic/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html @@ -1 +1 @@ -

I love #BlockNote

\ No newline at end of file +

I enjoy working with@Matthew

\ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/paragraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/paragraph/basic/external.html rename to packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/paragraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/paragraph/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/paragraph/empty/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/paragraph/empty/external.html rename to packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/paragraph/empty/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/paragraph/empty/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/paragraph/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/paragraph/nested/external.html rename to packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/paragraph/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/paragraph/nested/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/paragraph/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/paragraph/styled/external.html rename to packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/paragraph/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/paragraph/styled/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/internal.html diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-basic-block-types.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-basic-block-types.json new file mode 100644 index 0000000000..2d11e081f6 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-basic-block-types.json @@ -0,0 +1,140 @@ +[ + { + "id": "1", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Heading 1", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Heading 2", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 3 + }, + "content": [ + { + "type": "text", + "text": "Heading 3", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "5", + "type": "image", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "url": "exampleURL", + "caption": "Image Caption", + "width": 512 + }, + "children": [] + }, + { + "id": "6", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "None ", + "styles": {} + }, + { + "type": "text", + "text": "Bold ", + "styles": { + "bold": true + } + }, + { + "type": "text", + "text": "Italic ", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": "Underline ", + "styles": { + "underline": true + } + }, + { + "type": "text", + "text": "Strikethrough ", + "styles": { + "strike": true + } + }, + { + "type": "text", + "text": "All", + "styles": { + "bold": true, + "italic": true, + "underline": true, + "strike": true + } + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-deep-nested-content.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-deep-nested-content.json new file mode 100644 index 0000000000..ae11e36cb7 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-deep-nested-content.json @@ -0,0 +1,240 @@ +[ + { + "id": "1", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Outer 1 Div Before", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " Outer 2 Div Before", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " Outer 3 Div Before", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " Outer 4 Div Before", + "styles": {} + } + ], + "children": [] + }, + { + "id": "5", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Heading 1", + "styles": {} + } + ], + "children": [] + }, + { + "id": "6", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Heading 2", + "styles": {} + } + ], + "children": [] + }, + { + "id": "7", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 3 + }, + "content": [ + { + "type": "text", + "text": "Heading 3", + "styles": {} + } + ], + "children": [] + }, + { + "id": "8", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "9", + "type": "image", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "url": "exampleURL", + "caption": "Image Caption", + "width": 512 + }, + "children": [] + }, + { + "id": "10", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bold", + "styles": { + "bold": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Italic", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Underline", + "styles": { + "underline": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Strikethrough", + "styles": { + "strike": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "All", + "styles": { + "bold": true, + "italic": true, + "underline": true, + "strike": true + } + } + ], + "children": [] + }, + { + "id": "11", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " Outer 4 Div After Outer 3 Div After Outer 2 Div After Outer 1 Div After", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-div-with-inline-content.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-div-with-inline-content.json new file mode 100644 index 0000000000..d06969a05f --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-div-with-inline-content.json @@ -0,0 +1,91 @@ +[ + { + "id": "1", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "None ", + "styles": {} + }, + { + "type": "text", + "text": "Bold ", + "styles": { + "bold": true + } + }, + { + "type": "text", + "text": "Italic ", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": "Underline ", + "styles": { + "underline": true + } + }, + { + "type": "text", + "text": "Strikethrough ", + "styles": { + "strike": true + } + }, + { + "type": "text", + "text": "All", + "styles": { + "bold": true, + "italic": true, + "underline": true, + "strike": true + } + } + ], + "children": [] + }, + { + "id": "2", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Div", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Paragraph", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-divs.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-divs.json new file mode 100644 index 0000000000..33f2f5010b --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-divs.json @@ -0,0 +1,19 @@ +[ + { + "id": "1", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Single Div", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-fake-image-caption.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-fake-image-caption.json new file mode 100644 index 0000000000..86a0cb8168 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-fake-image-caption.json @@ -0,0 +1,31 @@ +[ + { + "id": "1", + "type": "image", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "url": "exampleURL", + "caption": "", + "width": 512 + }, + "children": [] + }, + { + "id": "2", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Image Caption", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-mixed-nested-lists.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-mixed-nested-lists.json new file mode 100644 index 0000000000..1acc524e82 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-mixed-nested-lists.json @@ -0,0 +1,70 @@ +[ + { + "id": "1", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "numberedListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "numberedListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json new file mode 100644 index 0000000000..6c5bcf5056 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json @@ -0,0 +1,70 @@ +[ + { + "id": "1", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists.json new file mode 100644 index 0000000000..6c5bcf5056 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists.json @@ -0,0 +1,70 @@ +[ + { + "id": "1", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/basic/external.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/nested/external.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/nested/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/styled/external.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleCustomParagraph/styled/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleImage/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleImage/basic/external.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleImage/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleImage/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleImage/button/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleImage/button/external.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleImage/button/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleImage/button/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleImage/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleImage/nested/external.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/simpleImage/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/simpleImage/nested/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html diff --git a/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html new file mode 100644 index 0000000000..35c3d5c232 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html @@ -0,0 +1 @@ +

This is a small text

\ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/mention/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html similarity index 66% rename from packages/core/src/api/serialization/html/__snapshots__/mention/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html index 7af6dad9c7..73836f647d 100644 --- a/packages/core/src/api/serialization/html/__snapshots__/mention/basic/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html @@ -1 +1 @@ -

I enjoy working with@Matthew

\ No newline at end of file +

This is a small text

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html new file mode 100644 index 0000000000..b8387e9a55 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html @@ -0,0 +1 @@ +

I love #BlockNote

\ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/small/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html similarity index 61% rename from packages/core/src/api/serialization/html/__snapshots__/small/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html index 805c78112e..bac28633b0 100644 --- a/packages/core/src/api/serialization/html/__snapshots__/small/basic/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html @@ -1 +1 @@ -

This is a small text

\ No newline at end of file +

I love #BlockNote

\ No newline at end of file diff --git a/packages/core/src/api/serialization/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts similarity index 97% rename from packages/core/src/api/serialization/html/externalHTMLExporter.ts rename to packages/core/src/api/exporters/html/externalHTMLExporter.ts index 76021c1333..8d62dd587c 100644 --- a/packages/core/src/api/serialization/html/externalHTMLExporter.ts +++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts @@ -10,12 +10,12 @@ import { } from "../../../extensions/Blocks/api/blocks/types"; import { InlineContentSchema } from "../../../extensions/Blocks/api/inlineContent/types"; import { StyleSchema } from "../../../extensions/Blocks/api/styles/types"; -import { simplifyBlocks } from "../../formatConversions/simplifyBlocksRehypePlugin"; import { blockToNode } from "../../nodeConversions/nodeConversions"; import { serializeNodeInner, serializeProseMirrorFragment, -} from "./sharedHTMLConversion"; +} from "./util/sharedHTMLConversion"; +import { simplifyBlocks } from "./util/simplifyBlocksRehypePlugin"; // Used to export BlockNote blocks and ProseMirror nodes to HTML for use outside // the editor. Blocks are exported using the `toExternalHTML` method in their diff --git a/packages/core/src/api/serialization/html/htmlConversion.test.ts b/packages/core/src/api/exporters/html/htmlConversion.test.ts similarity index 93% rename from packages/core/src/api/serialization/html/htmlConversion.test.ts rename to packages/core/src/api/exporters/html/htmlConversion.test.ts index 6c3d95acbd..f6592f1bb7 100644 --- a/packages/core/src/api/serialization/html/htmlConversion.test.ts +++ b/packages/core/src/api/exporters/html/htmlConversion.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { BlockNoteEditor } from "../../../BlockNoteEditor"; +import { addIdsToBlocks, partialBlocksToBlocksForTesting } from "../../.."; import { createBlockSpec } from "../../../extensions/Blocks/api/blocks/createSpec"; import { BlockSchema, @@ -294,7 +295,7 @@ const editorTestCases: EditorTestCases< ], }; -function convertToHTMLAndCompareSnapshots< +async function convertToHTMLAndCompareSnapshots< B extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema @@ -304,6 +305,7 @@ function convertToHTMLAndCompareSnapshots< snapshotDirectory: string, snapshotName: string ) { + addIdsToBlocks(blocks); const serializer = createInternalHTMLSerializer( editor._tiptapEditor.schema, editor @@ -317,6 +319,16 @@ function convertToHTMLAndCompareSnapshots< "/internal.html"; expect(internalHTML).toMatchFileSnapshot(internalHTMLSnapshotPath); + // turn the internalHTML back into blocks, and make sure no data was lost + const fullBlocks = partialBlocksToBlocksForTesting( + editor.blockSchema, + blocks + ); + const parsed = await editor.tryParseHTMLToBlocks(internalHTML); + + expect(parsed).toStrictEqual(fullBlocks); + + // Create the "external" HTML, which is a cleaned up HTML representation, but lossy const exporter = createExternalHTMLExporter( editor._tiptapEditor.schema, editor @@ -356,9 +368,9 @@ describe("Test HTML conversion", () => { for (const document of testCase.documents) { // eslint-disable-next-line no-loop-func - it("Convert " + document.name + " to HTML", () => { + it("Convert " + document.name + " to HTML", async () => { const nameSplit = document.name.split("/"); - convertToHTMLAndCompareSnapshots( + await convertToHTMLAndCompareSnapshots( editor, document.blocks, nameSplit[0], diff --git a/packages/core/src/api/serialization/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts similarity index 98% rename from packages/core/src/api/serialization/html/internalHTMLSerializer.ts rename to packages/core/src/api/exporters/html/internalHTMLSerializer.ts index bd32aa8950..77785dd0ac 100644 --- a/packages/core/src/api/serialization/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -10,7 +10,7 @@ import { blockToNode } from "../../nodeConversions/nodeConversions"; import { serializeNodeInner, serializeProseMirrorFragment, -} from "./sharedHTMLConversion"; +} 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 diff --git a/packages/core/src/api/serialization/html/sharedHTMLConversion.ts b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts similarity index 89% rename from packages/core/src/api/serialization/html/sharedHTMLConversion.ts rename to packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts index 1f91309287..79413388ad 100644 --- a/packages/core/src/api/serialization/html/sharedHTMLConversion.ts +++ b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts @@ -1,10 +1,10 @@ import { DOMSerializer, Fragment, Node } from "prosemirror-model"; -import { BlockNoteEditor } from "../../../BlockNoteEditor"; -import { BlockSchema } from "../../../extensions/Blocks/api/blocks/types"; -import { InlineContentSchema } from "../../../extensions/Blocks/api/inlineContent/types"; -import { StyleSchema } from "../../../extensions/Blocks/api/styles/types"; -import { nodeToBlock } from "../../nodeConversions/nodeConversions"; +import { BlockNoteEditor } from "../../../../BlockNoteEditor"; +import { BlockSchema } from "../../../../extensions/Blocks/api/blocks/types"; +import { InlineContentSchema } from "../../../../extensions/Blocks/api/inlineContent/types"; +import { StyleSchema } from "../../../../extensions/Blocks/api/styles/types"; +import { nodeToBlock } from "../../../nodeConversions/nodeConversions"; function doc(options: { document?: Document }) { return options.document || window.document; diff --git a/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts similarity index 100% rename from packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts rename to packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts diff --git a/packages/core/src/api/formatConversions/__snapshots__/formatConversions.test.ts.snap b/packages/core/src/api/exporters/markdown/__snapshots__/formatConversions.test.ts.snap similarity index 100% rename from packages/core/src/api/formatConversions/__snapshots__/formatConversions.test.ts.snap rename to packages/core/src/api/exporters/markdown/__snapshots__/formatConversions.test.ts.snap diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.ts b/packages/core/src/api/exporters/markdown/markdownExporter.ts new file mode 100644 index 0000000000..fbe1fdd15c --- /dev/null +++ b/packages/core/src/api/exporters/markdown/markdownExporter.ts @@ -0,0 +1,43 @@ +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 { + Block, + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, + createExternalHTMLExporter, +} from "../../.."; +import { removeUnderlines } from "./removeUnderlinesRehypePlugin"; + +export function cleanHTMLToMarkdown(cleanHTMLString: string) { + const markdownString = unified() + .use(rehypeParse, { fragment: true }) + .use(removeUnderlines) + .use(rehypeRemark) + .use(remarkGfm) + .use(remarkStringify) + .processSync(cleanHTMLString); + + return markdownString.value as string; +} + +// TODO: add tests +export function blocksToMarkdown< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + blocks: Block[], + schema: Schema, + editor: BlockNoteEditor +): string { + const exporter = createExternalHTMLExporter(schema, editor); + const externalHTML = exporter.exportBlocks(blocks); + + return cleanHTMLToMarkdown(externalHTML); +} diff --git a/packages/core/src/api/formatConversions/removeUnderlinesRehypePlugin.ts b/packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts similarity index 100% rename from packages/core/src/api/formatConversions/removeUnderlinesRehypePlugin.ts rename to packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts diff --git a/packages/core/src/api/formatConversions/formatConversions.ts b/packages/core/src/api/formatConversions/formatConversions.ts deleted file mode 100644 index 5f82683cd8..0000000000 --- a/packages/core/src/api/formatConversions/formatConversions.ts +++ /dev/null @@ -1,140 +0,0 @@ -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 { removeUnderlines } from "./removeUnderlinesRehypePlugin"; - -// export async function blocksToHTML( -// blocks: Block[], -// schema: Schema, -// editor: BlockNoteEditor -// ): Promise { -// const htmlParentElement = document.createElement("div"); -// const serializer = createInternalHTMLSerializer(schema, editor); -// -// for (const block of blocks) { -// const node = blockToNode(block, schema); -// const htmlNode = serializer.serializeNode(node); -// htmlParentElement.appendChild(htmlNode); -// } -// -// const htmlString = await unified() -// .use(rehypeParse, { fragment: true }) -// .use(simplifyBlocks, { -// orderedListItemBlockTypes: new Set(["numberedListItem"]), -// unorderedListItemBlockTypes: new Set(["bulletListItem"]), -// }) -// .use(rehypeStringify) -// .process(htmlParentElement.innerHTML); -// -// return htmlString.value as string; -// } -// -// export async function HTMLToBlocks( -// html: string, -// blockSchema: BSchema, -// schema: Schema -// ): Promise[]> { -// const htmlNode = document.createElement("div"); -// htmlNode.innerHTML = html.trim(); -// -// const parser = DOMParser.fromSchema(schema); -// const parentNode = parser.parse(htmlNode); //, { preserveWhitespace: "full" }); -// -// const blocks: Block[] = []; -// -// for (let i = 0; i < parentNode.firstChild!.childCount; i++) { -// blocks.push(nodeToBlock(parentNode.firstChild!.child(i), blockSchema)); -// } -// -// return blocks; -// } -// -// export async function blocksToMarkdown( -// blocks: Block[], -// schema: Schema, -// editor: BlockNoteEditor -// ): Promise { -// const markdownString = await unified() -// .use(rehypeParse, { fragment: true }) -// .use(removeUnderlines) -// .use(rehypeRemark) -// .use(remarkGfm) -// .use(remarkStringify) -// .process(await blocksToHTML(blocks, schema, editor)); -// -// return markdownString.value as string; -// } -// -// // modefied version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js -// // that outputs a data-language attribute instead of a CSS class (e.g.: language-typescript) -// function code(state: any, node: any) { -// const value = node.value ? node.value + "\n" : ""; -// /** @type {Properties} */ -// const properties: any = {}; -// -// if (node.lang) { -// // changed line -// properties["data-language"] = node.lang; -// } -// -// // Create ``. -// /** @type {Element} */ -// let result: any = { -// type: "element", -// tagName: "code", -// properties, -// children: [{ type: "text", value }], -// }; -// -// if (node.meta) { -// result.data = { meta: node.meta }; -// } -// -// state.patch(node, result); -// result = state.applyData(node, result); -// -// // Create `
`.
-//   result = {
-//     type: "element",
-//     tagName: "pre",
-//     properties: {},
-//     children: [result],
-//   };
-//   state.patch(node, result);
-//   return result;
-// }
-//
-// export async function markdownToBlocks(
-//   markdown: string,
-//   blockSchema: BSchema,
-//   schema: Schema
-// ): Promise[]> {
-//   const htmlString = await unified()
-//     .use(remarkParse)
-//     .use(remarkGfm)
-//     .use(remarkRehype, {
-//       handlers: {
-//         ...(defaultHandlers as any),
-//         code,
-//       },
-//     })
-//     .use(rehypeStringify)
-//     .process(markdown);
-//
-//   return HTMLToBlocks(htmlString.value as string, blockSchema, schema);
-// }
-
-export function markdown(cleanHTMLString: string) {
-  const markdownString = unified()
-    .use(rehypeParse, { fragment: true })
-    .use(removeUnderlines)
-    .use(rehypeRemark)
-    .use(remarkGfm)
-    .use(remarkStringify)
-    .processSync(cleanHTMLString);
-
-  return markdownString.value as string;
-}
diff --git a/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap b/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap
index 05bf59965a..41b67fb5ca 100644
--- a/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap
+++ b/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap
@@ -1,6 +1,6 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
-exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert fontSize/basic to/from prosemirror 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: custom inline content schema > Convert mention/basic to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -14,17 +14,15 @@ exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Con
       },
       "content": [
         {
-          "marks": [
-            {
-              "attrs": {
-                "stringValue": "18px",
-              },
-              "type": "fontSize",
-            },
-          ],
-          "text": "This is text with a custom fontSize",
+          "text": "I enjoy working with",
           "type": "text",
         },
+        {
+          "attrs": {
+            "user": "Matthew",
+          },
+          "type": "mention",
+        },
       ],
       "type": "paragraph",
     },
@@ -33,7 +31,7 @@ exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Con
 }
 `;
 
-exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert mention/basic to/from prosemirror 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: custom inline content schema > Convert tag/basic to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -47,14 +45,17 @@ exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Con
       },
       "content": [
         {
-          "text": "I enjoy working with",
+          "text": "I love ",
           "type": "text",
         },
         {
-          "attrs": {
-            "user": "Matthew",
-          },
-          "type": "mention",
+          "content": [
+            {
+              "text": "BlockNote",
+              "type": "text",
+            },
+          ],
+          "type": "tag",
         },
       ],
       "type": "paragraph",
@@ -64,7 +65,7 @@ exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Con
 }
 `;
 
-exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert small/basic to/from prosemirror 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert fontSize/basic to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -80,10 +81,13 @@ exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Con
         {
           "marks": [
             {
-              "type": "small",
+              "attrs": {
+                "stringValue": "18px",
+              },
+              "type": "fontSize",
             },
           ],
-          "text": "This is a small text",
+          "text": "This is text with a custom fontSize",
           "type": "text",
         },
       ],
@@ -94,7 +98,7 @@ exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Con
 }
 `;
 
-exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert tag/basic to/from prosemirror 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert small/basic to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -108,17 +112,13 @@ exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Con
       },
       "content": [
         {
-          "text": "I love ",
-          "type": "text",
-        },
-        {
-          "content": [
+          "marks": [
             {
-              "text": "BlockNote",
-              "type": "text",
+              "type": "small",
             },
           ],
-          "type": "tag",
+          "text": "This is a small text",
+          "type": "text",
         },
       ],
       "type": "paragraph",
diff --git a/packages/core/src/api/nodeConversions/nodeConversions.test.ts b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
index c645c3a2c0..be1d1cfaf2 100644
--- a/packages/core/src/api/nodeConversions/nodeConversions.test.ts
+++ b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
@@ -2,21 +2,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
 
 import { BlockNoteEditor } from "../../BlockNoteEditor";
 import { PartialBlock } from "../../extensions/Blocks/api/blocks/types";
-import UniqueID from "../../extensions/UniqueID/UniqueID";
 import { customInlineContentTestCases } from "../testCases/cases/customInlineContent";
 import { customStylesTestCases } from "../testCases/cases/customStyles";
 import { defaultSchemaTestCases } from "../testCases/cases/defaultSchema";
 import { blockToNode, nodeToBlock } from "./nodeConversions";
-import { partialBlockToBlockForTesting } from "./testUtil";
-
-function addIdsToBlock(block: PartialBlock) {
-  if (!block.id) {
-    block.id = UniqueID.options.generateID();
-  }
-  for (const child of block.children || []) {
-    addIdsToBlock(child);
-  }
-}
+import { addIdsToBlock, partialBlockToBlockForTesting } from "./testUtil";
 
 function validateConversion(
   block: PartialBlock,
diff --git a/packages/core/src/api/nodeConversions/testUtil.ts b/packages/core/src/api/nodeConversions/testUtil.ts
index e38638ea02..3398e19d2d 100644
--- a/packages/core/src/api/nodeConversions/testUtil.ts
+++ b/packages/core/src/api/nodeConversions/testUtil.ts
@@ -13,6 +13,7 @@ import {
   isStyledTextInlineContent,
 } from "../../extensions/Blocks/api/inlineContent/types";
 import { StyleSchema } from "../../extensions/Blocks/api/styles/types";
+import UniqueID from "../../extensions/UniqueID/UniqueID";
 
 function textShorthandToStyledText(
   content: string | StyledText[] = ""
@@ -62,6 +63,19 @@ function partialContentToInlineContent(
   return content;
 }
 
+export function partialBlocksToBlocksForTesting<
+  BSchema extends BlockSchema,
+  I extends InlineContentSchema,
+  S extends StyleSchema
+>(
+  schema: BSchema,
+  partialBlocks: Array>
+): Array> {
+  return partialBlocks.map((partialBlock) =>
+    partialBlockToBlockForTesting(schema, partialBlock)
+  );
+}
+
 export function partialBlockToBlockForTesting<
   BSchema extends BlockSchema,
   I extends InlineContentSchema,
@@ -96,3 +110,18 @@ export function partialBlockToBlockForTesting<
     }),
   } as any;
 }
+
+export function addIdsToBlock(block: PartialBlock) {
+  if (!block.id) {
+    block.id = UniqueID.options.generateID();
+  }
+  if (block.children) {
+    addIdsToBlocks(block.children);
+  }
+}
+
+export function addIdsToBlocks(blocks: PartialBlock[]) {
+  for (const block of blocks) {
+    addIdsToBlock(block);
+  }
+}
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json b/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json
new file mode 100644
index 0000000000..7ef10bf491
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json
@@ -0,0 +1,105 @@
+[
+  {
+    "id": "1",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "First",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Second",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Third",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "4",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Five Parent",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "5",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Child 1",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "6",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Child 2",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json
new file mode 100644
index 0000000000..2d11e081f6
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json
@@ -0,0 +1,140 @@
+[
+  {
+    "id": "1",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 1
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 1",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 2
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 3
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 3",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "4",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "image",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "url": "exampleURL",
+      "caption": "Image Caption",
+      "width": 512
+    },
+    "children": []
+  },
+  {
+    "id": "6",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "None ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Bold ",
+        "styles": {
+          "bold": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Italic ",
+        "styles": {
+          "italic": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Underline ",
+        "styles": {
+          "underline": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Strikethrough ",
+        "styles": {
+          "strike": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "All",
+        "styles": {
+          "bold": true,
+          "italic": true,
+          "underline": true,
+          "strike": true
+        }
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json
new file mode 100644
index 0000000000..ae11e36cb7
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json
@@ -0,0 +1,240 @@
+[
+  {
+    "id": "1",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Outer 1 Div Before",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": " Outer 2 Div Before",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": " Outer 3 Div Before",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "4",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": " Outer 4 Div Before",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 1
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 1",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "6",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 2
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "7",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 3
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 3",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "8",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "9",
+    "type": "image",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "url": "exampleURL",
+      "caption": "Image Caption",
+      "width": 512
+    },
+    "children": []
+  },
+  {
+    "id": "10",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bold",
+        "styles": {
+          "bold": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Italic",
+        "styles": {
+          "italic": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Underline",
+        "styles": {
+          "underline": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Strikethrough",
+        "styles": {
+          "strike": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "All",
+        "styles": {
+          "bold": true,
+          "italic": true,
+          "underline": true,
+          "strike": true
+        }
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "11",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": " Outer 4 Div After Outer 3 Div After Outer 2 Div After Outer 1 Div After",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json
new file mode 100644
index 0000000000..d06969a05f
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json
@@ -0,0 +1,91 @@
+[
+  {
+    "id": "1",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "None ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Bold ",
+        "styles": {
+          "bold": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Italic ",
+        "styles": {
+          "italic": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Underline ",
+        "styles": {
+          "underline": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Strikethrough ",
+        "styles": {
+          "strike": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "All",
+        "styles": {
+          "bold": true,
+          "italic": true,
+          "underline": true,
+          "strike": true
+        }
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Paragraph",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json
new file mode 100644
index 0000000000..764afd66ac
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json
@@ -0,0 +1,121 @@
+[
+  {
+    "id": "1",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Single Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": " Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "4",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Single Div 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "6",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "7",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json
new file mode 100644
index 0000000000..86a0cb8168
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json
@@ -0,0 +1,31 @@
+[
+  {
+    "id": "1",
+    "type": "image",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "url": "exampleURL",
+      "caption": "",
+      "width": 512
+    },
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Image Caption",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json
new file mode 100644
index 0000000000..7bb12cd2cb
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json
@@ -0,0 +1,140 @@
+[
+  {
+    "id": "1",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "2",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "3",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "4",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "6",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "7",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "8",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json
new file mode 100644
index 0000000000..cc6065d2d4
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json
@@ -0,0 +1,140 @@
+[
+  {
+    "id": "1",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "2",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "3",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "4",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "6",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "7",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "8",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json
new file mode 100644
index 0000000000..e20435c9c8
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json
@@ -0,0 +1,157 @@
+[
+  {
+    "id": "1",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "3",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "4",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "5",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "6",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "7",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "8",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "9",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json
new file mode 100644
index 0000000000..aa21de34f0
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json
@@ -0,0 +1,36 @@
+[
+  {
+    "id": "1",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Single Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "second Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/parseHTML.test.ts b/packages/core/src/api/parsers/html/parseHTML.test.ts
new file mode 100644
index 0000000000..5bd8238e3f
--- /dev/null
+++ b/packages/core/src/api/parsers/html/parseHTML.test.ts
@@ -0,0 +1,267 @@
+import { describe, expect, it } from "vitest";
+import { BlockNoteEditor } from "../../..";
+import { nestedListsToBlockNoteStructure } from "./util/nestedLists";
+
+async function parseHTMLAndCompareSnapshots(
+  html: string,
+  snapshotName: string
+) {
+  const view: any = await import("prosemirror-view");
+
+  const editor = BlockNoteEditor.create();
+  const blocks = await editor.tryParseHTMLToBlocks(html);
+
+  const snapshotPath = "./__snapshots__/paste/" + snapshotName + ".json";
+  expect(JSON.stringify(blocks, undefined, 2)).toMatchFileSnapshot(
+    snapshotPath
+  );
+
+  // Now, we also want to test actually pasting in the editor, and not just calling
+  // tryParseHTMLToBlocks directly.
+  // The reason is that the prosemirror logic for pasting can be a bit different, because
+  // it's related to the context of where the user is pasting exactly (selection)
+  //
+  // The internal difference come that in tryParseHTMLToBlocks, we use DOMParser.parse,
+  // while when pasting, Prosemirror uses DOMParser.parseSlice, and then tries to fit the
+  // slice in the document. This fitting might change the structure / interpretation of the pasted blocks
+
+  // Simulate a paste event (this uses DOMParser.parseSlice internally)
+
+  (window as any).__TEST_OPTIONS.mockID = 0; // reset id counter
+  const htmlNode = nestedListsToBlockNoteStructure(html);
+  const tt = editor._tiptapEditor;
+
+  const slice = view.__parseFromClipboard(
+    tt.view,
+    "",
+    htmlNode.innerHTML,
+    false,
+    tt.view.state.selection.$from
+  );
+  tt.view.dispatch(tt.view.state.tr.replaceSelection(slice));
+
+  // alternative paste simulation doesn't work in a non-browser vitest env
+  //   editor._tiptapEditor.view.pasteHTML(html, {
+  //     preventDefault: () => {
+  //       // noop
+  //     },
+  //     clipboardData: {
+  //       types: ["text/html"],
+  //       getData: () => html,
+  //     },
+  //   } as any);
+
+  const pastedBlocks = editor.topLevelBlocks;
+  pastedBlocks.pop(); // trailing paragraph
+  expect(pastedBlocks).toStrictEqual(blocks);
+}
+
+describe("Parse HTML", () => {
+  it("Parse basic block types", async () => {
+    const html = `

Heading 1

+

Heading 2

+

Heading 3

+

Paragraph

+
Image Caption
+

None Bold Italic Underline Strikethrough All

`; + + await parseHTMLAndCompareSnapshots(html, "parse-basic-block-types"); + }); + + it("list test", async () => { + const html = `
    +
  • First
  • +
  • Second
  • +
  • Third
  • +
  • Five Parent +
      +
    • Child 1
    • +
    • Child 2
    • +
    +
  • +
`; + await parseHTMLAndCompareSnapshots(html, "list-test"); + }); + + it("Parse nested lists", async () => { + const html = `
    +
  • Bullet List Item
  • +
  • Bullet List Item
  • +
      +
    • + Nested Bullet List Item +
    • +
    • + Nested Bullet List Item +
    • +
    +
  • + Bullet List Item +
  • +
+
    +
  1. + Numbered List Item +
      +
    1. + Nested Numbered List Item +
    2. +
    3. + Nested Numbered List Item +
    4. +
    +
  2. +
  3. + Numbered List Item +
  4. +
`; + + await parseHTMLAndCompareSnapshots(html, "parse-nested-lists"); + }); + + it("Parse nested lists with paragraphs", async () => { + const html = `
    +
  • +

    Bullet List Item

    +
      +
    • +

      Nested Bullet List Item

      +
    • +
    • +

      Nested Bullet List Item

      +
    • +
    +
  • +
  • +

    Bullet List Item

    +
  • +
+
    +
  1. +

    Numbered List Item

    +
      +
    1. +

      Nested Numbered List Item

      +
    2. +
    3. +

      Nested Numbered List Item

      +
    4. +
    +
  2. +
  3. +

    Numbered List Item

    +
  4. +
`; + + await parseHTMLAndCompareSnapshots( + html, + "parse-nested-lists-with-paragraphs" + ); + }); + + it("Parse mixed nested lists", async () => { + const html = `
    +
  • + Bullet List Item +
      +
    1. + Nested Numbered List Item +
    2. +
    3. + Nested Numbered List Item +
    4. +
    +
  • +
  • + Bullet List Item +
  • +
+
    +
  1. + Numbered List Item +
      +
    • +

      Nested Bullet List Item

      +
    • +
    • +

      Nested Bullet List Item

      +
    • +
    +
  2. +
  3. + Numbered List Item +
  4. +
`; + + await parseHTMLAndCompareSnapshots(html, "parse-mixed-nested-lists"); + }); + + it("Parse divs", async () => { + const html = `
Single Div
+
+ Div +
Nested Div
+
Nested Div
+
+
Single Div 2
+
+
Nested Div
+
Nested Div
+
`; + + await parseHTMLAndCompareSnapshots(html, "parse-divs"); + }); + + it("Parse two divs", async () => { + const html = `
Single Div
second Div
`; + + await parseHTMLAndCompareSnapshots(html, "parse-two-divs"); + }); + + it("Parse fake image caption", async () => { + const html = `
+ +

Image Caption

+
`; + + await parseHTMLAndCompareSnapshots(html, "parse-fake-image-caption"); + }); + + // TODO: this one fails + it.skip("Parse deep nested content", async () => { + const html = `
+ Outer 1 Div Before +
+ Outer 2 Div Before +
+ Outer 3 Div Before +
+ Outer 4 Div Before +

Heading 1

+

Heading 2

+

Heading 3

+

Paragraph

+
Image Caption
+

Bold Italic Underline Strikethrough All

+ Outer 4 Div After +
+ Outer 3 Div After +
+ Outer 2 Div After +
+ Outer 1 Div After +
`; + + await parseHTMLAndCompareSnapshots(html, "parse-deep-nested-content"); + }); + + it("Parse div with inline content and nested blocks", async () => { + const html = `
+ None Bold Italic Underline Strikethrough All +
Nested Div
+

Nested Paragraph

+
`; + + await parseHTMLAndCompareSnapshots(html, "parse-div-with-inline-content"); + }); +}); diff --git a/packages/core/src/api/parsers/html/parseHTML.ts b/packages/core/src/api/parsers/html/parseHTML.ts new file mode 100644 index 0000000000..cf4e983248 --- /dev/null +++ b/packages/core/src/api/parsers/html/parseHTML.ts @@ -0,0 +1,36 @@ +import { DOMParser, Schema } from "prosemirror-model"; +import { Block, BlockSchema, nodeToBlock } from "../../.."; +import { InlineContentSchema } from "../../../extensions/Blocks/api/inlineContent/types"; +import { StyleSchema } from "../../../extensions/Blocks/api/styles/types"; +import { nestedListsToBlockNoteStructure } from "./util/nestedLists"; + +export async function HTMLToBlocks< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + html: string, + blockSchema: BSchema, + icSchema: I, + styleSchema: S, + pmSchema: Schema +): Promise[]> { + const htmlNode = nestedListsToBlockNoteStructure(html); + const parser = DOMParser.fromSchema(pmSchema); + + // const doc = pmSchema.nodes["doc"].createAndFill()!; + + const parentNode = parser.parse(htmlNode, { + topNode: pmSchema.nodes["blockGroup"].create(), + // context: doc.resolve(3), + }); //, { preserveWhitespace: "full" }); + const blocks: Block[] = []; + + for (let i = 0; i < parentNode.childCount; i++) { + blocks.push( + nodeToBlock(parentNode.child(i), blockSchema, icSchema, styleSchema) + ); + } + + return blocks; +} diff --git a/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap b/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap new file mode 100644 index 0000000000..d697b8db72 --- /dev/null +++ b/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap @@ -0,0 +1,129 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Lift nested lists > Lifts multiple bullet lists 1`] = ` +" +
    +
    +
  • Bullet List Item 1
  • +
    +
      +
    • Nested Bullet List Item 1
    • +
    • Nested Bullet List Item 2
    • +
    +
      +
    • Nested Bullet List Item 3
    • +
    • Nested Bullet List Item 4
    • +
    +
    +
    +
  • Bullet List Item 2
  • +
+" +`; + +exports[`Lift nested lists > Lifts multiple bullet lists with content in between 1`] = ` +" +
    +
    +
  • Bullet List Item 1
  • +
    +
      +
    • Nested Bullet List Item 1
    • +
    • Nested Bullet List Item 2
    • +
    +
    +
    +
    +
  • In between content
  • +
    +
      +
    • Nested Bullet List Item 3
    • +
    • Nested Bullet List Item 4
    • +
    +
    +
    +
  • Bullet List Item 2
  • +
+" +`; + +exports[`Lift nested lists > Lifts nested bullet lists 1`] = ` +" +
    +
    +
  • Bullet List Item 1
  • +
    +
      +
    • Nested Bullet List Item 1
    • +
    • Nested Bullet List Item 2
    • +
    +
    +
    +
  • Bullet List Item 2
  • +
+" +`; + +exports[`Lift nested lists > Lifts nested bullet lists with content after nested list 1`] = ` +" +
    +
    +
  • Bullet List Item 1
  • +
    +
      +
    • Nested Bullet List Item 1
    • +
    • Nested Bullet List Item 2
    • +
    +
    +
    +
  • More content in list item 1
  • +
  • Bullet List Item 2
  • +
+" +`; + +exports[`Lift nested lists > Lifts nested bullet lists without li 1`] = ` +" +
    Bullet List Item 1 +
      +
    • Nested Bullet List Item 1
    • +
    • Nested Bullet List Item 2
    • +
    +
  • Bullet List Item 2
  • +
+" +`; + +exports[`Lift nested lists > Lifts nested mixed lists 1`] = ` +" +
    +
    +
  1. Numbered List Item 1
  2. +
    +
      +
    • Bullet List Item 1
    • +
    • Bullet List Item 2
    • +
    +
    +
    +
  3. Numbered List Item 2
  4. +
+" +`; + +exports[`Lift nested lists > Lifts nested numbered lists 1`] = ` +" +
    +
    +
  1. Numbered List Item 1
  2. +
    +
      +
    1. Nested Numbered List Item 1
    2. +
    3. Nested Numbered List Item 2
    4. +
    +
    +
    +
  3. Numbered List Item 2
  4. +
+" +`; diff --git a/packages/core/src/api/parsers/html/util/nestedLists.test.ts b/packages/core/src/api/parsers/html/util/nestedLists.test.ts new file mode 100644 index 0000000000..96b0e1e9d2 --- /dev/null +++ b/packages/core/src/api/parsers/html/util/nestedLists.test.ts @@ -0,0 +1,176 @@ +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 htmlNode = nestedListsToBlockNoteStructure(html); + + const pretty = await unified() + .use(rehypeParse, { fragment: true }) + .use(rehypeFormat) + .use(rehypeStringify) + .process(htmlNode.innerHTML); + + expect(pretty.value).toMatchSnapshot(); +} + +describe("Lift nested lists", () => { + it("Lifts nested bullet lists", async () => { + const html = `
    +
  • + Bullet List Item 1 +
      +
    • + Nested Bullet List Item 1 +
    • +
    • + Nested Bullet List Item 2 +
    • +
    +
  • +
  • + Bullet List Item 2 +
  • +
`; + await testHTML(html); + }); + + it("Lifts nested bullet lists without li", async () => { + const html = `
    + Bullet List Item 1 +
      +
    • + Nested Bullet List Item 1 +
    • +
    • + Nested Bullet List Item 2 +
    • +
    +
  • + Bullet List Item 2 +
  • +
`; + await testHTML(html); + }); + + it("Lifts nested bullet lists with content after nested list", async () => { + const html = `
    +
  • + Bullet List Item 1 +
      +
    • + Nested Bullet List Item 1 +
    • +
    • + Nested Bullet List Item 2 +
    • +
    + More content in list item 1 +
  • +
  • + Bullet List Item 2 +
  • +
`; + await testHTML(html); + }); + + it("Lifts multiple bullet lists", async () => { + const html = `
    +
  • + Bullet List Item 1 +
      +
    • + Nested Bullet List Item 1 +
    • +
    • + Nested Bullet List Item 2 +
    • +
    +
      +
    • + Nested Bullet List Item 3 +
    • +
    • + Nested Bullet List Item 4 +
    • +
    +
  • +
  • + Bullet List Item 2 +
  • +
`; + await testHTML(html); + }); + + it("Lifts multiple bullet lists with content in between", async () => { + const html = `
    +
  • + Bullet List Item 1 +
      +
    • + Nested Bullet List Item 1 +
    • +
    • + Nested Bullet List Item 2 +
    • +
    + In between content +
      +
    • + Nested Bullet List Item 3 +
    • +
    • + Nested Bullet List Item 4 +
    • +
    +
  • +
  • + Bullet List Item 2 +
  • +
`; + await testHTML(html); + }); + + it("Lifts nested numbered lists", async () => { + const html = `
    +
  1. + Numbered List Item 1 +
      +
    1. + Nested Numbered List Item 1 +
    2. +
    3. + Nested Numbered List Item 2 +
    4. +
    +
  2. +
  3. + Numbered List Item 2 +
  4. +
`; + await testHTML(html); + }); + + it("Lifts nested mixed lists", async () => { + const html = `
    +
  1. + Numbered List Item 1 +
      +
    • + Bullet List Item 1 +
    • +
    • + Bullet List Item 2 +
    • +
    +
  2. +
  3. + Numbered List Item 2 +
  4. +
`; + await testHTML(html); + }); +}); diff --git a/packages/core/src/api/parsers/html/util/nestedLists.ts b/packages/core/src/api/parsers/html/util/nestedLists.ts new file mode 100644 index 0000000000..78c60b2a1a --- /dev/null +++ b/packages/core/src/api/parsers/html/util/nestedLists.ts @@ -0,0 +1,113 @@ +function getChildIndex(node: Element) { + return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); +} + +function isWhitespaceNode(node: Node) { + return node.nodeType === 3 && !/\S/.test(node.nodeValue || ""); +} + +/** + * Step 1, Turns: + * + *
    + *
  • item
  • + *
  • + *
      + *
    • ...
    • + *
    • ...
    • + *
    + *
  • + * + * Into: + *
      + *
    • item
    • + *
        + *
      • ...
      • + *
      • ...
      • + *
      + *
    + * + */ +function liftNestedListsToParent(element: HTMLElement) { + element.querySelectorAll("li > ul, li > ol").forEach((list) => { + const index = getChildIndex(list); + const parentListItem = list.parentElement!; + const siblingsAfter = Array.from(parentListItem.childNodes).slice( + index + 1 + ); + list.remove(); + siblingsAfter.forEach((sibling) => { + sibling.remove(); + }); + + parentListItem.insertAdjacentElement("afterend", list); + + siblingsAfter.reverse().forEach((sibling) => { + if (isWhitespaceNode(sibling)) { + return; + } + const siblingContainer = document.createElement("li"); + siblingContainer.append(sibling); + list.insertAdjacentElement("afterend", siblingContainer); + }); + if (parentListItem.childNodes.length === 0) { + parentListItem.remove(); + } + }); +} + +/** + * Step 2, Turns (output of liftNestedListsToParent): + * + *
  • item
  • + *
      + *
    • ...
    • + *
    • ...
    • + *
    + * + * Into: + *
    + *
  • item
  • + *
    + *
      + *
    • ...
    • + *
    • ...
    • + *
    + *
    + *
    + * + * This resulting format is parsed + */ +function createGroups(element: HTMLElement) { + element.querySelectorAll("li + ul, li + ol").forEach((list) => { + const listItem = list.previousElementSibling as HTMLElement; + const blockContainer = document.createElement("div"); + + listItem.insertAdjacentElement("afterend", blockContainer); + blockContainer.append(listItem); + + const blockGroup = document.createElement("div"); + blockGroup.setAttribute("data-node-type", "blockGroup"); + blockContainer.append(blockGroup); + + while ( + blockContainer.nextElementSibling?.nodeName === "UL" || + blockContainer.nextElementSibling?.nodeName === "OL" + ) { + blockGroup.append(blockContainer.nextElementSibling); + } + }); +} + +export function nestedListsToBlockNoteStructure( + elementOrHTML: HTMLElement | string +) { + if (typeof elementOrHTML === "string") { + const element = document.createElement("div"); + element.innerHTML = elementOrHTML; + elementOrHTML = element; + } + liftNestedListsToParent(elementOrHTML); + createGroups(elementOrHTML); + return elementOrHTML; +} diff --git a/packages/core/src/api/parsers/markdown/parseMarkdown.ts b/packages/core/src/api/parsers/markdown/parseMarkdown.ts new file mode 100644 index 0000000000..f81cb7a0b3 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/parseMarkdown.ts @@ -0,0 +1,80 @@ +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, BlockSchema, InlineContentSchema, StyleSchema } from "../../.."; +import { HTMLToBlocks } from "../html/parseHTML"; + +// modified version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js +// that outputs a data-language attribute instead of a CSS class (e.g.: language-typescript) +function code(state: any, node: any) { + const value = node.value ? node.value + "\n" : ""; + /** @type {Properties} */ + const properties: any = {}; + + if (node.lang) { + // changed line + properties["data-language"] = node.lang; + } + + // Create ``. + /** @type {Element} */ + let result: any = { + type: "element", + tagName: "code", + properties, + children: [{ type: "text", value }], + }; + + if (node.meta) { + result.data = { meta: node.meta }; + } + + state.patch(node, result); + result = state.applyData(node, result); + + // Create `
    `.
    +  result = {
    +    type: "element",
    +    tagName: "pre",
    +    properties: {},
    +    children: [result],
    +  };
    +  state.patch(node, result);
    +  return result;
    +}
    +
    +// TODO: add tests
    +export function markdownToBlocks<
    +  BSchema extends BlockSchema,
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  markdown: string,
    +  blockSchema: BSchema,
    +  icSchema: I,
    +  styleSchema: S,
    +  pmSchema: Schema
    +): Promise[]> {
    +  const htmlString = unified()
    +    .use(remarkParse)
    +    .use(remarkGfm)
    +    .use(remarkRehype, {
    +      handlers: {
    +        ...(defaultHandlers as any),
    +        code,
    +      },
    +    })
    +    .use(rehypeStringify)
    +    .processSync(markdown);
    +
    +  return HTMLToBlocks(
    +    htmlString.value as string,
    +    blockSchema,
    +    icSchema,
    +    styleSchema,
    +    pmSchema
    +  );
    +}
    diff --git a/packages/core/src/api/parsers/pasteExtension.ts b/packages/core/src/api/parsers/pasteExtension.ts
    new file mode 100644
    index 0000000000..f0dec4f86d
    --- /dev/null
    +++ b/packages/core/src/api/parsers/pasteExtension.ts
    @@ -0,0 +1,61 @@
    +import { Extension } from "@tiptap/core";
    +import { Plugin } from "prosemirror-state";
    +
    +import { BlockNoteEditor } from "../../BlockNoteEditor";
    +import { BlockSchema } from "../../extensions/Blocks/api/blocks/types";
    +import { InlineContentSchema } from "../../extensions/Blocks/api/inlineContent/types";
    +import { StyleSchema } from "../../extensions/Blocks/api/styles/types";
    +import { nestedListsToBlockNoteStructure } from "./html/util/nestedLists";
    +
    +const acceptedMIMETypes = [
    +  "blocknote/html",
    +  "text/html",
    +  "text/plain",
    +] as const;
    +
    +export const createPasteFromClipboardExtension = <
    +  BSchema extends BlockSchema,
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  editor: BlockNoteEditor
    +) =>
    +  Extension.create<{ editor: BlockNoteEditor }, undefined>({
    +    name: "pasteFromClipboard",
    +    addProseMirrorPlugins() {
    +      return [
    +        new Plugin({
    +          props: {
    +            handleDOMEvents: {
    +              paste(_view, event) {
    +                event.preventDefault();
    +                let format: (typeof acceptedMIMETypes)[number] | null = null;
    +
    +                for (const mimeType of acceptedMIMETypes) {
    +                  if (event.clipboardData!.types.includes(mimeType)) {
    +                    format = mimeType;
    +                    break;
    +                  }
    +                }
    +
    +                if (format !== null) {
    +                  let data = event.clipboardData!.getData(format);
    +                  if (format === "text/html") {
    +                    const htmlNode = nestedListsToBlockNoteStructure(
    +                      data.trim()
    +                    );
    +
    +                    data = htmlNode.innerHTML;
    +                    console.log(data);
    +                  }
    +                  editor._tiptapEditor.view.pasteHTML(data);
    +                }
    +
    +                return true;
    +              },
    +            },
    +          },
    +        }),
    +      ];
    +    },
    +  });
    diff --git a/packages/core/src/api/serialization/html/__snapshots__/fontSize/basic/external.html b/packages/core/src/api/serialization/html/__snapshots__/fontSize/basic/external.html
    deleted file mode 100644
    index 4c7e8f174d..0000000000
    --- a/packages/core/src/api/serialization/html/__snapshots__/fontSize/basic/external.html
    +++ /dev/null
    @@ -1 +0,0 @@
    -

    This is text with a custom fontSize

    \ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/mention/basic/external.html b/packages/core/src/api/serialization/html/__snapshots__/mention/basic/external.html deleted file mode 100644 index e1513fed2d..0000000000 --- a/packages/core/src/api/serialization/html/__snapshots__/mention/basic/external.html +++ /dev/null @@ -1 +0,0 @@ -

    I enjoy working with@Matthew

    \ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/small/basic/external.html b/packages/core/src/api/serialization/html/__snapshots__/small/basic/external.html deleted file mode 100644 index 4206d07a95..0000000000 --- a/packages/core/src/api/serialization/html/__snapshots__/small/basic/external.html +++ /dev/null @@ -1 +0,0 @@ -

    This is a small text

    \ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/tag/basic/external.html b/packages/core/src/api/serialization/html/__snapshots__/tag/basic/external.html deleted file mode 100644 index 4229ae0a83..0000000000 --- a/packages/core/src/api/serialization/html/__snapshots__/tag/basic/external.html +++ /dev/null @@ -1 +0,0 @@ -

    I love #BlockNote

    \ No newline at end of file diff --git a/packages/core/src/api/testCases/cases/customInlineContent.ts b/packages/core/src/api/testCases/cases/customInlineContent.ts index 13e4bb6e2d..a1603f4a87 100644 --- a/packages/core/src/api/testCases/cases/customInlineContent.ts +++ b/packages/core/src/api/testCases/cases/customInlineContent.ts @@ -68,7 +68,7 @@ export const customInlineContentTestCases: EditorTestCases< InlineContentSchemaFromSpecs, DefaultStyleSchema > = { - name: "custom style schema", + name: "custom inline content schema", createEditor: () => { return BlockNoteEditor.create({ uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, diff --git a/packages/core/src/api/testCases/cases/defaultSchema.ts b/packages/core/src/api/testCases/cases/defaultSchema.ts index bb7ccf4526..87aa6b01b1 100644 --- a/packages/core/src/api/testCases/cases/defaultSchema.ts +++ b/packages/core/src/api/testCases/cases/defaultSchema.ts @@ -24,7 +24,7 @@ export const defaultSchemaTestCases: EditorTestCases< name: "paragraph/empty", blocks: [ { - type: "paragraph" as const, + type: "paragraph", }, ], }, @@ -32,7 +32,7 @@ export const defaultSchemaTestCases: EditorTestCases< name: "paragraph/basic", blocks: [ { - type: "paragraph" as const, + type: "paragraph", content: "Paragraph", }, ], @@ -41,12 +41,12 @@ export const defaultSchemaTestCases: EditorTestCases< name: "paragraph/styled", blocks: [ { - type: "paragraph" as const, + type: "paragraph", props: { textAlignment: "center", textColor: "orange", backgroundColor: "pink", - } as const, + }, content: [ { type: "text", @@ -83,15 +83,15 @@ export const defaultSchemaTestCases: EditorTestCases< name: "paragraph/nested", blocks: [ { - type: "paragraph" as const, + type: "paragraph", content: "Paragraph", children: [ { - type: "paragraph" as const, + type: "paragraph", content: "Nested Paragraph 1", }, { - type: "paragraph" as const, + type: "paragraph", content: "Nested Paragraph 2", }, ], @@ -102,7 +102,7 @@ export const defaultSchemaTestCases: EditorTestCases< name: "image/button", blocks: [ { - type: "image" as const, + type: "image", }, ], }, @@ -110,7 +110,7 @@ export const defaultSchemaTestCases: EditorTestCases< name: "image/basic", blocks: [ { - type: "image" as const, + type: "image", props: { url: "exampleURL", caption: "Caption", @@ -123,20 +123,20 @@ export const defaultSchemaTestCases: EditorTestCases< name: "image/nested", blocks: [ { - type: "image" as const, + type: "image", props: { url: "exampleURL", caption: "Caption", width: 256, - } as const, + }, children: [ { - type: "image" as const, + type: "image", props: { url: "exampleURL", caption: "Caption", width: 256, - } as const, + }, }, ], }, diff --git a/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts index 81e6d370c9..18b0d780f4 100644 --- a/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts +++ b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts @@ -1,3 +1,4 @@ +import { ParseRule } from "@tiptap/pm/model"; import { BlockNoteEditor } from "../../../../BlockNoteEditor"; import { InlineContentSchema } from "../inlineContent/types"; import { StyleSchema } from "../styles/types"; @@ -5,11 +6,15 @@ import { createInternalBlockSpec, createStronglyTypedTiptapNode, getBlockFromPos, - parse, propsToAttributes, wrapInBlockStructure, } from "./internal"; -import { BlockConfig, BlockFromConfig, BlockSchemaWithBlock } from "./types"; +import { + BlockConfig, + BlockFromConfig, + BlockSchemaWithBlock, + PartialBlockFromConfig, +} from "./types"; // restrict content to "inline" and "none" only export type CustomBlockConfig = BlockConfig & { @@ -50,8 +55,60 @@ export type CustomBlockImplementation< dom: HTMLElement; contentDOM?: HTMLElement; }; + + parse?: (el: HTMLElement) => PartialBlockFromConfig | undefined; }; +// Function that uses the 'parse' function of a blockConfig to create a +// TipTap node's `parseHTML` property. This is only used for parsing content +// from the clipboard. +export function getParseRules( + config: BlockConfig, + customParseFunction: CustomBlockImplementation["parse"] +) { + const rules: ParseRule[] = [ + { + tag: "div[data-content-type=" + config.type + "]", + }, + ]; + + if (customParseFunction) { + rules.push({ + tag: "*", + getAttrs(node: string | HTMLElement) { + if (typeof node === "string") { + return false; + } + + const block = customParseFunction?.(node); + + if (block === undefined) { + return false; + } + + return block.props || {}; + }, + }); + } + // getContent(node, schema) { + // const block = blockConfig.parse?.(node as HTMLElement); + // + // if (block !== undefined && block.content !== undefined) { + // return Fragment.from( + // typeof block.content === "string" + // ? schema.text(block.content) + // : inlineContentToNodes(block.content, schema) + // ); + // } + // + // return Fragment.empty; + // }, + // }); + // } + + return rules; +} + // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead export function createBlockSpec< @@ -72,7 +129,7 @@ export function createBlockSpec< }, parseHTML() { - return parse(blockConfig); + return getParseRules(blockConfig, blockImplementation.parse); }, addNodeView() { diff --git a/packages/core/src/extensions/Blocks/api/blocks/internal.ts b/packages/core/src/extensions/Blocks/api/blocks/internal.ts index 34e39c6ad2..58f8b84d50 100644 --- a/packages/core/src/extensions/Blocks/api/blocks/internal.ts +++ b/packages/core/src/extensions/Blocks/api/blocks/internal.ts @@ -1,5 +1,11 @@ -import { Attribute, Attributes, Editor, Node, NodeConfig } from "@tiptap/core"; -import { ParseRule } from "prosemirror-model"; +import { + Attribute, + Attributes, + Editor, + Extension, + Node, + NodeConfig, +} from "@tiptap/core"; import { BlockNoteEditor } from "../../../../BlockNoteEditor"; import { mergeCSSClasses } from "../../../../shared/utils"; import { defaultBlockToHTML } from "../../nodes/BlockContent/defaultBlockHelpers"; @@ -82,51 +88,6 @@ export function propsToAttributes(propSchema: PropSchema): Attributes { return tiptapAttributes; } -// Function that uses the 'parse' function of a blockConfig to create a -// TipTap node's `parseHTML` property. This is only used for parsing content -// from the clipboard. -export function parse(blockConfig: BlockConfig) { - const rules: ParseRule[] = [ - { - tag: "div[data-content-type=" + blockConfig.type + "]", - }, - ]; - - // if (blockConfig.parse) { - // rules.push({ - // tag: "*", - // getAttrs(node: string | HTMLElement) { - // if (typeof node === "string") { - // return false; - // } - // - // const block = blockConfig.parse?.(node); - // - // if (block === undefined) { - // return false; - // } - // - // return block.props || {}; - // }, - // getContent(node, schema) { - // const block = blockConfig.parse?.(node as HTMLElement); - // - // if (block !== undefined && block.content !== undefined) { - // return Fragment.from( - // typeof block.content === "string" - // ? schema.text(block.content) - // : inlineContentToNodes(block.content, schema) - // ); - // } - // - // return Fragment.empty; - // }, - // }); - // } - - return rules; -} - // Used to figure out which block should be rendered. This block is then used to // create the node view. export function getBlockFromPos< @@ -263,7 +224,7 @@ export function createInternalBlockSpec( export function createBlockSpecFromStronglyTypedTiptapNode< T extends Node, P extends PropSchema ->(node: T, propSchema: P, requiredNodes?: Node[]) { +>(node: T, propSchema: P, requiredExtensions?: Array) { return createInternalBlockSpec( { type: node.name as T["name"], @@ -280,9 +241,10 @@ export function createBlockSpecFromStronglyTypedTiptapNode< }, { node, - requiredNodes, + requiredExtensions, toInternalHTML: defaultBlockToHTML, toExternalHTML: defaultBlockToHTML, + // parse: () => undefined, // parse rules are in node already } ); } diff --git a/packages/core/src/extensions/Blocks/api/blocks/types.ts b/packages/core/src/extensions/Blocks/api/blocks/types.ts index c16e723e6b..e98453b41a 100644 --- a/packages/core/src/extensions/Blocks/api/blocks/types.ts +++ b/packages/core/src/extensions/Blocks/api/blocks/types.ts @@ -1,5 +1,5 @@ /** Define the main block types **/ -import { Node } from "@tiptap/core"; +import { Extension, Node } from "@tiptap/core"; import { BlockNoteEditor } from "../../../../BlockNoteEditor"; import { @@ -69,7 +69,7 @@ export type TiptapBlockImplementation< I extends InlineContentSchema, S extends StyleSchema > = { - requiredNodes?: Node[]; + requiredExtensions?: Array; node: Node; toInternalHTML: ( block: BlockFromConfigNoChildren & { @@ -273,4 +273,12 @@ export type SpecificPartialBlock< children?: Block[]; }; +export type PartialBlockFromConfig< + B extends BlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = PartialBlockFromConfigNoChildren & { + children?: Block[]; +}; + export type BlockIdentifier = { id: string } | string; diff --git a/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts b/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts index 7e68f51cbb..6b6bff26a4 100644 --- a/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts +++ b/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts @@ -1,8 +1,13 @@ import { Node } from "@tiptap/core"; +import { ParseRule } from "@tiptap/pm/model"; import { nodeToCustomInlineContent } from "../../../../api/nodeConversions/nodeConversions"; import { propsToAttributes } from "../blocks/internal"; +import { Props } from "../blocks/types"; import { StyleSchema } from "../styles/types"; -import { createInlineContentSpecFromTipTapNode } from "./internal"; +import { + addInlineContentAttributes, + createInlineContentSpecFromTipTapNode, +} from "./internal"; import { CustomInlineContentConfig, InlineContentConfig, @@ -38,6 +43,16 @@ export type CustomInlineContentImplementation< }; }; +export function getInlineContentParseRules( + config: InlineContentConfig +): ParseRule[] { + return [ + { + tag: `.bn-inline-content-section[data-inline-content-type="${config.type}"]`, + }, + ]; +} + export function createInlineContentSpec< T extends CustomInlineContentConfig, S extends StyleSchema @@ -58,6 +73,10 @@ export function createInlineContentSpec< return propsToAttributes(inlineContentConfig.propSchema); }, + parseHTML() { + return getInlineContentParseRules(inlineContentConfig); + }, + renderHTML({ node }) { const editor = this.options.editor; @@ -69,7 +88,15 @@ export function createInlineContentSpec< ) as any as InlineContentFromConfig // TODO: fix cast ); - return output; + return { + dom: addInlineContentAttributes( + output.dom, + inlineContentConfig.type, + node.attrs as Props, + inlineContentConfig.propSchema + ), + contentDOM: output.contentDOM, + }; }, }); diff --git a/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts b/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts index 9c623c44cf..d081338be8 100644 --- a/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts +++ b/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts @@ -1,5 +1,6 @@ import { Node } from "@tiptap/core"; -import { PropSchema } from "../blocks/types"; +import { camelToDataKebab } from "../blocks/internal"; +import { Props, PropSchema } from "../blocks/types"; import { InlineContentConfig, InlineContentImplementation, @@ -7,6 +8,38 @@ import { InlineContentSpec, InlineContentSpecs, } from "./types"; +import { mergeCSSClasses } from "../../../../shared/utils"; + +// Function that adds necessary classes and attributes to the `dom` element +// returned from a custom inline content's 'render' function, to ensure no data +// is lost on internal copy & paste. +export function addInlineContentAttributes< + IType extends string, + PSchema extends PropSchema +>( + element: HTMLElement, + inlineContentType: IType, + inlineContentProps: Props, + propSchema: PSchema +): HTMLElement { + // Sets inline content section class + element.className = mergeCSSClasses( + "bn-inline-content-section", + element.className + ); + // Sets content type attribute + element.setAttribute("data-inline-content-type", inlineContentType); + // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props + // set to their default values. + Object.entries(inlineContentProps) + .filter(([prop, value]) => value !== propSchema[prop].default) + .map(([prop, value]) => { + return [camelToDataKebab(prop), value]; + }) + .forEach(([prop, value]) => element.setAttribute(prop, value)); + + return element; +} // This helper function helps to instantiate a InlineContentSpec with a // config and implementation that conform to the type of Config diff --git a/packages/core/src/extensions/Blocks/api/styles/createSpec.ts b/packages/core/src/extensions/Blocks/api/styles/createSpec.ts index 9f0d742f75..14c1c2274f 100644 --- a/packages/core/src/extensions/Blocks/api/styles/createSpec.ts +++ b/packages/core/src/extensions/Blocks/api/styles/createSpec.ts @@ -1,6 +1,11 @@ import { Mark } from "@tiptap/core"; +import { ParseRule } from "@tiptap/pm/model"; import { UnreachableCaseError } from "../../../../shared/utils"; -import { createInternalStyleSpec } from "./internal"; +import { + addStyleAttributes, + createInternalStyleSpec, + stylePropsToAttributes, +} from "./internal"; import { StyleConfig, StyleSpec } from "./types"; export type CustomStyleImplementation = { @@ -17,6 +22,14 @@ export type CustomStyleImplementation = { // TODO: support serialization +export function getStyleParseRules(config: StyleConfig): ParseRule[] { + return [ + { + tag: `.bn-style[data-style-type="${config.type}"]`, + }, + ]; +} + export function createStyleSpec( styleConfig: T, styleImplementation: CustomStyleImplementation @@ -25,21 +38,11 @@ export function createStyleSpec( name: styleConfig.type, addAttributes() { - if (styleConfig.propSchema === "boolean") { - return {}; - } - return { - stringValue: { - default: undefined, - // TODO: parsing + return stylePropsToAttributes(styleConfig.propSchema); + }, - // parseHTML: (element) => - // element.getAttribute(`data-${styleConfig.type}`), - // renderHTML: (attributes) => ({ - // [`data-${styleConfig.type}`]: attributes.stringValue, - // }), - }, - }; + parseHTML() { + return getStyleParseRules(styleConfig); }, renderHTML({ mark }) { @@ -58,7 +61,15 @@ export function createStyleSpec( } // const renderResult = styleImplementation.render(); - return renderResult; + return { + dom: addStyleAttributes( + renderResult.dom, + styleConfig.type, + mark.attrs.stringValue, + styleConfig.propSchema + ), + contentDOM: renderResult.contentDOM, + }; }, }); diff --git a/packages/core/src/extensions/Blocks/api/styles/internal.ts b/packages/core/src/extensions/Blocks/api/styles/internal.ts index 648bb133d5..27b32a3f7a 100644 --- a/packages/core/src/extensions/Blocks/api/styles/internal.ts +++ b/packages/core/src/extensions/Blocks/api/styles/internal.ts @@ -1,4 +1,4 @@ -import { Mark } from "@tiptap/core"; +import { Attributes, Mark } from "@tiptap/core"; import { StyleConfig, StyleImplementation, @@ -7,6 +7,53 @@ import { StyleSpec, StyleSpecs, } from "./types"; +import { mergeCSSClasses } from "../../../../shared/utils"; + +export function stylePropsToAttributes( + propSchema: StylePropSchema +): Attributes { + if (propSchema === "boolean") { + return {}; + } + return { + stringValue: { + default: undefined, + keepOnSplit: true, + parseHTML: (element) => element.getAttribute("data-value"), + renderHTML: (attributes) => + attributes.stringValue !== undefined + ? { + "data-value": attributes.stringValue, + } + : {}, + }, + }; +} + +// Function that adds necessary classes and attributes to the `dom` element +// returned from a custom style's 'render' function, to ensure no data is lost +// on internal copy & paste. +export function addStyleAttributes< + SType extends string, + PSchema extends StylePropSchema +>( + element: HTMLElement, + styleType: SType, + styleValue: PSchema extends "boolean" ? undefined : string, + propSchema: PSchema +): HTMLElement { + // Sets inline content section class + element.className = mergeCSSClasses("bn-style", element.className); + // Sets content type attribute + element.setAttribute("data-style-type", styleType); + // Adds style value as an HTML attribute in kebab-case with "data-" prefix, if + // the style takes a string value. + if (propSchema === "string") { + element.setAttribute("data-value", styleValue as string); + } + + return element; +} // This helper function helps to instantiate a stylespec with a // config and implementation that conform to the type of Config diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index 443cc7d7fd..bba83b4308 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -483,13 +483,12 @@ export const BlockContainer = Node.create<{ // Reverts block content type to a paragraph if the selection is at the start of the block. () => commands.command(({ state }) => { - const { contentType } = getBlockInfoFromPos( + const { contentType, startPos } = getBlockInfoFromPos( state.doc, state.selection.from )!; - const selectionAtBlockStart = - state.selection.$anchor.parentOffset === 0; + const selectionAtBlockStart = state.selection.from === startPos + 1; const isParagraph = contentType.name === "paragraph"; if (selectionAtBlockStart && !isParagraph) { @@ -504,8 +503,12 @@ export const BlockContainer = Node.create<{ // Removes a level of nesting if the block is indented if the selection is at the start of the block. () => commands.command(({ state }) => { - const selectionAtBlockStart = - state.selection.$anchor.parentOffset === 0; + const { startPos } = getBlockInfoFromPos( + state.doc, + state.selection.from + )!; + + const selectionAtBlockStart = state.selection.from === startPos + 1; if (selectionAtBlockStart) { return commands.liftListItem("blockContainer"); @@ -522,10 +525,8 @@ export const BlockContainer = Node.create<{ state.selection.from )!; - const selectionAtBlockStart = - state.selection.$anchor.parentOffset === 0; - const selectionEmpty = - state.selection.anchor === state.selection.head; + const selectionAtBlockStart = state.selection.from === startPos + 1; + const selectionEmpty = state.selection.empty; const blockAtDocStart = startPos === 2; const posBetweenBlocks = startPos - 1; @@ -552,17 +553,14 @@ export const BlockContainer = Node.create<{ // end of the block. () => commands.command(({ state }) => { - const { node, contentNode, depth, endPos } = getBlockInfoFromPos( + const { node, depth, endPos } = getBlockInfoFromPos( state.doc, state.selection.from )!; const blockAtDocEnd = false; - const selectionAtBlockEnd = - state.selection.$anchor.parentOffset === - contentNode.firstChild!.nodeSize; - const selectionEmpty = - state.selection.anchor === state.selection.head; + const selectionAtBlockEnd = state.selection.from === endPos - 1; + const selectionEmpty = state.selection.empty; const hasChildBlocks = node.childCount === 2; if ( diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts index 3cfbea0518..50a0b74197 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts @@ -21,7 +21,14 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({ level: { default: 1, // instead of "level" attributes, use "data-level" - parseHTML: (element) => element.getAttribute("data-level")!, + parseHTML: (element) => { + const attr = element.getAttribute("data-level")!; + const parsed = parseInt(attr); + if (isFinite(parsed)) { + return parsed; + } + return undefined; + }, renderHTML: (attributes) => { return { "data-level": (attributes.level as number).toString(), @@ -78,9 +85,20 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({ }), }; }, - parseHTML() { return [ + { + tag: "div[data-content-type=" + this.name + "]", + getAttrs: (element) => { + if (typeof element === "string") { + return false; + } + + return { + level: element.getAttribute("data-level"), + }; + }, + }, { tag: "h1", attrs: { level: 1 }, diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts index 2fea7b6f71..4a373c03e5 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -371,17 +371,29 @@ export const Image = createBlockSpec( dom: figure, }; }, + parse: (element: HTMLElement) => { + if (element.tagName === "FIGURE") { + const img = element.querySelector("img"); + const caption = element.querySelector("figcaption"); + return { + type: "image", + props: { + url: img?.getAttribute("src") || "", + caption: + caption?.textContent || img?.getAttribute("alt") || undefined, + }, + }; + } else if (element.tagName === "IMG") { + return { + type: "image", + props: { + url: element.getAttribute("src") || "", + caption: element.getAttribute("alt") || undefined, + }, + }; + } + + return undefined; + }, } - // parse: (element) => { - // if (element.tagName === "IMG") { - // return { - // type: "image", - // props: { - // url: element.getAttribute("src") || "", - // }, - // }; - // } - // - // return; - // }, ); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts index 6362288e02..602510ade1 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts @@ -48,6 +48,9 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({ parseHTML() { return [ // Case for regular HTML list structure. + { + tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + }, { tag: "li", getAttrs: (element) => { @@ -61,7 +64,10 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({ return false; } - if (parent.tagName === "UL") { + if ( + parent.tagName === "UL" || + (parent.tagName === "DIV" && parent.parentElement!.tagName === "UL") + ) { return {}; } diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts index 5c838415e0..e8db16998f 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts @@ -66,6 +66,9 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({ parseHTML() { return [ + { + tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + }, // Case for regular HTML list structure. // (e.g.: when pasting from other apps) { @@ -81,7 +84,10 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({ return false; } - if (parent.tagName === "OL") { + if ( + parent.tagName === "OL" || + (parent.tagName === "DIV" && parent.parentElement!.tagName === "OL") + ) { return {}; } diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts index a645ba347c..8c826f413e 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts @@ -15,6 +15,7 @@ export const ParagraphBlockContent = createStronglyTypedTiptapNode({ group: "blockContent", parseHTML() { return [ + { tag: "div[data-content-type=" + this.name + "]" }, { tag: "p", priority: 200, diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts index b3793ccb07..8807586fdc 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts @@ -1,4 +1,4 @@ -import { Paragraph } from "@tiptap/extension-paragraph"; +import { Node, mergeAttributes } from "@tiptap/core"; import { TableCell } from "@tiptap/extension-table-cell"; import { TableHeader } from "@tiptap/extension-table-header"; import { TableRow } from "@tiptap/extension-table-row"; @@ -8,6 +8,7 @@ import { } from "../../../api/blocks/internal"; import { defaultProps } from "../../../api/defaultProps"; import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers"; +import { TableExtension } from "./TableExtension"; export const tablePropSchema = { ...defaultProps, @@ -38,15 +39,28 @@ export const TableBlockContent = createStronglyTypedTiptapNode({ }, }); -const TableParagraph = Paragraph.extend({ +const TableParagraph = Node.create({ name: "tableParagraph", group: "tableContent", + + parseHTML() { + return [{ tag: "p" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "p", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, }); export const Table = createBlockSpecFromStronglyTypedTiptapNode( TableBlockContent, tablePropSchema, [ + TableExtension, TableParagraph, TableHeader.extend({ content: "tableContent", diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index 8f023ad70f..ed87b5df07 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -3,9 +3,9 @@ import { Node } from "prosemirror-model"; import { NodeSelection, Plugin, PluginKey, Selection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { BlockNoteEditor } from "../../BlockNoteEditor"; -import { markdown } from "../../api/formatConversions/formatConversions"; -import { createExternalHTMLExporter } from "../../api/serialization/html/externalHTMLExporter"; -import { createInternalHTMLSerializer } from "../../api/serialization/html/internalHTMLSerializer"; +import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter"; +import { createInternalHTMLSerializer } from "../../api/exporters/html/internalHTMLSerializer"; +import { cleanHTMLToMarkdown } from "../../api/exporters/markdown/markdownExporter"; import { BaseUiElementState } from "../../shared/BaseUiElementTypes"; import { EventEmitter } from "../../shared/EventEmitter"; import { Block, BlockSchema } from "../Blocks/api/blocks/types"; @@ -234,7 +234,7 @@ function dragStart< selectedSlice.content ); - const plainText = markdown(externalHTML); + const plainText = cleanHTMLToMarkdown(externalHTML); e.dataTransfer.clearData(); e.dataTransfer.setData("blocknote/html", internalHTML); diff --git a/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts b/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts index 4535866dad..7f9fb505ea 100644 --- a/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts +++ b/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts @@ -12,7 +12,9 @@ export const TextAlignmentExtension = Extension.create({ attributes: { textAlignment: { default: "left", - parseHTML: (element) => element.getAttribute("data-text-alignment"), + parseHTML: (element) => { + return element.getAttribute("data-text-alignment"); + }, renderHTML: (attributes) => attributes.textAlignment !== "left" && { "data-text-alignment": attributes.textAlignment, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 81d2c288a8..41637442bb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,7 @@ export * from "./BlockNoteEditor"; export * from "./BlockNoteExtensions"; -export * from "./api/serialization/html/externalHTMLExporter"; -export * from "./api/serialization/html/internalHTMLSerializer"; +export * from "./api/exporters/html/externalHTMLExporter"; +export * from "./api/exporters/html/internalHTMLSerializer"; export * from "./api/testCases/index"; export * from "./extensions/Blocks/api/blocks/createSpec"; export * from "./extensions/Blocks/api/blocks/internal"; diff --git a/packages/react/src/BlockNoteView.tsx b/packages/react/src/BlockNoteView.tsx index 7a126b8f48..c65cbc9b26 100644 --- a/packages/react/src/BlockNoteView.tsx +++ b/packages/react/src/BlockNoteView.tsx @@ -47,7 +47,9 @@ function BaseBlockNoteView< - + {props.editor.blockSchema.table && ( + + )} )} diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index e23077b998..b5f48dc2df 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -1,6 +1,5 @@ import { BlockFromConfig, - BlockNoteDOMAttributes, BlockNoteEditor, BlockSchemaWithBlock, camelToDataKebab, @@ -8,10 +7,11 @@ import { createStronglyTypedTiptapNode, CustomBlockConfig, getBlockFromPos, + getParseRules, inheritedProps, InlineContentSchema, mergeCSSClasses, - parse, + PartialBlockFromConfig, Props, PropSchema, propsToAttributes, @@ -23,8 +23,8 @@ import { NodeViewWrapper, ReactNodeViewRenderer, } from "@tiptap/react"; -import { createContext, ElementType, FC, HTMLProps, useContext } from "react"; -import { renderToString } from "react-dom/server"; +import { FC } from "react"; +import { renderToDOMSpec } from "./ReactRenderUtil"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks @@ -37,38 +37,14 @@ export type ReactCustomBlockImplementation< render: FC<{ block: BlockFromConfig; editor: BlockNoteEditor, I, S>; + contentRef: (node: HTMLElement | null) => void; }>; toExternalHTML?: FC<{ block: BlockFromConfig; editor: BlockNoteEditor, I, S>; + contentRef: (node: HTMLElement | null) => void; }>; -}; - -const BlockNoteDOMAttributesContext = createContext({}); - -export const InlineContent = ( - props: { as?: Tag } & HTMLProps -) => { - const inlineContentDOMAttributes = - useContext(BlockNoteDOMAttributesContext).inlineContent || {}; - - const classNames = mergeCSSClasses( - props.className || "", - "bn-inline-content", - inlineContentDOMAttributes.class - ); - - return ( - key !== "class" - ) - )} - {...props} - className={classNames} - /> - ); + parse?: (el: HTMLElement) => PartialBlockFromConfig | undefined; }; // Function that wraps the React component returned from 'blockConfig.render' in @@ -141,7 +117,7 @@ export function createReactBlockSpec< }, parseHTML() { - return parse(blockConfig); + return getParseRules(blockConfig, blockImplementation.parse); }, addNodeView() { @@ -161,9 +137,12 @@ export function createReactBlockSpec< const blockContentDOMAttributes = this.options.domAttributes?.blockContent || {}; + // hacky, should export `useReactNodeView` from tiptap to get access to ref + const ref = (NodeViewContent({}) as any).ref; + const Content = blockImplementation.render; const BlockContent = reactWrapInBlockStructure( - , + , block.type, block.props, blockConfig.propSchema, @@ -186,47 +165,43 @@ export function createReactBlockSpec< node.options.domAttributes?.blockContent || {}; const Content = blockImplementation.render; - const BlockContent = reactWrapInBlockStructure( - , - block.type, - block.props, - blockConfig.propSchema, - blockContentDOMAttributes - ); - const parent = document.createElement("div"); - parent.innerHTML = renderToString(); - - return { - dom: parent.firstElementChild! as HTMLElement, - contentDOM: (parent.querySelector(".bn-inline-content") || - undefined) as HTMLElement | undefined, - }; + return renderToDOMSpec((refCB) => { + const BlockContent = reactWrapInBlockStructure( + , + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + return ; + }); }, toExternalHTML: (block, editor) => { const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; - let Content = blockImplementation.toExternalHTML; - if (Content === undefined) { - Content = blockImplementation.render; - } - const BlockContent = reactWrapInBlockStructure( - , - block.type, - block.props, - blockConfig.propSchema, - blockContentDOMAttributes - ); - - const parent = document.createElement("div"); - parent.innerHTML = renderToString(); - - return { - dom: parent.firstElementChild! as HTMLElement, - contentDOM: (parent.querySelector(".bn-inline-content") || - undefined) as HTMLElement | undefined, - }; + const Content = + blockImplementation.toExternalHTML || blockImplementation.render; + + return renderToDOMSpec((refCB) => { + const BlockContent = reactWrapInBlockStructure( + , + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + return ; + }); }, }); } diff --git a/packages/react/src/ReactInlineContentSpec.tsx b/packages/react/src/ReactInlineContentSpec.tsx index 99415a1bc9..6d598990e0 100644 --- a/packages/react/src/ReactInlineContentSpec.tsx +++ b/packages/react/src/ReactInlineContentSpec.tsx @@ -1,12 +1,17 @@ import { - createInternalInlineContentSpec, - createStronglyTypedTiptapNode, CustomInlineContentConfig, InlineContentConfig, InlineContentFromConfig, + PropSchema, + Props, + StyleSchema, + addInlineContentAttributes, + camelToDataKebab, + createInternalInlineContentSpec, + createStronglyTypedTiptapNode, + getInlineContentParseRules, nodeToCustomInlineContent, propsToAttributes, - StyleSchema, } from "@blocknote/core"; import { NodeViewContent, @@ -16,8 +21,7 @@ import { } from "@tiptap/react"; // import { useReactNodeView } from "@tiptap/react/dist/packages/react/src/useReactNodeView"; import { FC } from "react"; -import { flushSync } from "react-dom"; -import { createRoot } from "react-dom/client"; +import { renderToDOMSpec } from "./ReactRenderUtil"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks @@ -38,6 +42,40 @@ export type ReactInlineContentImplementation< // }>; }; +// Function that adds a wrapper with necessary classes and attributes to the +// component returned from a custom inline content's 'render' function, to +// ensure no data is lost on internal copy & paste. +export function reactWrapInInlineContentStructure< + IType extends string, + PSchema extends PropSchema +>( + element: JSX.Element, + inlineContentType: IType, + inlineContentProps: Props, + propSchema: PSchema +) { + return () => ( + // Creates inline content section element + value !== propSchema[prop].default) + .map(([prop, value]) => { + return [camelToDataKebab(prop), value]; + }) + )}> + {element} + + ); +} + // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead export function createReactInlineContentSpec< @@ -52,6 +90,8 @@ export function createReactInlineContentSpec< name: inlineContentConfig.type as T["type"], inline: true, group: "inline", + selectable: inlineContentConfig.content === "styled", + atom: inlineContentConfig.content === "none", content: (inlineContentConfig.content === "styled" ? "inline*" : "") as T["content"] extends "styled" ? "inline*" : "", @@ -60,9 +100,9 @@ export function createReactInlineContentSpec< return propsToAttributes(inlineContentConfig.propSchema); }, - // parseHTML() { - // return parse(blockConfig); - // }, + parseHTML() { + return getInlineContentParseRules(inlineContentConfig); + }, renderHTML({ node }) { const editor = this.options.editor; @@ -72,43 +112,19 @@ export function createReactInlineContentSpec< editor.inlineContentSchema, editor.styleSchema ) as any as InlineContentFromConfig; // TODO: fix cast - const Content = inlineContentImplementation.render; - - let contentDOM: HTMLElement | undefined; - const div = document.createElement("div"); - const root = createRoot(div); - flushSync(() => { - root.render( - (contentDOM = el || undefined)} - /> - ); - }); - - if (!div.childElementCount) { - // TODO - console.warn("ReactInlineContentSpec: renderHTML() failed"); - return { - dom: document.createElement("span"), - }; - } - - // clone so we can unmount the react root - contentDOM?.setAttribute("data-tmp-find", "true"); - const cloneRoot = div.cloneNode(true) as HTMLElement; - const dom = cloneRoot.firstElementChild! as HTMLElement; - const contentDOMClone = cloneRoot.querySelector( - "[data-tmp-find]" - ) as HTMLElement | null; - contentDOMClone?.removeAttribute("data-tmp-find"); - - root.unmount(); + const output = renderToDOMSpec((refCB) => ( + + )); return { - dom, - contentDOM: contentDOMClone || undefined, + dom: addInlineContentAttributes( + output.dom, + inlineContentConfig.type, + node.attrs as Props, + inlineContentConfig.propSchema + ), + contentDOM: output.contentDOM, }; }, @@ -123,20 +139,22 @@ export function createReactInlineContentSpec< const ref = (NodeViewContent({}) as any).ref; const Content = inlineContentImplementation.render; - return ( - - // TODO: fix cast - } - /> - + const FullContent = reactWrapInInlineContentStructure( + // TODO: fix cast + } + />, + inlineContentConfig.type, + props.node.attrs as Props, + inlineContentConfig.propSchema ); + return ; }, { className: "bn-ic-react-node-view-renderer", diff --git a/packages/react/src/ReactRenderUtil.ts b/packages/react/src/ReactRenderUtil.ts new file mode 100644 index 0000000000..36262e9392 --- /dev/null +++ b/packages/react/src/ReactRenderUtil.ts @@ -0,0 +1,37 @@ +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; + +export function renderToDOMSpec( + fc: (refCB: (ref: HTMLElement | null) => void) => React.ReactNode +) { + let contentDOM: HTMLElement | undefined; + const div = document.createElement("div"); + const root = createRoot(div); + flushSync(() => { + root.render(fc((el) => (contentDOM = el || undefined))); + }); + + if (!div.childElementCount) { + // TODO + console.warn("ReactInlineContentSpec: renderHTML() failed"); + return { + dom: document.createElement("span"), + }; + } + + // clone so we can unmount the react root + contentDOM?.setAttribute("data-tmp-find", "true"); + const cloneRoot = div.cloneNode(true) as HTMLElement; + const dom = cloneRoot.firstElementChild! as HTMLElement; + const contentDOMClone = cloneRoot.querySelector( + "[data-tmp-find]" + ) as HTMLElement | null; + contentDOMClone?.removeAttribute("data-tmp-find"); + + root.unmount(); + + return { + dom, + contentDOM: contentDOMClone || undefined, + }; +} diff --git a/packages/react/src/ReactStyleSpec.tsx b/packages/react/src/ReactStyleSpec.tsx index e9baef7503..cb401850b7 100644 --- a/packages/react/src/ReactStyleSpec.tsx +++ b/packages/react/src/ReactStyleSpec.tsx @@ -1,8 +1,13 @@ -import { createInternalStyleSpec, StyleConfig } from "@blocknote/core"; +import { + addStyleAttributes, + createInternalStyleSpec, + getStyleParseRules, + StyleConfig, + stylePropsToAttributes, +} from "@blocknote/core"; import { Mark } from "@tiptap/react"; import { FC } from "react"; -import { flushSync } from "react-dom"; -import { createRoot } from "react-dom/client"; +import { renderToDOMSpec } from "./ReactRenderUtil"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks @@ -23,21 +28,11 @@ export function createReactStyleSpec( name: styleConfig.type, addAttributes() { - if (styleConfig.propSchema === "boolean") { - return {}; - } - return { - stringValue: { - default: undefined, - // TODO: parsing + return stylePropsToAttributes(styleConfig.propSchema); + }, - // parseHTML: (element) => - // element.getAttribute(`data-${styleConfig.type}`), - // renderHTML: (attributes) => ({ - // [`data-${styleConfig.type}`]: attributes.stringValue, - // }), - }, - }; + parseHTML() { + return getStyleParseRules(styleConfig); }, renderHTML({ mark }) { @@ -48,40 +43,18 @@ export function createReactStyleSpec( } const Content = styleImplementation.render; - - let contentDOM: HTMLElement | undefined; - const div = document.createElement("div"); - const root = createRoot(div); - flushSync(() => { - root.render( - (contentDOM = el || undefined)} - /> - ); - }); - - if (!div.childElementCount) { - // TODO - console.warn("ReactSdtyleSpec: renderHTML() failed"); - return { - dom: document.createElement("span"), - }; - } - - // clone so we can unmount the react root - contentDOM?.setAttribute("data-tmp-find", "true"); - const cloneRoot = div.cloneNode(true) as HTMLElement; - const dom = cloneRoot.firstElementChild! as HTMLElement; - const contentDOMClone = - (cloneRoot.querySelector("[data-tmp-find]") as HTMLElement) || null; - contentDOMClone?.removeAttribute("data-tmp-find"); - - root.unmount(); + const renderResult = renderToDOMSpec((refCB) => ( + + )); return { - dom, - contentDOM: contentDOMClone || undefined, + dom: addStyleAttributes( + renderResult.dom, + styleConfig.type, + mark.attrs.stringValue, + styleConfig.propSchema + ), + contentDOM: renderResult.contentDOM, }; }, }); diff --git a/packages/react/src/test/__snapshots__/fontSize/basic/external.html b/packages/react/src/test/__snapshots__/fontSize/basic/external.html index 00a5bc6b6e..6c8910692f 100644 --- a/packages/react/src/test/__snapshots__/fontSize/basic/external.html +++ b/packages/react/src/test/__snapshots__/fontSize/basic/external.html @@ -1 +1 @@ -

    This is text with a custom fontSize

    \ No newline at end of file +

    This is text with a custom fontSize

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/fontSize/basic/internal.html b/packages/react/src/test/__snapshots__/fontSize/basic/internal.html index a41d39869a..998d9bcf8b 100644 --- a/packages/react/src/test/__snapshots__/fontSize/basic/internal.html +++ b/packages/react/src/test/__snapshots__/fontSize/basic/internal.html @@ -1 +1 @@ -

    This is text with a custom fontSize

    \ No newline at end of file +

    This is text with a custom fontSize

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/mention/basic/external.html b/packages/react/src/test/__snapshots__/mention/basic/external.html index e1513fed2d..2e6f533ca1 100644 --- a/packages/react/src/test/__snapshots__/mention/basic/external.html +++ b/packages/react/src/test/__snapshots__/mention/basic/external.html @@ -1 +1 @@ -

    I enjoy working with@Matthew

    \ No newline at end of file +

    I enjoy working with@Matthew

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/mention/basic/internal.html b/packages/react/src/test/__snapshots__/mention/basic/internal.html index 7af6dad9c7..6ca7d81c2c 100644 --- a/packages/react/src/test/__snapshots__/mention/basic/internal.html +++ b/packages/react/src/test/__snapshots__/mention/basic/internal.html @@ -1 +1 @@ -

    I enjoy working with@Matthew

    \ No newline at end of file +

    I enjoy working with@Matthew

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html index 91eec85769..edde3826ef 100644 --- a/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html @@ -1 +1 @@ -

    React Custom Paragraph

    \ No newline at end of file +

    React Custom Paragraph

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html index 22dd233fa1..faec73f053 100644 --- a/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html @@ -1 +1 @@ -

    React Custom Paragraph

    Nested React Custom Paragraph 1

    Nested React Custom Paragraph 2

    \ No newline at end of file +

    React Custom Paragraph

    Nested React Custom Paragraph 1

    Nested React Custom Paragraph 2

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html index ec4f7f99a2..dd2e249332 100644 --- a/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html @@ -1 +1 @@ -

    Plain Red Text Blue Background Mixed Colors

    \ No newline at end of file +

    Plain Red Text Blue Background Mixed Colors

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html index 1a5c3daa4a..a12e18e1e3 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html @@ -1 +1 @@ -

    React Custom Paragraph

    \ No newline at end of file +

    React Custom Paragraph

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html index 08534f9e77..ef4a1496c0 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html @@ -1 +1 @@ -

    React Custom Paragraph

    \ No newline at end of file +

    React Custom Paragraph

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html index a61e824d02..f34364cb2a 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html @@ -1 +1 @@ -

    Custom React Paragraph

    Nested React Custom Paragraph 1

    Nested React Custom Paragraph 2

    \ No newline at end of file +

    Custom React Paragraph

    Nested React Custom Paragraph 1

    Nested React Custom Paragraph 2

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html index 5ce1aa3e93..b036c67a6d 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html @@ -1 +1 @@ -

    Custom React Paragraph

    Nested React Custom Paragraph 1

    Nested React Custom Paragraph 2

    \ No newline at end of file +

    Custom React Paragraph

    Nested React Custom Paragraph 1

    Nested React Custom Paragraph 2

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html index 816f2ca547..df6c3a0e11 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html @@ -1 +1 @@ -

    Plain Red Text Blue Background Mixed Colors

    \ No newline at end of file +

    Plain Red Text Blue Background Mixed Colors

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html index fefa7e8680..fdc04d2f52 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html @@ -1 +1 @@ -

    Plain Red Text Blue Background Mixed Colors

    \ No newline at end of file +

    Plain Red Text Blue Background Mixed Colors

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/small/basic/external.html b/packages/react/src/test/__snapshots__/small/basic/external.html index 4206d07a95..35c3d5c232 100644 --- a/packages/react/src/test/__snapshots__/small/basic/external.html +++ b/packages/react/src/test/__snapshots__/small/basic/external.html @@ -1 +1 @@ -

    This is a small text

    \ No newline at end of file +

    This is a small text

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/small/basic/internal.html b/packages/react/src/test/__snapshots__/small/basic/internal.html index 805c78112e..73836f647d 100644 --- a/packages/react/src/test/__snapshots__/small/basic/internal.html +++ b/packages/react/src/test/__snapshots__/small/basic/internal.html @@ -1 +1 @@ -

    This is a small text

    \ No newline at end of file +

    This is a small text

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/tag/basic/external.html b/packages/react/src/test/__snapshots__/tag/basic/external.html index 4229ae0a83..b8387e9a55 100644 --- a/packages/react/src/test/__snapshots__/tag/basic/external.html +++ b/packages/react/src/test/__snapshots__/tag/basic/external.html @@ -1 +1 @@ -

    I love #BlockNote

    \ No newline at end of file +

    I love #BlockNote

    \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/tag/basic/internal.html b/packages/react/src/test/__snapshots__/tag/basic/internal.html index dac5db0ca8..bac28633b0 100644 --- a/packages/react/src/test/__snapshots__/tag/basic/internal.html +++ b/packages/react/src/test/__snapshots__/tag/basic/internal.html @@ -1 +1 @@ -

    I love #BlockNote

    \ No newline at end of file +

    I love #BlockNote

    \ No newline at end of file diff --git a/packages/react/src/test/htmlConversion.test.tsx b/packages/react/src/test/htmlConversion.test.tsx index 5a2f466e3f..08c01088db 100644 --- a/packages/react/src/test/htmlConversion.test.tsx +++ b/packages/react/src/test/htmlConversion.test.tsx @@ -6,15 +6,18 @@ import { InlineContentSchema, PartialBlock, StyleSchema, + addIdsToBlocks, createExternalHTMLExporter, createInternalHTMLSerializer, + partialBlocksToBlocksForTesting, } from "@blocknote/core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { customReactBlockSchemaTestCases } from "./testCases/customReactBlocks"; import { customReactInlineContentTestCases } from "./testCases/customReactInlineContent"; import { customReactStylesTestCases } from "./testCases/customReactStyles"; -function convertToHTMLAndCompareSnapshots< +// TODO: code same from @blocknote/core, maybe create separate test util package +async function convertToHTMLAndCompareSnapshots< B extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema @@ -24,6 +27,7 @@ function convertToHTMLAndCompareSnapshots< snapshotDirectory: string, snapshotName: string ) { + addIdsToBlocks(blocks); const serializer = createInternalHTMLSerializer( editor._tiptapEditor.schema, editor @@ -37,6 +41,16 @@ function convertToHTMLAndCompareSnapshots< "/internal.html"; expect(internalHTML).toMatchFileSnapshot(internalHTMLSnapshotPath); + // turn the internalHTML back into blocks, and make sure no data was lost + const fullBlocks = partialBlocksToBlocksForTesting( + editor.blockSchema, + blocks + ); + const parsed = await editor.tryParseHTMLToBlocks(internalHTML); + + expect(parsed).toStrictEqual(fullBlocks); + + // Create the "external" HTML, which is a cleaned up HTML representation, but lossy const exporter = createExternalHTMLExporter( editor._tiptapEditor.schema, editor @@ -75,9 +89,9 @@ describe("Test React HTML conversion", () => { for (const document of testCase.documents) { // eslint-disable-next-line no-loop-func - it("Convert " + document.name + " to HTML", () => { + it("Convert " + document.name + " to HTML", async () => { const nameSplit = document.name.split("/"); - convertToHTMLAndCompareSnapshots( + await convertToHTMLAndCompareSnapshots( editor, document.blocks, nameSplit[0], diff --git a/packages/react/src/test/testCases/customReactBlocks.tsx b/packages/react/src/test/testCases/customReactBlocks.tsx index fc709cb2a6..8dd528f74d 100644 --- a/packages/react/src/test/testCases/customReactBlocks.tsx +++ b/packages/react/src/test/testCases/customReactBlocks.tsx @@ -9,7 +9,7 @@ import { defaultProps, uploadToTmpFilesDotOrg_DEV_ONLY, } from "@blocknote/core"; -import { InlineContent, createReactBlockSpec } from "../../ReactBlockSpec"; +import { createReactBlockSpec } from "../../ReactBlockSpec"; const ReactCustomParagraph = createReactBlockSpec( { @@ -18,8 +18,8 @@ const ReactCustomParagraph = createReactBlockSpec( content: "inline", }, { - render: () => ( - + render: (props) => ( +

    ), toExternalHTML: () => (

    Hello World

    @@ -34,8 +34,8 @@ const SimpleReactCustomParagraph = createReactBlockSpec( content: "inline", }, { - render: () => ( - + render: (props) => ( +

    ), } ); diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 0557d683ef..41e980486e 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -11,6 +11,16 @@ export default defineConfig((conf) => ({ setupFiles: ["./vitestSetup.ts"], }, plugins: [react()], + // used so that vitest resolves the core package from the sources instead of the built version + resolve: { + alias: + conf.command === "build" + ? ({} as Record) + : ({ + // load live from sources with live reload working + "@blocknote/core": path.resolve(__dirname, "../core/src/"), + } as Record), + }, build: { sourcemap: true, lib: {