From f5e523a98398f310e55515d20b912d100cf1a4ed Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 15 Nov 2023 22:24:30 +0100 Subject: [PATCH 01/30] fix table types --- packages/core/src/extensions/Blocks/api/block.ts | 16 ++++++++++++---- .../core/src/extensions/Blocks/api/blockTypes.ts | 2 +- .../src/extensions/Blocks/api/defaultBlocks.ts | 4 ++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index 7cff191007..b0efea33ac 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -224,12 +224,12 @@ export function wrapInBlockStructure< // Helper type to keep track of the `name` and `content` properties after calling Node.create. type StronglyTypedTipTapNode< Name extends string, - Content extends "inline*" | "table" | "tableRow+" | "" -> = Node & { name: Name; content: Content }; + Content extends "inline*" | "tableRow+" | "" +> = Node & { name: Name; config: { content: Content } }; export function createStronglyTypedTiptapNode< Name extends string, - Content extends "inline*" | "table" | "tableRow+" | "" + Content extends "inline*" | "tableRow+" | "" >(config: NodeConfig & { name: Name; content: Content }) { return Node.create(config) as StronglyTypedTipTapNode; // force re-typing (should be safe as it's type-checked from the config) } @@ -253,7 +253,15 @@ export function createBlockSpecFromStronglyTypedTiptapNode< return createInternalBlockSpec( { type: node.name as T["name"], - content: "inline", + content: (node.config.content === "inline*" + ? "inline" + : node.config.content === "tableRow+" + ? "table" + : "none") as T["config"]["content"] extends "inline*" + ? "inline" + : T["config"]["content"] extends "tableRow+" + ? "table" + : "none", propSchema, }, { diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index 3d4ebf6367..854e553de8 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -52,7 +52,7 @@ export type Props = { export type BlockConfig = { type: string; readonly propSchema: PropSchema; - content: "inline" | "none"; // | "table"; + content: "inline" | "none" | "table"; }; // Block implementation contains the "implementation" info about a Block diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts index b64973727c..41a8c2906e 100644 --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts @@ -3,7 +3,7 @@ import { Image } from "../nodes/BlockContent/ImageBlockContent/ImageBlockContent import { BulletListItem } from "../nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent"; import { NumberedListItem } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; import { Paragraph } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent"; -// import { Table } from "../nodes/BlockContent/TableBlockContent/TableBlockContent"; +import { Table } from "../nodes/BlockContent/TableBlockContent/TableBlockContent"; import { BlockSchema } from "./blockTypes"; export const defaultBlockSchema = { @@ -12,7 +12,7 @@ export const defaultBlockSchema = { bulletListItem: BulletListItem, numberedListItem: NumberedListItem, image: Image, - // table: Table, + table: Table, } satisfies BlockSchema; export type DefaultBlockSchema = typeof defaultBlockSchema; From 94a23deae7bc1393041010f45d7a9504a2479ebe Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Nov 2023 11:32:52 +0100 Subject: [PATCH 02/30] add tablecontent --- .../api/nodeConversions/nodeConversions.ts | 30 +++++++++++++- .../core/src/api/nodeConversions/testUtil.ts | 29 ++++++++------ .../src/extensions/Blocks/api/blockTypes.ts | 19 ++++++--- .../extensions/Blocks/nodes/BlockContainer.ts | 9 ++++- .../SlashMenu/defaultSlashMenuItems.ts | 39 ++++++++++++++++--- 5 files changed, 100 insertions(+), 26 deletions(-) diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts index f2e8104031..1b7d1cc8c6 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts @@ -4,6 +4,7 @@ import { Block, BlockSchema, PartialBlock, + TableContent, } from "../../extensions/Blocks/api/blockTypes"; import { ColorStyle, @@ -125,6 +126,28 @@ export function inlineContentToNodes( return nodes; } +/** + * converts an array of inline content elements to prosemirror nodes + */ +export function tableContentToNodes( + tableContent: TableContent, + schema: Schema +): Node[] { + const rowNodes: Node[] = []; + + for (const row of tableContent.rows) { + const columnNodes: Node[] = []; + for (const cell of row.cells) { + const nodes = inlineContentToNodes(cell, schema); + const cellNode = schema.nodes["tableCell"].create({}, nodes); + columnNodes.push(cellNode); + } + const rowNode = schema.nodes["tableRow"].create({}, columnNodes); + rowNodes.push(rowNode); + } + return rowNodes; +} + /** * Converts a BlockNote block to a TipTap node. */ @@ -153,9 +176,14 @@ export function blockToNode( block.props, schema.text(block.content) ); - } else { + } else if (Array.isArray(block.content)) { const nodes = inlineContentToNodes(block.content, schema); contentNode = schema.nodes[type].create(block.props, nodes); + } else if (block.content.type === "tableContent") { + const nodes = tableContentToNodes(block.content, schema); + contentNode = schema.nodes[type].create(block.props, nodes); + } else { + throw new UnreachableCaseError(block.content.type); } const children: Node[] = []; diff --git a/packages/core/src/api/nodeConversions/testUtil.ts b/packages/core/src/api/nodeConversions/testUtil.ts index bf2ece2be2..490f989958 100644 --- a/packages/core/src/api/nodeConversions/testUtil.ts +++ b/packages/core/src/api/nodeConversions/testUtil.ts @@ -2,6 +2,7 @@ import { Block, BlockSchema, PartialBlock, + TableContent, } from "../../extensions/Blocks/api/blockTypes"; import { InlineContent, @@ -25,22 +26,26 @@ function textShorthandToStyledText( } function partialContentToInlineContent( - content: string | PartialInlineContent[] = "" -): InlineContent[] { + content: string | PartialInlineContent[] | TableContent = "" +): InlineContent[] | TableContent { if (typeof content === "string") { return textShorthandToStyledText(content); } - return content.map((partialContent) => { - if (partialContent.type === "link") { - return { - ...partialContent, - content: textShorthandToStyledText(partialContent.content), - }; - } else { - return partialContent; - } - }); + if (Array.isArray(content)) { + return content.map((partialContent) => { + if (partialContent.type === "link") { + return { + ...partialContent, + content: textShorthandToStyledText(partialContent.content), + }; + } else { + return partialContent; + } + }); + } + + return content; } export function partialBlockToBlockForTesting( diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index 854e553de8..e31423957b 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -112,6 +112,13 @@ export type BlockSchemaWithBlock< }; }; +export type TableContent = { + type: "tableContent"; + rows: { + cells: InlineContent[][]; + }[]; +}; + // A BlockConfig has all the information to get the type of a Block (which is a specific instance of the BlockConfig. // i.e.: paragraphConfig: BlockConfig defines what a "paragraph" is / supports, and BlockFromBlockConfig is the shape of a specific paragraph block. // (for internal use) @@ -121,9 +128,9 @@ type BlockFromBlockConfig = { props: Props; content: B["content"] extends "inline" ? InlineContent[] - : // : B["content"] extends "table" - // ? InlineContent[][] - undefined; + : B["content"] extends "table" + ? TableContent + : undefined; }; // Converts each block spec into a Block object without children. We later merge @@ -161,9 +168,9 @@ type PartialBlockFromBlockConfig = { props?: Partial>; content?: B["content"] extends "inline" ? PartialInlineContent[] | string - : // : B["content"] extends "table" - // ? PartialInlineContent[][] - undefined; + : B["content"] extends "table" + ? TableContent + : undefined; }; // Same as BlockWithoutChildren, but as a partial type with some changes to make diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index bbde4d39af..a290f6f5dd 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -4,9 +4,10 @@ import { NodeSelection, TextSelection } from "prosemirror-state"; import { blockToNode, inlineContentToNodes, + tableContentToNodes, } from "../../../api/nodeConversions/nodeConversions"; -import { mergeCSSClasses } from "../../../shared/utils"; +import { UnreachableCaseError, mergeCSSClasses } from "../../../shared/utils"; import { NonEditableBlockPlugin } from "../NonEditableBlockPlugin"; import { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin"; import { @@ -185,10 +186,14 @@ export const BlockContainer = Node.create<{ if (typeof block.content === "string") { // Adds a single text node with no marks to the content. content.push(state.schema.text(block.content)); - } else { + } else if (Array.isArray(block.content)) { // Adds a text node with the provided styles converted into marks to the content, for each InlineContent // object. content = inlineContentToNodes(block.content, state.schema); + } else if (block.content.type === "tableContent") { + content = tableContentToNodes(block.content, state.schema); + } else { + throw new UnreachableCaseError(block.content.type); } // Replaces the contents of the blockContent node with the previously created text node(s). diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index 6b71948865..bf5b09e6e2 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -11,16 +11,15 @@ function insertOrUpdateBlock( const currentBlock = editor.getTextCursorPosition().block; if (currentBlock.content === undefined) { - throw new Error( - "Slash Menu open in a block that doesn't contain inline content." - ); + throw new Error("Slash Menu open in a block that doesn't contain content."); } if ( - (currentBlock.content.length === 1 && + Array.isArray(currentBlock.content) && + ((currentBlock.content.length === 1 && currentBlock.content[0].type === "text" && currentBlock.content[0].text === "/") || - currentBlock.content.length === 0 + currentBlock.content.length === 0) ) { editor.updateBlock(currentBlock, block); } else { @@ -119,6 +118,36 @@ export const getDefaultSlashMenuItems = ( execute: (editor) => insertOrUpdateBlock(editor, { type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [ + [ + { + type: "text", + text: "", + styles: {}, + }, + ], + [ + { + type: "text", + text: "", + styles: {}, + }, + ], + [ + { + type: "text", + text: "", + styles: {}, + }, + ], + ], + }, + ], + }, } as PartialBlock), }); } From 0567bc06e4bef30e7c03592055d2f7ba8b28634f Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Nov 2023 13:00:42 +0100 Subject: [PATCH 03/30] clean BNUpdateBlock --- .../extensions/Blocks/nodes/BlockContainer.ts | 101 ++++++++++-------- 1 file changed, 56 insertions(+), 45 deletions(-) diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index a290f6f5dd..a5c01384d8 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -178,63 +178,55 @@ export const BlockContainer = Node.create<{ } } - // Replaces the blockContent node's content if necessary. - if (block.content !== undefined) { - let content: PMNode[] = []; + const oldType = contentNode.type.name; + const newType = block.type || oldType; + + // The code below determines the new content of the block. + // or "keep" to keep as-is + let content: PMNode[] | "keep" = "keep"; - // Checks if the provided content is a string or InlineContent[] type. + // Has there been any custom content provided? + if (block.content) { if (typeof block.content === "string") { // Adds a single text node with no marks to the content. - content.push(state.schema.text(block.content)); + content = [state.schema.text(block.content)]; } else if (Array.isArray(block.content)) { - // Adds a text node with the provided styles converted into marks to the content, for each InlineContent - // object. + // Adds a text node with the provided styles converted into marks to the content, + // for each InlineContent object. content = inlineContentToNodes(block.content, state.schema); } else if (block.content.type === "tableContent") { content = tableContentToNodes(block.content, state.schema); } else { throw new UnreachableCaseError(block.content.type); } - - // Replaces the contents of the blockContent node with the previously created text node(s). - state.tr.replace( - startPos + 1, - startPos + contentNode.nodeSize - 1, - new Slice(Fragment.from(content), 0, 0) - ); + } else { + // no custom content has been provided, use existing content IF possible + + // Since some block types contain inline content and others don't, + // we either need to call setNodeMarkup to just update type & + // attributes, or replaceWith to replace the whole blockContent. + const oldContentType = state.schema.nodes[oldType].spec.content; + const newContentType = state.schema.nodes[newType].spec.content; + + if (oldContentType === "") { + // keep old content, because it's empty anyway and should be compatible with + // any newContentType + } else if (newContentType !== oldContentType) { + // the content type changed, replace the previous content + content = []; + } else { + // keep old content, because the content type is the same and should be compatible + } } - // Since some block types contain inline content and others don't, - // we either need to call setNodeMarkup to just update type & - // attributes, or replaceWith to replace the whole blockContent. - const oldType = contentNode.type.name; - const newType = block.type || oldType; - - const oldContentType = state.schema.nodes[oldType].spec.content; - const newContentType = state.schema.nodes[newType].spec.content; - - if (oldContentType === "inline*" && newContentType === "") { - // Replaces the blockContent node with one of the new type and - // adds the provided props as attributes. Also preserves all - // existing attributes that are compatible with the new type. - // Need to reset the selection since replacing the block content - // sets it to the next block. - state.tr - .replaceWith( - startPos, - endPos, - state.schema.nodes[newType].create({ - ...contentNode.attrs, - ...block.props, - }) - ) - .setSelection( - new NodeSelection(state.tr.doc.resolve(startPos)) - ); - } else { - // Changes the blockContent node type and adds the provided props - // as attributes. Also preserves all existing attributes that are - // compatible with the new type. + // Now, changes the blockContent node type and adds the provided props + // as attributes. Also preserves all existing attributes that are + // compatible with the new type. + // + // Use either setNodeMarkup or replaceWith depending on whether the + // content is being replaced or not. + if (content === "keep") { + // use setNodeMarkup to only update the type and attributes state.tr.setNodeMarkup( startPos, block.type === undefined @@ -245,6 +237,25 @@ export const BlockContainer = Node.create<{ ...block.props, } ); + } else { + // use replaceWith to replace the content and the block itself + // also reset the selection since replacing the block content + // sets it to the next block. + state.tr + .replaceWith( + startPos, + endPos, + state.schema.nodes[newType].create( + { + ...contentNode.attrs, + ...block.props, + }, + content + ) + ) + .setSelection( + new NodeSelection(state.tr.doc.resolve(startPos)) + ); } // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing From 9b358542bfef562e71736005dc380349146941f4 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Nov 2023 13:41:17 +0100 Subject: [PATCH 04/30] add partial inline content --- examples/editor/src/App.tsx | 3 +- .../api/nodeConversions/nodeConversions.ts | 17 ++++++++--- packages/core/src/editor.css | 19 ++++++++++++ .../src/extensions/Blocks/api/blockTypes.ts | 9 +++++- .../extensions/Blocks/nodes/BlockContainer.ts | 29 +++++++++++++++++-- .../SlashMenu/defaultSlashMenuItems.ts | 27 ++++------------- 6 files changed, 74 insertions(+), 30 deletions(-) diff --git a/examples/editor/src/App.tsx b/examples/editor/src/App.tsx index b4acf6ca17..8224bb456b 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/src/App.tsx @@ -1,8 +1,9 @@ // import logo from './logo.svg' +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; import "@blocknote/core/style.css"; import { BlockNoteView, useBlockNote } from "@blocknote/react"; +import "prosemirror-tables/style/tables.css"; // TODO: where to put this? import "./App.css"; -import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts index 1b7d1cc8c6..627dc4c761 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts @@ -4,7 +4,7 @@ import { Block, BlockSchema, PartialBlock, - TableContent, + PartialTableContent, } from "../../extensions/Blocks/api/blockTypes"; import { ColorStyle, @@ -130,7 +130,7 @@ export function inlineContentToNodes( * converts an array of inline content elements to prosemirror nodes */ export function tableContentToNodes( - tableContent: TableContent, + tableContent: PartialTableContent, schema: Schema ): Node[] { const rowNodes: Node[] = []; @@ -138,8 +138,17 @@ export function tableContentToNodes( for (const row of tableContent.rows) { const columnNodes: Node[] = []; for (const cell of row.cells) { - const nodes = inlineContentToNodes(cell, schema); - const cellNode = schema.nodes["tableCell"].create({}, nodes); + let pNode: Node; + if (!cell) { + pNode = schema.nodes["tableParagraph"].create({}); + } else if (typeof cell === "string") { + pNode = schema.nodes["tableParagraph"].create({}, schema.text(cell)); + } else { + const textNodes = inlineContentToNodes(cell, schema); + pNode = schema.nodes["tableParagraph"].create({}, textNodes); + } + + const cellNode = schema.nodes["tableCell"].create({}, pNode); columnNodes.push(cellNode); } const rowNode = schema.nodes["tableRow"].create({}, columnNodes); diff --git a/packages/core/src/editor.css b/packages/core/src/editor.css index e47157e409..b9b29b6232 100644 --- a/packages/core/src/editor.css +++ b/packages/core/src/editor.css @@ -85,3 +85,22 @@ Tippy popups that are appended to document.body directly user-select: none; white-space: nowrap; } + +/** +TODO: why is this necessary and where to put this table-related styles? +*/ +.bn-editor th, +.bn-editor td { + min-width: 1em; + border: 1px solid #ddd; + padding: 3px 5px; +} + +.bn-editor .tableWrapper { + margin: 1em 0; +} + +.bn-editor th { + font-weight: bold; + text-align: left; +} diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index e31423957b..c4897cdfa2 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -119,6 +119,13 @@ export type TableContent = { }[]; }; +export type PartialTableContent = { + type: "tableContent"; + rows: { + cells: (PartialInlineContent[] | string)[]; + }[]; +}; + // A BlockConfig has all the information to get the type of a Block (which is a specific instance of the BlockConfig. // i.e.: paragraphConfig: BlockConfig defines what a "paragraph" is / supports, and BlockFromBlockConfig is the shape of a specific paragraph block. // (for internal use) @@ -169,7 +176,7 @@ type PartialBlockFromBlockConfig = { content?: B["content"] extends "inline" ? PartialInlineContent[] | string : B["content"] extends "table" - ? TableContent + ? PartialTableContent : undefined; }; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index a5c01384d8..42e01666fc 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -1,12 +1,12 @@ -import { Node } from "@tiptap/core"; +import { Node, callOrReturn, getExtensionField } from "@tiptap/core"; import { Fragment, Node as PMNode, Slice } from "prosemirror-model"; import { NodeSelection, TextSelection } from "prosemirror-state"; +import { columnResizing, tableEditing } from "prosemirror-tables"; import { blockToNode, inlineContentToNodes, tableContentToNodes, } from "../../../api/nodeConversions/nodeConversions"; - import { UnreachableCaseError, mergeCSSClasses } from "../../../shared/utils"; import { NonEditableBlockPlugin } from "../NonEditableBlockPlugin"; import { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin"; @@ -433,7 +433,16 @@ export const BlockContainer = Node.create<{ }, addProseMirrorPlugins() { - return [PreviousBlockTypePlugin(), NonEditableBlockPlugin()]; + return [ + PreviousBlockTypePlugin(), + NonEditableBlockPlugin(), + columnResizing(), + tableEditing(), + // keymap({ + // Tab: goToNextCell(1), + // "Shift-Tab": goToNextCell(-1), + // }), + ]; }, addKeyboardShortcuts() { @@ -656,4 +665,18 @@ export const BlockContainer = Node.create<{ ), }; }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + }; + + return { + tableRole: callOrReturn( + getExtensionField(extension, "tableRole", context) + ), + }; + }, }); diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index bf5b09e6e2..b323f2e3d5 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -123,29 +123,14 @@ export const getDefaultSlashMenuItems = ( rows: [ { cells: [ - [ - { - type: "text", - text: "", - styles: {}, - }, - ], - [ - { - type: "text", - text: "", - styles: {}, - }, - ], - [ - { - type: "text", - text: "", - styles: {}, - }, - ], + "ab", + [{ type: "text", styles: { bold: true }, text: "hello" }], + "", ], }, + { + cells: ["", "cd", ""], + }, ], }, } as PartialBlock), From 2a0d1a6e7625019c1d2896a8b44873673bbe72ce Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Nov 2023 15:41:54 +0100 Subject: [PATCH 05/30] add contentNodeToTableContent --- .../api/nodeConversions/nodeConversions.ts | 53 ++++++++++++++++--- packages/core/src/editor.css | 4 ++ .../extensions/Blocks/nodes/BlockContainer.ts | 4 +- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts index 627dc4c761..2974824ebd 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts @@ -2,9 +2,12 @@ import { Mark } from "@tiptap/pm/model"; import { Node, Schema } from "prosemirror-model"; import { Block, + BlockConfig, BlockSchema, + BlockSpec, PartialBlock, PartialTableContent, + TableContent, } from "../../extensions/Blocks/api/blockTypes"; import { ColorStyle, @@ -214,6 +217,30 @@ export function blockToNode( ); } +/** + * Converts an internal (prosemirror) table node contentto a BlockNote Tablecontent + */ +function contentNodeToTableContent(contentNode: Node) { + const ret: TableContent = { + type: "tableContent", + rows: [], + }; + + contentNode.content.forEach((rowNode) => { + const row: TableContent["rows"][0] = { + cells: [], + }; + + rowNode.content.forEach((cellNode) => { + row.cells.push(contentNodeToInlineContent(cellNode.firstChild!)); + }); + + ret.rows.push(row); + }); + + return ret; +} + /** * Converts an internal (prosemirror) content node to a BlockNote InlineContent array. */ @@ -418,7 +445,10 @@ export function nodeToBlock( ...node.attrs, ...blockInfo.contentNode.attrs, })) { - const blockSpec = blockSchema[blockInfo.contentType.name]; + const blockSpec = blockSchema[ + blockInfo.contentType.name + ] as BlockSpec; // TODO: fix cast + if (!blockSpec) { throw Error( "Block is of an unrecognized type: " + blockInfo.contentType.name @@ -432,7 +462,9 @@ export function nodeToBlock( } } - const blockSpec = blockSchema[blockInfo.contentType.name]!; + const blockSpec = blockSchema[ + blockInfo.contentType.name + ] as BlockSpec; // TODO: fix cast const children: Block[] = []; for (let i = 0; i < blockInfo.numChildBlocks; i++) { @@ -441,14 +473,23 @@ export function nodeToBlock( ); } + let content: Block["content"]; + + if (blockSpec.config.content === "inline") { + content = contentNodeToInlineContent(blockInfo.contentNode); + } else if (blockSpec.config.content === "table") { + content = contentNodeToTableContent(blockInfo.contentNode); + } else if (blockSpec.config.content === "none") { + content = undefined; + } else { + throw new UnreachableCaseError(blockSpec.config.content); + } + const block = { id, type: blockSpec.config.type, props, - content: - blockSpec.config.content === "inline" - ? contentNodeToInlineContent(blockInfo.contentNode) - : undefined, + content, children, } as Block; diff --git a/packages/core/src/editor.css b/packages/core/src/editor.css index b9b29b6232..1138b9d459 100644 --- a/packages/core/src/editor.css +++ b/packages/core/src/editor.css @@ -89,6 +89,10 @@ Tippy popups that are appended to document.body directly /** TODO: why is this necessary and where to put this table-related styles? */ +.bn-editor table { + width: auto !important; + /* min-width: 300px; */ +} .bn-editor th, .bn-editor td { min-width: 1em; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index 42e01666fc..5abc96ca65 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -436,7 +436,9 @@ export const BlockContainer = Node.create<{ return [ PreviousBlockTypePlugin(), NonEditableBlockPlugin(), - columnResizing(), + columnResizing({ + cellMinWidth: 100, + }), tableEditing(), // keymap({ // Tab: goToNextCell(1), From d5e6f2a7135abb99baa5f8941e41891559e08fdd Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Nov 2023 22:01:00 +0100 Subject: [PATCH 06/30] first draft of tablehandles --- packages/core/src/BlockNoteEditor.ts | 4 + .../ImageToolbar/ImageToolbarPlugin.ts | 6 - .../src/extensions/SideMenu/SideMenuPlugin.ts | 8 +- .../TableHandles/TableHandlesPlugin.ts | 239 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/react/src/BlockNoteView.tsx | 8 +- .../components/DefaultTableHandle.tsx | 76 ++++++ .../components/TableHandlePositioner.tsx | 110 ++++++++ 8 files changed, 439 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts create mode 100644 packages/react/src/TableHandles/components/DefaultTableHandle.tsx create mode 100644 packages/react/src/TableHandles/components/TableHandlePositioner.tsx diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index ba20ec6dcc..3d3c74933e 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -43,6 +43,7 @@ import { SideMenuProsemirrorPlugin } from "./extensions/SideMenu/SideMenuPlugin" import { BaseSlashMenuItem } from "./extensions/SlashMenu/BaseSlashMenuItem"; import { SlashMenuProsemirrorPlugin } from "./extensions/SlashMenu/SlashMenuPlugin"; import { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; +import { TableHandlesProsemirrorPlugin } from "./extensions/TableHandles/TableHandlesPlugin"; import { UniqueID } from "./extensions/UniqueID/UniqueID"; import { mergeCSSClasses } from "./shared/utils"; @@ -154,6 +155,7 @@ export class BlockNoteEditor { public readonly slashMenu: SlashMenuProsemirrorPlugin; public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin; public readonly imageToolbar: ImageToolbarProsemirrorPlugin; + public readonly tableHandles: TableHandlesProsemirrorPlugin; public readonly uploadFile: ((file: File) => Promise) | undefined; @@ -184,6 +186,7 @@ export class BlockNoteEditor { ); this.hyperlinkToolbar = new HyperlinkToolbarProsemirrorPlugin(this); this.imageToolbar = new ImageToolbarProsemirrorPlugin(this); + this.tableHandles = new TableHandlesProsemirrorPlugin(this); const extensions = getBlockNoteExtensions({ editor: this, @@ -202,6 +205,7 @@ export class BlockNoteEditor { this.slashMenu.plugin, this.hyperlinkToolbar.plugin, this.imageToolbar.plugin, + this.tableHandles.plugin, ]; }, }); diff --git a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts index 1a5df8b4d2..acf7f015f8 100644 --- a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts +++ b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts @@ -1,4 +1,3 @@ -import { Node as PMNode } from "prosemirror-model"; import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { @@ -22,11 +21,6 @@ export class ImageToolbarView { public prevWasEditable: boolean | null = null; - public shouldShow: (state: EditorState) => boolean = (state) => - "node" in state.selection && - (state.selection.node as PMNode).type.name === "image" && - (state.selection.node as PMNode).attrs.src === ""; - constructor( private readonly pluginKey: PluginKey, private readonly pmView: EditorView, diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index f2dba2aa61..c4e5823426 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -3,15 +3,15 @@ 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 { BaseUiElementState } from "../../shared/BaseUiElementTypes"; import { EventEmitter } from "../../shared/EventEmitter"; import { Block, BlockSchema } from "../Blocks/api/blockTypes"; import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos"; import { slashMenuPluginKey } from "../SlashMenu/SlashMenuPlugin"; import { MultipleNodeSelection } from "./MultipleNodeSelection"; -import { createInternalHTMLSerializer } from "../../api/serialization/html/internalHTMLSerializer"; -import { createExternalHTMLExporter } from "../../api/serialization/html/externalHTMLExporter"; -import { markdown } from "../../api/formatConversions/formatConversions"; let dragImageElement: Element | undefined; @@ -20,7 +20,7 @@ export type SideMenuState = BaseUiElementState & { block: Block; }; -function getDraggableBlockFromCoords( +export function getDraggableBlockFromCoords( coords: { left: number; top: number }, view: EditorView ) { diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts new file mode 100644 index 0000000000..01ba195d5a --- /dev/null +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -0,0 +1,239 @@ +import { Plugin, PluginKey } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { + BaseUiElementCallbacks, + BlockNoteEditor, + BlockSchema, + getDraggableBlockFromCoords, +} from "../.."; +import { EventEmitter } from "../../shared/EventEmitter"; +import { Block } from "../Blocks/api/blockTypes"; +import { Image } from "../Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent"; +import { Table } from "../Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent"; +export type TableHandlesCallbacks = BaseUiElementCallbacks; + +export type TableHandlesState = { + show: boolean; + referencePosTop: { top: number; left: number }; + referencePosLeft: { top: number; left: number }; + colIndex: number; + rowIndex: number; + block: Block<(typeof Table)["config"]>; +}; + +function getChildIndex(node: HTMLElement) { + return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); +} + +function domCellAround(target: HTMLElement | null): HTMLElement | null { + while (target && target.nodeName !== "TD" && target.nodeName !== "TH") + target = + target.classList && target.classList.contains("ProseMirror") + ? null + : (target.parentNode as HTMLElement); + return target; +} + +export class TableHandlesView { + private state?: TableHandlesState; + public updateState: () => void; + + public prevWasEditable: boolean | null = null; + + constructor( + private readonly editor: BlockNoteEditor, + // @ts-ignore + private readonly pluginKey: PluginKey, + private readonly pmView: EditorView, + updateState: (state: TableHandlesState) => void + ) { + this.updateState = () => { + if (!this.state) { + throw new Error("Attempting to update uninitialized image toolbar"); + } + + updateState(this.state); + }; + + pmView.dom.addEventListener("mousemove", this.mouseMoveHandler); + + // pmView.dom.addEventListener("dragstart", this.dragstartHandler); + + // pmView.dom.addEventListener("blur", this.blurHandler); + + // document.addEventListener("scroll", this.scrollHandler); + } + + mouseMoveHandler = (event: MouseEvent) => { + // console.log("mousemove"); + const target = domCellAround(event.target as HTMLElement); + + if (!target) { + if (this.state?.show) { + this.state.show = false; + this.updateState(); + } + return; + } + + const colIndex = getChildIndex(target); + const rowIndex = getChildIndex(target.parentElement!); + const cellRect = target.getBoundingClientRect(); + const tableRect = + target.parentElement!.parentElement!.getBoundingClientRect(); + + const blockEl = getDraggableBlockFromCoords(cellRect, this.pmView); + const block = this.editor.getBlock(blockEl!.id)! as Block< + (typeof Table)["config"] + >; + + this.state = { + referencePosLeft: { + top: cellRect.top + cellRect.height / 2, + left: tableRect.left, + }, + referencePosTop: { + top: tableRect.top, + left: cellRect.left + cellRect.width / 2, + }, + colIndex, + rowIndex, + show: true, + block, + }; + this.updateState(); + }; + + // // For dragging the whole editor. + // dragstartHandler = () => { + // if (this.imageToolbarState?.show) { + // this.imageToolbarState.show = false; + // this.updateImageToolbar(); + // } + // }; + + // blurHandler = (event: FocusEvent) => { + // const editorWrapper = this.pmView.dom.parentElement!; + + // // Checks if the focus is moving to an element outside the editor. If it is, + // // the toolbar is hidden. + // if ( + // // An element is clicked. + // event && + // event.relatedTarget && + // // Element is inside the editor. + // (editorWrapper === (event.relatedTarget as Node) || + // editorWrapper.contains(event.relatedTarget as Node)) + // ) { + // return; + // } + + // if (this.imageToolbarState?.show) { + // this.imageToolbarState.show = false; + // this.updateImageToolbar(); + // } + // }; + + // scrollHandler = () => { + // if (this.imageToolbarState?.show) { + // const blockElement = document.querySelector( + // `[data-node-type="blockContainer"][data-id="${this.imageToolbarState.block.id}"]` + // )!; + + // this.imageToolbarState.referencePos = + // blockElement.getBoundingClientRect(); + // this.updateImageToolbar(); + // } + // }; + + // update(view: EditorView, prevState: EditorState) { + // const pluginState: { + // block: Block<(typeof Image)["config"]>; + // } = this.pluginKey.getState(view.state); + + // if (!this.imageToolbarState?.show && pluginState.block) { + // const blockElement = document.querySelector( + // `[data-node-type="blockContainer"][data-id="${pluginState.block.id}"]` + // )!; + + // this.imageToolbarState = { + // show: true, + // referencePos: blockElement.getBoundingClientRect(), + // block: pluginState.block, + // }; + + // this.updateImageToolbar(); + + // return; + // } + + // if ( + // !view.state.selection.eq(prevState.selection) || + // !view.state.doc.eq(prevState.doc) + // ) { + // if (this.imageToolbarState?.show) { + // this.imageToolbarState.show = false; + + // this.updateImageToolbar(); + // } + // } + // } + + destroy() { + this.pmView.dom.removeEventListener("mousedown", this.mouseMoveHandler); + + // this.pmView.dom.removeEventListener("dragstart", this.dragstartHandler); + + // this.pmView.dom.removeEventListener("blur", this.blurHandler); + + // document.removeEventListener("scroll", this.scrollHandler); + } +} + +export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin"); + +export class TableHandlesProsemirrorPlugin< + BSchema extends BlockSchema +> extends EventEmitter { + private view: TableHandlesView | undefined; + public readonly plugin: Plugin; + + constructor(editor: BlockNoteEditor) { + super(); + this.plugin = new Plugin<{ + block: Block<(typeof Image)["config"]> | undefined; + }>({ + key: tableHandlesPluginKey, + view: (editorView) => { + this.view = new TableHandlesView( + editor, + tableHandlesPluginKey, + editorView, + (state) => { + this.emit("update", state); + } + ); + return this.view; + }, + state: { + init: () => { + return { + block: undefined, + }; + }, + apply: (transaction) => { + const block: Block<(typeof Image)["config"]> | undefined = + transaction.getMeta(tableHandlesPluginKey)?.block; + + return { + block, + }; + }, + }, + }); + } + + public onUpdate(callback: (state: TableHandlesState) => void) { + return this.on("update", callback); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 770be69b7f..e7849ebd06 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,7 @@ export * from "./extensions/SideMenu/SideMenuPlugin"; export * from "./extensions/SlashMenu/BaseSlashMenuItem"; export * from "./extensions/SlashMenu/SlashMenuPlugin"; export { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; +export * from "./extensions/TableHandles/TableHandlesPlugin"; export * from "./shared/BaseUiElementTypes"; export type { SuggestionItem } from "./shared/plugins/suggestion/SuggestionItem"; export * from "./shared/plugins/suggestion/SuggestionPlugin"; diff --git a/packages/react/src/BlockNoteView.tsx b/packages/react/src/BlockNoteView.tsx index a5f3ee308b..e9ba1a4d01 100644 --- a/packages/react/src/BlockNoteView.tsx +++ b/packages/react/src/BlockNoteView.tsx @@ -1,15 +1,16 @@ import { BlockNoteEditor, BlockSchema, mergeCSSClasses } from "@blocknote/core"; -import { createStyles, MantineProvider } from "@mantine/core"; +import { MantineProvider, createStyles } from "@mantine/core"; import { EditorContent } from "@tiptap/react"; import { HTMLAttributes, ReactNode, useMemo } from "react"; import usePrefersColorScheme from "use-prefers-color-scheme"; -import { blockNoteToMantineTheme, Theme } from "./BlockNoteTheme"; +import { Theme, blockNoteToMantineTheme } from "./BlockNoteTheme"; import { FormattingToolbarPositioner } from "./FormattingToolbar/components/FormattingToolbarPositioner"; import { HyperlinkToolbarPositioner } from "./HyperlinkToolbar/components/HyperlinkToolbarPositioner"; +import { ImageToolbarPositioner } from "./ImageToolbar/components/ImageToolbarPositioner"; import { SideMenuPositioner } from "./SideMenu/components/SideMenuPositioner"; import { SlashMenuPositioner } from "./SlashMenu/components/SlashMenuPositioner"; +import { TableHandlesPositioner } from "./TableHandles/components/TableHandlePositioner"; import { darkDefaultTheme, lightDefaultTheme } from "./defaultThemes"; -import { ImageToolbarPositioner } from "./ImageToolbar/components/ImageToolbarPositioner"; // Renders the editor as well as all menus & toolbars using default styles. function BaseBlockNoteView( @@ -36,6 +37,7 @@ function BaseBlockNoteView( + )} diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx new file mode 100644 index 0000000000..f8b53f9eef --- /dev/null +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx @@ -0,0 +1,76 @@ +import { BlockSchema } from "@blocknote/core"; + +import { Menu } from "@mantine/core"; +import { MdDragIndicator } from "react-icons/md"; +import { SideMenuButton } from "../../SideMenu/components/SideMenuButton"; +import { TableHandlesProps } from "./TableHandlePositioner"; + +const DefaultTableHandleLeft = ( + props: TableHandlesProps +) => { + return ( + + +
+ + + +
+
+ + Delete row + Add row above + Add row below + +
+ ); +}; + +const DefaultTableHandleTop = ( + props: TableHandlesProps +) => { + return ( + + +
+ + + +
+
+ + { + console.log(props.block); + debugger; + }}> + Delete column + + Add column left + Add column right + +
+ ); +}; + +export const DefaultTableHandle = (props: TableHandlesProps) => { + if (props.side === "left") { + return ; + } else { + return ; + } +}; diff --git a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx new file mode 100644 index 0000000000..afb49c35e7 --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx @@ -0,0 +1,110 @@ +import { + BaseUiElementState, + Block, + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, + TableHandlesState, +} from "@blocknote/core"; +import Tippy, { tippy } from "@tippyjs/react"; +import { FC, useEffect, useMemo, useRef, useState } from "react"; +import { DefaultTableHandle } from "./DefaultTableHandle"; + +export type TableHandlesProps< + BSchema extends BlockSchema = DefaultBlockSchema +> = Omit & { + block: Block; + editor: BlockNoteEditor; + side: "top" | "left"; +}; + +export const TableHandlesPositioner = < + BSchema extends BlockSchema = DefaultBlockSchema +>(props: { + editor: BlockNoteEditor; + tableHandle?: FC>; +}) => { + const [show, setShow] = useState(false); + const [block, setBlock] = useState>(); + const [_, setForceUpdate] = useState(0); + + const referencePosLeft = useRef(); + const referencePosTop = useRef(); + + useEffect(() => { + tippy.setDefaultProps({ maxWidth: "" }); + + return props.editor.tableHandles.onUpdate((state) => { + console.log("update", state); + setShow(state.show); + setBlock(state.block as any); // TODO: types + setForceUpdate(Math.random()); + + referencePosLeft.current = state.referencePosLeft; + referencePosTop.current = state.referencePosTop; + }); + }, [props.editor]); + + const getReferenceClientRectLeft = useMemo( + () => { + if (!referencePosLeft.current) { + return undefined; + } + return () => + ({ ...referencePosLeft.current!, width: 0, height: 0 } as DOMRect); + }, + [referencePosLeft.current] // eslint-disable-line + ); + + const getReferenceClientRectTop = useMemo( + () => { + if (!referencePosTop.current) { + return undefined; + } + return () => + ({ ...referencePosTop.current!, width: 0, height: 0 } as DOMRect); + }, + [referencePosTop.current] // eslint-disable-line + ); + + const tableHandleElementTop = useMemo(() => { + const TableHandle = props.tableHandle || DefaultTableHandle; + + return ( + + ); + }, [block, props.editor, props.tableHandle]); + + const tableHandleElementLeft = useMemo(() => { + const TableHandle = props.tableHandle || DefaultTableHandle; + + return ( + + ); + }, [block, props.editor, props.tableHandle]); + + return ( + <> + + + + ); +}; From e86313adfad21d90dfc4d37d041b4213c4eed800 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Nov 2023 23:14:20 +0100 Subject: [PATCH 07/30] implement table functions --- .../components/DefaultTableHandle.tsx | 98 +++++++++++++++++-- .../components/TableHandlePositioner.tsx | 26 ++++- 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx index f8b53f9eef..3de1868ab6 100644 --- a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx @@ -1,4 +1,4 @@ -import { BlockSchema } from "@blocknote/core"; +import { BlockSchema, TableContent } from "@blocknote/core"; import { Menu } from "@mantine/core"; import { MdDragIndicator } from "react-icons/md"; @@ -23,9 +23,57 @@ const DefaultTableHandleLeft = ( - Delete row - Add row above - Add row below + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.filter( + (_, index) => index !== props.rowIndex + ), + }; + + props.editor.updateBlock(props.block, { content } as any); // TODO: type + }}> + Delete row + + { + const emptyCol = props.block.content.rows[props.rowIndex].cells.map( + () => [] + ); + const rows = [...props.block.content.rows]; + rows.splice(props.rowIndex, 0, { + cells: emptyCol, + }); + const content: TableContent = { + type: "tableContent", + rows, + }; + + props.editor.updateBlock(props.block, { content } as any); // TODO: type + }}> + Add row above + + { + const emptyCol = props.block.content.rows[props.rowIndex].cells.map( + () => [] + ); + + const rows = [...props.block.content.rows]; + rows.splice(props.rowIndex + 1, 0, { + cells: emptyCol, + }); + + const content: TableContent = { + type: "tableContent", + rows, + }; + + props.editor.updateBlock(props.block, { content } as any); // TODO: type + }}> + Add row below + ); @@ -55,13 +103,47 @@ const DefaultTableHandleTop = ( { - console.log(props.block); - debugger; + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.map((row) => ({ + cells: row.cells.filter((_, index) => index !== props.colIndex), + })), + }; + + props.editor.updateBlock(props.block, { content } as any); // TODO: type }}> Delete column - Add column left - Add column right + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.map((row) => { + const cells = [...row.cells]; + cells.splice(props.colIndex, 0, []); + return { cells }; + }), + }; + + props.editor.updateBlock(props.block, { content } as any); // TODO: type + }}> + Add column left + + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.map((row) => { + const cells = [...row.cells]; + cells.splice(props.colIndex + 1, 0, []); + return { cells }; + }), + }; + + props.editor.updateBlock(props.block, { content } as any); // TODO: type + }}> + Add column right + ); diff --git a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx index afb49c35e7..0210f9ece2 100644 --- a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx +++ b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx @@ -13,6 +13,8 @@ import { DefaultTableHandle } from "./DefaultTableHandle"; export type TableHandlesProps< BSchema extends BlockSchema = DefaultBlockSchema > = Omit & { + colIndex: number; + rowIndex: number; block: Block; editor: BlockNoteEditor; side: "top" | "left"; @@ -26,6 +28,8 @@ export const TableHandlesPositioner = < }) => { const [show, setShow] = useState(false); const [block, setBlock] = useState>(); + const [colIndex, setColIndex] = useState(); + const [rowIndex, setRowIndex] = useState(); const [_, setForceUpdate] = useState(0); const referencePosLeft = useRef(); @@ -38,6 +42,8 @@ export const TableHandlesPositioner = < console.log("update", state); setShow(state.show); setBlock(state.block as any); // TODO: types + setColIndex(state.colIndex); + setRowIndex(state.rowIndex); setForceUpdate(Math.random()); referencePosLeft.current = state.referencePosLeft; @@ -71,17 +77,29 @@ export const TableHandlesPositioner = < const TableHandle = props.tableHandle || DefaultTableHandle; return ( - + ); - }, [block, props.editor, props.tableHandle]); + }, [block, props.editor, props.tableHandle, rowIndex, colIndex]); const tableHandleElementLeft = useMemo(() => { const TableHandle = props.tableHandle || DefaultTableHandle; return ( - + ); - }, [block, props.editor, props.tableHandle]); + }, [block, props.editor, props.tableHandle, rowIndex, colIndex]); return ( <> From 37b308b13bd56b1d76e996f3cbc3e17801bfee3c Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 17 Nov 2023 04:18:46 +0100 Subject: [PATCH 08/30] fix styles --- examples/editor/src/App.tsx | 1 - package-lock.json | 13 +++++++++++++ packages/core/package.json | 1 + packages/core/src/BlockNoteEditor.ts | 1 + packages/core/src/editor.css | 5 +---- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/examples/editor/src/App.tsx b/examples/editor/src/App.tsx index 8224bb456b..2ff77e8c34 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/src/App.tsx @@ -2,7 +2,6 @@ import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; import "@blocknote/core/style.css"; import { BlockNoteView, useBlockNote } from "@blocknote/react"; -import "prosemirror-tables/style/tables.css"; // TODO: where to put this? import "./App.css"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; diff --git a/package-lock.json b/package-lock.json index bf45f95646..bd53877c0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19824,6 +19824,7 @@ "lodash": "^4.17.21", "prosemirror-model": "^1.18.3", "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.3.4", "prosemirror-transform": "^1.7.2", "prosemirror-view": "^1.31.4", "rehype-parse": "^8.0.4", @@ -19933,6 +19934,18 @@ "node": "^10 || ^12 || >=14" } }, + "packages/core/node_modules/prosemirror-tables": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.3.4.tgz", + "integrity": "sha512-z6uLSQ1BLC3rgbGwZmpfb+xkdvD7W/UOsURDfognZFYaTtc0gsk7u/t71Yijp2eLflVpffMk6X0u0+u+MMDvIw==", + "dependencies": { + "prosemirror-keymap": "^1.1.2", + "prosemirror-model": "^1.8.1", + "prosemirror-state": "^1.3.1", + "prosemirror-transform": "^1.2.1", + "prosemirror-view": "^1.13.3" + } + }, "packages/core/node_modules/rollup": { "version": "3.27.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.27.0.tgz", diff --git a/packages/core/package.json b/packages/core/package.json index a41f4a7055..4ed2dc62aa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -78,6 +78,7 @@ "prosemirror-state": "^1.4.3", "prosemirror-transform": "^1.7.2", "prosemirror-view": "^1.31.4", + "prosemirror-tables": "^1.3.4", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 3d3c74933e..8510786585 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -35,6 +35,7 @@ import { import { Selection } from "./extensions/Blocks/api/selectionTypes"; import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos"; +import "prosemirror-tables/style/tables.css"; import "./editor.css"; import { FormattingToolbarProsemirrorPlugin } from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; import { HyperlinkToolbarProsemirrorPlugin } from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; diff --git a/packages/core/src/editor.css b/packages/core/src/editor.css index 1138b9d459..8a0f691830 100644 --- a/packages/core/src/editor.css +++ b/packages/core/src/editor.css @@ -86,12 +86,9 @@ Tippy popups that are appended to document.body directly white-space: nowrap; } -/** -TODO: why is this necessary and where to put this table-related styles? -*/ +/* table related: */ .bn-editor table { width: auto !important; - /* min-width: 300px; */ } .bn-editor th, .bn-editor td { From f73c502e8ee3db027e4dd2c9b952423139cc63a7 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 17 Nov 2023 04:22:27 +0100 Subject: [PATCH 09/30] fix imports --- packages/core/src/BlockNoteEditor.ts | 5 +++++ .../src/extensions/Blocks/nodes/BlockContainer.ts | 15 ++------------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 8510786585..4f068f0800 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -35,6 +35,7 @@ import { import { Selection } from "./extensions/Blocks/api/selectionTypes"; import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos"; +import { columnResizing, tableEditing } from "prosemirror-tables"; import "prosemirror-tables/style/tables.css"; import "./editor.css"; import { FormattingToolbarProsemirrorPlugin } from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; @@ -207,6 +208,10 @@ export class BlockNoteEditor { this.hyperlinkToolbar.plugin, this.imageToolbar.plugin, this.tableHandles.plugin, + columnResizing({ + cellMinWidth: 100, + }), + tableEditing(), ]; }, }); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index 5abc96ca65..c28796e059 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -1,7 +1,7 @@ import { Node, callOrReturn, getExtensionField } from "@tiptap/core"; import { Fragment, Node as PMNode, Slice } from "prosemirror-model"; import { NodeSelection, TextSelection } from "prosemirror-state"; -import { columnResizing, tableEditing } from "prosemirror-tables"; + import { blockToNode, inlineContentToNodes, @@ -433,18 +433,7 @@ export const BlockContainer = Node.create<{ }, addProseMirrorPlugins() { - return [ - PreviousBlockTypePlugin(), - NonEditableBlockPlugin(), - columnResizing({ - cellMinWidth: 100, - }), - tableEditing(), - // keymap({ - // Tab: goToNextCell(1), - // "Shift-Tab": goToNextCell(-1), - // }), - ]; + return [PreviousBlockTypePlugin(), NonEditableBlockPlugin()]; }, addKeyboardShortcuts() { From 2f458e3be292eecb32df47d3965a4b62dbc8bc90 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 17 Nov 2023 04:29:16 +0100 Subject: [PATCH 10/30] create separate TableExtension --- packages/core/src/BlockNoteEditor.ts | 5 ---- packages/core/src/BlockNoteExtensions.ts | 2 ++ .../extensions/Blocks/nodes/BlockContainer.ts | 16 +--------- .../TableBlockContent/TableExtension.ts | 29 +++++++++++++++++++ 4 files changed, 32 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 4f068f0800..8510786585 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -35,7 +35,6 @@ import { import { Selection } from "./extensions/Blocks/api/selectionTypes"; import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos"; -import { columnResizing, tableEditing } from "prosemirror-tables"; import "prosemirror-tables/style/tables.css"; import "./editor.css"; import { FormattingToolbarProsemirrorPlugin } from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; @@ -208,10 +207,6 @@ export class BlockNoteEditor { this.hyperlinkToolbar.plugin, this.imageToolbar.plugin, this.tableHandles.plugin, - columnResizing({ - cellMinWidth: 100, - }), - tableEditing(), ]; }, }); diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/BlockNoteExtensions.ts index e580d3e9d3..16ae7853a9 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -24,6 +24,7 @@ import { BlockNoteDOMAttributes, BlockSchema, } from "./extensions/Blocks/api/blockTypes"; +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"; @@ -93,6 +94,7 @@ export const getBlockNoteExtensions = (opts: { BlockGroup.configure({ domAttributes: opts.domAttributes, }), + TableExtension, ...Object.values(opts.blockSchema).flatMap((blockSpec) => { return [ // dependent nodes (e.g.: tablecell / row) diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index c28796e059..b379ce584b 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -1,4 +1,4 @@ -import { Node, callOrReturn, getExtensionField } from "@tiptap/core"; +import { Node } from "@tiptap/core"; import { Fragment, Node as PMNode, Slice } from "prosemirror-model"; import { NodeSelection, TextSelection } from "prosemirror-state"; @@ -656,18 +656,4 @@ export const BlockContainer = Node.create<{ ), }; }, - - extendNodeSchema(extension) { - const context = { - name: extension.name, - options: extension.options, - storage: extension.storage, - }; - - return { - tableRole: callOrReturn( - getExtensionField(extension, "tableRole", context) - ), - }; - }, }); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts new file mode 100644 index 0000000000..ba974d6aae --- /dev/null +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts @@ -0,0 +1,29 @@ +import { Extension, callOrReturn, getExtensionField } from "@tiptap/core"; +import { columnResizing, tableEditing } from "prosemirror-tables"; + +export const TableExtension = Extension.create({ + name: "BlockNoteTableExtension", + + addProseMirrorPlugins: () => { + return [ + columnResizing({ + cellMinWidth: 100, + }), + tableEditing(), + ]; + }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + }; + + return { + tableRole: callOrReturn( + getExtensionField(extension, "tableRole", context) + ), + }; + }, +}); From cfb326fccaee92858850d76df525b2ec57be5d84 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 17 Nov 2023 11:30:15 +0100 Subject: [PATCH 11/30] improve types --- package-lock.json | 9 ++-- package.json | 2 +- .../core/src/extensions/Blocks/api/block.ts | 4 +- .../src/extensions/Blocks/api/blockTypes.ts | 33 ++++++++++----- .../src/extensions/Blocks/api/customBlocks.ts | 4 +- .../ImageBlockContent/ImageBlockContent.ts | 2 +- .../SlashMenu/defaultSlashMenuItems.ts | 1 + packages/react/src/BlockNoteView.tsx | 2 +- packages/react/src/ReactBlockSpec.tsx | 4 +- .../components/DefaultTableHandle.tsx | 42 +++++++++---------- .../components/TableHandlePositioner.tsx | 38 +++++++++-------- 11 files changed, 79 insertions(+), 62 deletions(-) diff --git a/package-lock.json b/package-lock.json index bd53877c0b..9da000e7e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "eslint-plugin-import": "^2.28.0", "lerna": "^5.4.0", "patch-package": "^6.4.7", - "typescript": "^5.0.4" + "typescript": "^5.2.2" } }, "examples/editor": { @@ -18567,15 +18567,16 @@ } }, "node_modules/typescript": { - "version": "5.0.4", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=14.17" } }, "node_modules/uc.micro": { diff --git a/package.json b/package.json index 74b0a5febe..170c4a0c2a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "eslint-config-react-app": "^7.0.0", "lerna": "^5.4.0", "patch-package": "^6.4.7", - "typescript": "^5.0.4", + "typescript": "^5.2.2", "@typescript-eslint/parser": "^5.5.0", "@typescript-eslint/eslint-plugin": "^5.5.0" }, diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index b0efea33ac..75a045a39c 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -126,8 +126,8 @@ export function parse(blockConfig: BlockConfig) { // create the node view. export function getBlockFromPos< BType extends string, - PSchema extends PropSchema, - BSchema extends BlockSchemaWithBlock + Config extends BlockConfig, + BSchema extends BlockSchemaWithBlock >( getPos: (() => number) | boolean, editor: BlockNoteEditor, diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index c4897cdfa2..9042d348e7 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -1,7 +1,6 @@ /** Define the main block types **/ import { Node } from "@tiptap/core"; -import { BlockNoteEditor } from "../../.."; -import { DefaultBlockSchema } from "./defaultBlocks"; +import { BlockNoteEditor, DefaultBlockSchema } from "../../.."; import { InlineContent, PartialInlineContent } from "./inlineContentTypes"; export type BlockNoteDOMElement = @@ -62,14 +61,14 @@ export type TiptapBlockImplementation = { node: Node; toInternalHTML: ( block: Block, - editor: BlockNoteEditor> + editor: BlockNoteEditor> ) => { dom: HTMLElement; contentDOM?: HTMLElement; }; toExternalHTML: ( block: Block, - editor: BlockNoteEditor> + editor: BlockNoteEditor> ) => { dom: HTMLElement; contentDOM?: HTMLElement; @@ -105,11 +104,9 @@ export type BlockSchema = NamesMatch>>; export type BlockSchemaWithBlock< BType extends string, - PSchema extends PropSchema -> = BlockSchema & { - [k in BType]: BlockConfig & { - propSchema: PSchema; - }; + C extends BlockConfig +> = { + [k in BType]: BlockSpec; }; export type TableContent = { @@ -137,7 +134,9 @@ type BlockFromBlockConfig = { ? InlineContent[] : B["content"] extends "table" ? TableContent - : undefined; + : B["content"] extends "none" + ? undefined + : never; }; // Converts each block spec into a Block object without children. We later merge @@ -190,12 +189,26 @@ type PartialBlocksWithoutChildren = { // Same as Block, but as a partial type with some changes to make it easier to // create/update blocks in the editor. + export type PartialBlock = PartialBlocksWithoutChildren[keyof PartialBlocksWithoutChildren] & Partial<{ children: PartialBlock[]; }>; +// export type PartialBlock = +// T extends BlockSchema +// ? PartialBlocksWithoutChildren[keyof T] +// : T extends BlockConfig +// ? PartialBlockFromBlockConfig +// : never; + +// & { +// children?: PartialBlock< +// T extends BlockSchema ? T : any // any should probably be BlockSchemaWithBlock; +// >[]; +// }; + export type SpecificPartialBlock< BSchema extends BlockSchema, BType extends keyof BSchema diff --git a/packages/core/src/extensions/Blocks/api/customBlocks.ts b/packages/core/src/extensions/Blocks/api/customBlocks.ts index d02c3b11bf..fcff18a666 100644 --- a/packages/core/src/extensions/Blocks/api/customBlocks.ts +++ b/packages/core/src/extensions/Blocks/api/customBlocks.ts @@ -25,7 +25,7 @@ export type CustomBlockImplementation = { * This is typed generically. If you want an editor with your custom schema, you need to * cast it manually, e.g.: `const e = editor as BlockNoteEditor;` */ - editor: BlockNoteEditor> + editor: BlockNoteEditor> // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics ) => { @@ -39,7 +39,7 @@ export type CustomBlockImplementation = { // TODO: Maybe can return undefined to ignore when serializing? toExternalHTML?: ( block: Block, - editor: BlockNoteEditor> + editor: BlockNoteEditor> ) => { dom: HTMLElement; contentDOM?: HTMLElement; 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 2098bfd3b0..85f1d3592e 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -53,7 +53,7 @@ const blockConfig = { export const renderImage = ( block: Block, - editor: BlockNoteEditor> + editor: BlockNoteEditor> ) => { // Wrapper element to set the image alignment, contains both image/image // upload dashboard and caption. diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index b323f2e3d5..98f39ea9a1 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -121,6 +121,7 @@ export const getDefaultSlashMenuItems = ( content: { type: "tableContent", rows: [ + // TODO: replace with empty content before merging { cells: [ "ab", diff --git a/packages/react/src/BlockNoteView.tsx b/packages/react/src/BlockNoteView.tsx index e9ba1a4d01..1dddab9f30 100644 --- a/packages/react/src/BlockNoteView.tsx +++ b/packages/react/src/BlockNoteView.tsx @@ -37,7 +37,7 @@ function BaseBlockNoteView( - + )} diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 59f08c0cfb..ea992828af 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -30,11 +30,11 @@ import { renderToString } from "react-dom/server"; export type ReactCustomBlockImplementation = { render: FC<{ block: Block; - editor: BlockNoteEditor>; + editor: BlockNoteEditor>; }>; toExternalHTML?: FC<{ block: Block; - editor: BlockNoteEditor>; + editor: BlockNoteEditor>; }>; }; diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx index 3de1868ab6..845a45ec9a 100644 --- a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx @@ -1,13 +1,11 @@ -import { BlockSchema, TableContent } from "@blocknote/core"; +import { TableContent } from "@blocknote/core"; import { Menu } from "@mantine/core"; import { MdDragIndicator } from "react-icons/md"; import { SideMenuButton } from "../../SideMenu/components/SideMenuButton"; import { TableHandlesProps } from "./TableHandlePositioner"; -const DefaultTableHandleLeft = ( - props: TableHandlesProps -) => { +const DefaultTableHandleLeft = (props: TableHandlesProps) => { return ( ( ), }; - props.editor.updateBlock(props.block, { content } as any); // TODO: type + props.editor.updateBlock(props.block, { type: "table", content }); }}> Delete row @@ -45,12 +43,14 @@ const DefaultTableHandleLeft = ( rows.splice(props.rowIndex, 0, { cells: emptyCol, }); - const content: TableContent = { - type: "tableContent", - rows, - }; - props.editor.updateBlock(props.block, { content } as any); // TODO: type + props.editor.updateBlock(props.block, { + type: "table", + content: { + type: "tableContent", + rows, + }, + }); }}> Add row above @@ -65,12 +65,12 @@ const DefaultTableHandleLeft = ( cells: emptyCol, }); - const content: TableContent = { - type: "tableContent", - rows, - }; - - props.editor.updateBlock(props.block, { content } as any); // TODO: type + props.editor.updateBlock(props.block, { + content: { + type: "tableContent", + rows, + }, + }); }}> Add row below @@ -79,9 +79,7 @@ const DefaultTableHandleLeft = ( ); }; -const DefaultTableHandleTop = ( - props: TableHandlesProps -) => { +const DefaultTableHandleTop = (props: TableHandlesProps) => { return ( ( })), }; - props.editor.updateBlock(props.block, { content } as any); // TODO: type + props.editor.updateBlock(props.block, { content }); }}> Delete column @@ -125,7 +123,7 @@ const DefaultTableHandleTop = ( }), }; - props.editor.updateBlock(props.block, { content } as any); // TODO: type + props.editor.updateBlock(props.block, { content }); }}> Add column left @@ -140,7 +138,7 @@ const DefaultTableHandleTop = ( }), }; - props.editor.updateBlock(props.block, { content } as any); // TODO: type + props.editor.updateBlock(props.block, { content }); }}> Add column right diff --git a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx index 0210f9ece2..48e8952018 100644 --- a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx +++ b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx @@ -1,8 +1,7 @@ import { - BaseUiElementState, Block, BlockNoteEditor, - BlockSchema, + BlockSchemaWithBlock, DefaultBlockSchema, TableHandlesState, } from "@blocknote/core"; @@ -10,24 +9,28 @@ import Tippy, { tippy } from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; import { DefaultTableHandle } from "./DefaultTableHandle"; -export type TableHandlesProps< - BSchema extends BlockSchema = DefaultBlockSchema -> = Omit & { - colIndex: number; - rowIndex: number; - block: Block; - editor: BlockNoteEditor; +export type TableHandlesProps = Omit< + TableHandlesState, + "referencePosLeft" | "referencePosTop" | "show" +> & { + editor: BlockNoteEditor< + BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]["config"]> + >; side: "top" | "left"; }; export const TableHandlesPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema + BSchema extends BlockSchemaWithBlock< + "table", + DefaultBlockSchema["table"]["config"] + > >(props: { editor: BlockNoteEditor; - tableHandle?: FC>; + tableHandle?: FC; }) => { const [show, setShow] = useState(false); - const [block, setBlock] = useState>(); + const [block, setBlock] = + useState>(); const [colIndex, setColIndex] = useState(); const [rowIndex, setRowIndex] = useState(); const [_, setForceUpdate] = useState(0); @@ -41,7 +44,7 @@ export const TableHandlesPositioner = < return props.editor.tableHandles.onUpdate((state) => { console.log("update", state); setShow(state.show); - setBlock(state.block as any); // TODO: types + setBlock(state.block); setColIndex(state.colIndex); setRowIndex(state.rowIndex); setForceUpdate(Math.random()); @@ -78,11 +81,11 @@ export const TableHandlesPositioner = < return ( ); }, [block, props.editor, props.tableHandle, rowIndex, colIndex]); @@ -92,11 +95,12 @@ export const TableHandlesPositioner = < return ( ); }, [block, props.editor, props.tableHandle, rowIndex, colIndex]); From 226320a128a04f4ed47fa285fc1a14626a8584b8 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 17 Nov 2023 11:35:24 +0100 Subject: [PATCH 12/30] test some types --- .../blockManipulation.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/core/src/api/blockManipulation/blockManipulation.test.ts b/packages/core/src/api/blockManipulation/blockManipulation.test.ts index 4185f4b6b4..cc6733d5d2 100644 --- a/packages/core/src/api/blockManipulation/blockManipulation.test.ts +++ b/packages/core/src/api/blockManipulation/blockManipulation.test.ts @@ -76,6 +76,52 @@ afterEach(() => { editor = undefined as any; }); +describe("Test strong typing", () => { + it("checks that block types are inferred correctly", () => { + try { + editor.updateBlock( + { id: "sdf" }, + { + // @ts-expect-error invalid type + type: "non-existing", + } + ); + } catch (e) { + // id doesn't exists, which is fine, this is a compile-time check + } + }); + + it("checks that block props are inferred correctly", () => { + try { + editor.updateBlock( + { id: "sdf" }, + { + type: "paragraph", + props: { + // @ts-expect-error level not suitable for paragraph + level: 1, + }, + } + ); + } catch (e) { + // id doesn't exists, which is fine, this is a compile-time check + } + try { + editor.updateBlock( + { id: "sdf" }, + { + type: "heading", + props: { + level: 1, + }, + } + ); + } catch (e) { + // id doesn't exists, which is fine, this is a compile-time check + } + }); +}); + describe("Inserting Blocks with Different Placements", () => { it("Insert before existing block", async () => { await waitForEditor(); From 708d4e94d1667ae1d8844ee33efff66fd369300b Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 17 Nov 2023 17:39:02 +0100 Subject: [PATCH 13/30] Fixed setting selection for table blocks --- packages/core/src/BlockNoteEditor.ts | 32 +++++++++---- .../extensions/Blocks/nodes/BlockContainer.ts | 7 ++- .../SlashMenu/defaultSlashMenuItems.ts | 46 ++++++++++++++----- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 8510786585..475db6fcab 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -493,18 +493,34 @@ export class BlockNoteEditor { posBeforeNode + 2 )!; - // For blocks without inline content - if (contentNode.type.spec.content === "") { + const contentType: "none" | "inline" | "table" = + this.schema[contentNode.type.name].config.content; + + if (contentType === "none") { this._tiptapEditor.commands.setNodeSelection(startPos); return; } - if (placement === "start") { - this._tiptapEditor.commands.setTextSelection(startPos + 1); - } else { - this._tiptapEditor.commands.setTextSelection( - startPos + contentNode.nodeSize - 1 - ); + if (contentType === "inline") { + if (placement === "start") { + this._tiptapEditor.commands.setTextSelection(startPos + 1); + } else { + this._tiptapEditor.commands.setTextSelection( + startPos + contentNode.nodeSize - 1 + ); + } + } + + if (contentType === "table") { + if (placement === "start") { + console.log(startPos + 4); + this._tiptapEditor.commands.setTextSelection(startPos + 4); + } else { + console.log(startPos + contentNode.nodeSize - 4); + this._tiptapEditor.commands.setTextSelection( + startPos + contentNode.nodeSize - 1 + ); + } } } diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index b379ce584b..44071074b3 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -253,8 +253,13 @@ export const BlockContainer = Node.create<{ content ) ) + // If the node doesn't contain editable content, we want to + // select the whole node. But if it does have editable content, + // we want to set the selection to the start of it. .setSelection( - new NodeSelection(state.tr.doc.resolve(startPos)) + state.schema.nodes[newType].spec.content === "" + ? new NodeSelection(state.tr.doc.resolve(startPos)) + : new TextSelection(state.tr.doc.resolve(startPos)) ); } diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index b323f2e3d5..b03b885f3c 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -1,13 +1,34 @@ import { BlockNoteEditor } from "../../BlockNoteEditor"; -import { BlockSchema, PartialBlock } from "../Blocks/api/blockTypes"; +import { Block, BlockSchema, PartialBlock } from "../Blocks/api/blockTypes"; import { defaultBlockSchema } from "../Blocks/api/defaultBlocks"; import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin"; import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; +function setSelectionToNextContentEditableBlock( + editor: BlockNoteEditor +) { + let block = editor.getTextCursorPosition().block; + let contentType = editor.schema[block.type].config.content as + | "inline" + | "table" + | "none"; + + while (contentType === "none") { + editor.setTextCursorPosition(block, "start"); + block = editor.getTextCursorPosition().nextBlock!; + contentType = editor.schema[block.type].config.content as + | "inline" + | "table" + | "none"; + } + + editor.setTextCursorPosition(block, "start"); +} + function insertOrUpdateBlock( editor: BlockNoteEditor, block: PartialBlock -) { +): Block { const currentBlock = editor.getTextCursorPosition().block; if (currentBlock.content === undefined) { @@ -26,6 +47,11 @@ function insertOrUpdateBlock( editor.insertBlocks([block], currentBlock, "after"); editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!); } + + const insertedBlock = editor.getTextCursorPosition().block; + setSelectionToNextContentEditableBlock(editor); + + return insertedBlock; } export const getDefaultSlashMenuItems = ( @@ -115,7 +141,7 @@ export const getDefaultSlashMenuItems = ( slashMenuItems.push({ name: "Table", aliases: ["table"], - execute: (editor) => + execute: (editor) => { insertOrUpdateBlock(editor, { type: "table", content: { @@ -133,7 +159,8 @@ export const getDefaultSlashMenuItems = ( }, ], }, - } as PartialBlock), + } as PartialBlock); + }, }); } @@ -152,19 +179,14 @@ export const getDefaultSlashMenuItems = ( "dropbox", ], execute: (editor) => { - insertOrUpdateBlock(editor, { + const insertedBlock = insertOrUpdateBlock(editor, { type: "image", } as PartialBlock); - // Don't want to select the add image button, instead select the block - // below it - editor.setTextCursorPosition( - editor.getTextCursorPosition().nextBlock!, - "start" - ); + // Immediately open the image toolbar editor._tiptapEditor.view.dispatch( editor._tiptapEditor.state.tr.setMeta(imageToolbarPluginKey, { - block: editor.getTextCursorPosition().prevBlock, + block: insertedBlock, }) ); }, From c18e6f325e9917c22ef97ad09581293d8ff228b1 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 17 Nov 2023 17:52:22 +0100 Subject: [PATCH 14/30] Fixed backspace deleting table if at start of cell --- .../TableBlockContent/TableBlockContent.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 f24ff7ce4d..6873743b79 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts @@ -41,6 +41,25 @@ export const TableBlockContent = createStronglyTypedTiptapNode({ const TableParagraph = Paragraph.extend({ name: "tableParagraph", group: "tableContent", + addKeyboardShortcuts() { + return { + // Ensures that backspace won't delete the table if the text cursor is at + // the start of a cell and the selection is empty. + Backspace: () => { + const selection = this.editor.state.selection; + const selectionIsEmpty = selection.empty; + const selectionIsAtStartOfNode = selection.$head.parentOffset === 0; + const selectionIsInTableParagraphNode = + selection.$head.node().type.name === "tableParagraph"; + + return ( + selectionIsEmpty && + selectionIsAtStartOfNode && + selectionIsInTableParagraphNode + ); + }, + }; + }, }); export const Table = createBlockSpecFromStronglyTypedTiptapNode( From 0c9f6207c93c5acb8ffa7d9ed9464d73c1862ebf Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 20 Nov 2023 10:28:57 +0100 Subject: [PATCH 15/30] small code fixes --- packages/core/src/BlockNoteEditor.ts | 12 +++++------ .../TableBlockContent/TableBlockContent.ts | 19 ------------------ .../TableBlockContent/TableExtension.ts | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 475db6fcab..2f1b79c23b 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -46,7 +46,7 @@ import { SlashMenuProsemirrorPlugin } from "./extensions/SlashMenu/SlashMenuPlug import { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; import { TableHandlesProsemirrorPlugin } from "./extensions/TableHandles/TableHandlesPlugin"; import { UniqueID } from "./extensions/UniqueID/UniqueID"; -import { mergeCSSClasses } from "./shared/utils"; +import { UnreachableCaseError, mergeCSSClasses } from "./shared/utils"; export type BlockNoteEditorOptions = { // TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them. @@ -494,7 +494,7 @@ export class BlockNoteEditor { )!; const contentType: "none" | "inline" | "table" = - this.schema[contentNode.type.name].config.content; + this.schema[contentNode.type.name]!.config.content; if (contentType === "none") { this._tiptapEditor.commands.setNodeSelection(startPos); @@ -509,18 +509,16 @@ export class BlockNoteEditor { startPos + contentNode.nodeSize - 1 ); } - } - - if (contentType === "table") { + } else if (contentType === "table") { if (placement === "start") { - console.log(startPos + 4); this._tiptapEditor.commands.setTextSelection(startPos + 4); } else { - console.log(startPos + contentNode.nodeSize - 4); this._tiptapEditor.commands.setTextSelection( startPos + contentNode.nodeSize - 1 ); } + } else { + throw new UnreachableCaseError(contentType); } } 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 6873743b79..f24ff7ce4d 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts @@ -41,25 +41,6 @@ export const TableBlockContent = createStronglyTypedTiptapNode({ const TableParagraph = Paragraph.extend({ name: "tableParagraph", group: "tableContent", - addKeyboardShortcuts() { - return { - // Ensures that backspace won't delete the table if the text cursor is at - // the start of a cell and the selection is empty. - Backspace: () => { - const selection = this.editor.state.selection; - const selectionIsEmpty = selection.empty; - const selectionIsAtStartOfNode = selection.$head.parentOffset === 0; - const selectionIsInTableParagraphNode = - selection.$head.node().type.name === "tableParagraph"; - - return ( - selectionIsEmpty && - selectionIsAtStartOfNode && - selectionIsInTableParagraphNode - ); - }, - }; - }, }); export const Table = createBlockSpecFromStronglyTypedTiptapNode( diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts index ba974d6aae..1352bd6f09 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts @@ -13,6 +13,26 @@ export const TableExtension = Extension.create({ ]; }, + addKeyboardShortcuts() { + return { + // Ensures that backspace won't delete the table if the text cursor is at + // the start of a cell and the selection is empty. + Backspace: () => { + const selection = this.editor.state.selection; + const selectionIsEmpty = selection.empty; + const selectionIsAtStartOfNode = selection.$head.parentOffset === 0; + const selectionIsInTableParagraphNode = + selection.$head.node().type.name === "tableParagraph"; + + return ( + selectionIsEmpty && + selectionIsAtStartOfNode && + selectionIsInTableParagraphNode + ); + }, + }; + }, + extendNodeSchema(extension) { const context = { name: extension.name, From 0cadfd01f5f669fab245f7be7ab7b513f82e1ba3 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 20 Nov 2023 18:19:45 +0100 Subject: [PATCH 16/30] Implemented PR feedback --- packages/core/src/BlockNoteEditor.ts | 7 ++++--- .../extensions/Blocks/nodes/BlockContainer.ts | 7 ++++++- .../SlashMenu/defaultSlashMenuItems.ts | 17 +++++++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 475db6fcab..229c476ab1 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -513,12 +513,13 @@ export class BlockNoteEditor { if (contentType === "table") { if (placement === "start") { - console.log(startPos + 4); + // Need to offset the position as we have to get through the `tableRow` + // and `tableCell` nodes to get to the `tableParagraph` node we want to + // set the selection in. this._tiptapEditor.commands.setTextSelection(startPos + 4); } else { - console.log(startPos + contentNode.nodeSize - 4); this._tiptapEditor.commands.setTextSelection( - startPos + contentNode.nodeSize - 1 + startPos + contentNode.nodeSize - 4 ); } } diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index 44071074b3..499f81c160 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -259,7 +259,12 @@ export const BlockContainer = Node.create<{ .setSelection( state.schema.nodes[newType].spec.content === "" ? new NodeSelection(state.tr.doc.resolve(startPos)) - : new TextSelection(state.tr.doc.resolve(startPos)) + : state.schema.nodes[newType].spec.content === "inline*" + ? new TextSelection(state.tr.doc.resolve(startPos)) + : // Need to offset the position as we have to get through the + // `tableRow` and `tableCell` nodes to get to the + // `tableParagraph` node we want to set the selection in. + new TextSelection(state.tr.doc.resolve(startPos + 4)) ); } diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index 130c691da2..d4f1c14824 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -4,6 +4,10 @@ import { defaultBlockSchema } from "../Blocks/api/defaultBlocks"; import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin"; import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; +// Sets the editor's text cursor position to the next content editable block, +// so either a block with inline content or a table. The last block is always a +// paragraph, so this function won't try to set the cursor position past the +// last block. function setSelectionToNextContentEditableBlock( editor: BlockNoteEditor ) { @@ -14,17 +18,19 @@ function setSelectionToNextContentEditableBlock( | "none"; while (contentType === "none") { - editor.setTextCursorPosition(block, "start"); + editor.setTextCursorPosition(block, "end"); block = editor.getTextCursorPosition().nextBlock!; contentType = editor.schema[block.type].config.content as | "inline" | "table" | "none"; } - - editor.setTextCursorPosition(block, "start"); } +// Checks if the current block is empty or only contains a slash, and if so, +// updates the current block instead of inserting a new one below. If the new +// block doesn't contain editable content, the cursor is moved to the next block +// that does. function insertOrUpdateBlock( editor: BlockNoteEditor, block: PartialBlock @@ -45,7 +51,10 @@ function insertOrUpdateBlock( editor.updateBlock(currentBlock, block); } else { editor.insertBlocks([block], currentBlock, "after"); - editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!); + editor.setTextCursorPosition( + editor.getTextCursorPosition().nextBlock!, + "end" + ); } const insertedBlock = editor.getTextCursorPosition().block; From d9ac4d10d077c2dd5c63b50ca8b4ff89323a3a03 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 22 Nov 2023 23:33:18 +0100 Subject: [PATCH 17/30] Improved table row/column drag & drop UX --- packages/core/src/editor.css | 7 + .../TableHandles/TableHandlesPlugin.ts | 454 +++++++++++++++--- .../components/DefaultTableHandle.tsx | 10 +- .../components/TableHandlePositioner.tsx | 118 ++++- 4 files changed, 486 insertions(+), 103 deletions(-) diff --git a/packages/core/src/editor.css b/packages/core/src/editor.css index 8a0f691830..2dac0db661 100644 --- a/packages/core/src/editor.css +++ b/packages/core/src/editor.css @@ -54,6 +54,13 @@ Tippy popups that are appended to document.body directly -moz-osx-font-smoothing: grayscale; } +.bn-table-drop-cursor { + position: absolute; + z-index: 20; + background-color: #adf; + pointer-events: none; +} + .bn-drag-preview { position: absolute; left: -100000px; diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index 01ba195d5a..b42435c207 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -1,5 +1,5 @@ import { Plugin, PluginKey } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; +import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { BaseUiElementCallbacks, BlockNoteEditor, @@ -8,36 +8,62 @@ import { } from "../.."; import { EventEmitter } from "../../shared/EventEmitter"; import { Block } from "../Blocks/api/blockTypes"; -import { Image } from "../Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent"; import { Table } from "../Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent"; +import { nodeToBlock } from "../../api/nodeConversions/nodeConversions"; export type TableHandlesCallbacks = BaseUiElementCallbacks; export type TableHandlesState = { show: boolean; - referencePosTop: { top: number; left: number }; - referencePosLeft: { top: number; left: number }; + referencePosCell: DOMRect; + referencePosTable: DOMRect; + + block: Block<(typeof Table)["config"]>; colIndex: number; rowIndex: number; - block: Block<(typeof Table)["config"]>; + + isDragging: + | { + draggedCellOrientation: "row" | "col"; + mousePos: number; + } + | undefined; }; function getChildIndex(node: HTMLElement) { return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); } +// Finds the DOM element corresponding to the table cell that the target element +// is currently in. If the target element is not in a table cell, returns null. function domCellAround(target: HTMLElement | null): HTMLElement | null { - while (target && target.nodeName !== "TD" && target.nodeName !== "TH") + while (target && target.nodeName !== "TD" && target.nodeName !== "TH") { target = target.classList && target.classList.contains("ProseMirror") ? null : (target.parentNode as HTMLElement); + } return target; } +// Hides elements in the DOMwith the provided class names. +function hideElementsWithClassNames(classNames: string[]) { + classNames.forEach((className) => { + const elementsToHide = document.getElementsByClassName(className); + for (let i = 0; i < elementsToHide.length; i++) { + (elementsToHide[i] as HTMLElement).style.visibility = "hidden"; + } + }); +} + export class TableHandlesView { - private state?: TableHandlesState; + public state?: TableHandlesState; public updateState: () => void; + public tableId: string | undefined; + public tablePos: number | undefined; + + public menuFrozen = false; + public prevWasEditable: boolean | null = null; constructor( @@ -56,16 +82,12 @@ export class TableHandlesView { }; pmView.dom.addEventListener("mousemove", this.mouseMoveHandler); - - // pmView.dom.addEventListener("dragstart", this.dragstartHandler); - - // pmView.dom.addEventListener("blur", this.blurHandler); + document.addEventListener("dragover", this.dragOverHandler); // document.addEventListener("scroll", this.scrollHandler); } mouseMoveHandler = (event: MouseEvent) => { - // console.log("mousemove"); const target = domCellAround(event.target as HTMLElement); if (!target) { @@ -83,56 +105,142 @@ export class TableHandlesView { target.parentElement!.parentElement!.getBoundingClientRect(); const blockEl = getDraggableBlockFromCoords(cellRect, this.pmView); - const block = this.editor.getBlock(blockEl!.id)! as Block< - (typeof Table)["config"] - >; + if (!blockEl) { + throw new Error( + "Found table cell element, but could not find surrounding blockContent element." + ); + } + this.tableId = blockEl.id; + + if ( + this.state !== undefined && + this.tableId === blockEl.id && + this.state.rowIndex === rowIndex && + this.state.colIndex === colIndex + ) { + return; + } + + let block: Block | undefined = undefined; + + // Copied from `getBlock`. We don't use `getBlock` since we also need the PM + // node for the table, so we would effectively be doing the same work twice. + this.editor._tiptapEditor.state.doc.descendants((node, pos) => { + if (typeof block !== "undefined") { + return false; + } + + if (node.type.name !== "blockContainer" || node.attrs.id !== blockEl.id) { + return true; + } + + block = nodeToBlock(node, this.editor.schema, this.editor.blockCache); + this.tablePos = pos + 1; + + return false; + }); this.state = { - referencePosLeft: { - top: cellRect.top + cellRect.height / 2, - left: tableRect.left, - }, - referencePosTop: { - top: tableRect.top, - left: cellRect.left + cellRect.width / 2, - }, - colIndex, - rowIndex, show: true, - block, + referencePosCell: cellRect, + referencePosTable: tableRect, + + block: block! as Block<(typeof Table)["config"]>, + colIndex: colIndex, + rowIndex: rowIndex, + + isDragging: undefined, }; this.updateState(); + + return false; }; - // // For dragging the whole editor. - // dragstartHandler = () => { - // if (this.imageToolbarState?.show) { - // this.imageToolbarState.show = false; - // this.updateImageToolbar(); - // } - // }; + dragOverHandler = (event: DragEvent) => { + if (this.state?.isDragging === undefined) { + return; + } - // blurHandler = (event: FocusEvent) => { - // const editorWrapper = this.pmView.dom.parentElement!; - - // // Checks if the focus is moving to an element outside the editor. If it is, - // // the toolbar is hidden. - // if ( - // // An element is clicked. - // event && - // event.relatedTarget && - // // Element is inside the editor. - // (editorWrapper === (event.relatedTarget as Node) || - // editorWrapper.contains(event.relatedTarget as Node)) - // ) { - // return; - // } + hideElementsWithClassNames([ + "column-resize-handle", + "prosemirror-dropcursor-block", + "prosemirror-dropcursor-inline", + ]); + + // The mouse coordinates, bounded to the table's bounding box. + const boundedMouseCoords = { + left: Math.min( + Math.max(event.clientX, this.state.referencePosTable.left), + this.state.referencePosTable.right + ), + top: Math.min( + Math.max(event.clientY, this.state.referencePosTable.top), + this.state.referencePosTable.bottom + ), + }; + const mousePos = + this.state.isDragging.draggedCellOrientation === "row" + ? boundedMouseCoords.top + : boundedMouseCoords.left; + + // Gets the ProseMirror position corresponding to the projected mouse + // coordinates. + let proseMirrorPos = this.pmView.posAtCoords(boundedMouseCoords)!.pos; + let resolvedPos = this.pmView.state.doc.resolve(proseMirrorPos); + + // Gets the ProseMirror node type at `proseMirrorPos`. This node will always + // be either a `tableParagraph` or `tableCell`. + let nodeType = resolvedPos.node().type.name; + + // If the node type is `tableParagraph`, we need to get a position in its + // parent node (the `tableCell`). We use `before()` to get this position. + if (nodeType === "tableParagraph") { + proseMirrorPos = resolvedPos.before(); + resolvedPos = this.pmView.state.doc.resolve(proseMirrorPos); + nodeType = resolvedPos.node().type.name; + } - // if (this.imageToolbarState?.show) { - // this.imageToolbarState.show = false; - // this.updateImageToolbar(); - // } - // }; + // Finally, we get the row/column index corresponding to the mouse position. + const rowIndex = resolvedPos.index(resolvedPos.depth - 2); + const colIndex = resolvedPos.index(resolvedPos.depth - 1); + const newIndex = + this.state.isDragging.draggedCellOrientation === "row" + ? rowIndex + : colIndex; + + const referencePosCell = ( + this.pmView.nodeDOM(resolvedPos.before()) as HTMLElement + ).getBoundingClientRect(); + + // Since `dragOver` events continually fire, we want to make sure that + // updates only trigger when the fields actually change. + const needsUpdate = + this.state.rowIndex !== rowIndex || + this.state.colIndex !== colIndex || + this.state.isDragging.mousePos !== mousePos; + + if (needsUpdate) { + // TODO: This should always be `tableCell`, but sometimes it's `table`. + // This happens in the topmost and leftmost cells, in the gap between the + // text and the edge of the table. This is a bit of a hack to prevent it + // from making the reference DOMRect incorrect. + if (nodeType === "tableCell") { + this.state.referencePosCell = referencePosCell; + } + this.state.rowIndex = rowIndex; + this.state.colIndex = colIndex; + + this.state.isDragging.mousePos = mousePos; + + this.updateState(); + + this.pmView.dispatch( + this.pmView.state.tr.setMeta(tableHandlesPluginKey, { + newIndex: newIndex, + }) + ); + } + }; // scrollHandler = () => { // if (this.imageToolbarState?.show) { @@ -150,20 +258,20 @@ export class TableHandlesView { // const pluginState: { // block: Block<(typeof Image)["config"]>; // } = this.pluginKey.getState(view.state); - + // // if (!this.imageToolbarState?.show && pluginState.block) { // const blockElement = document.querySelector( // `[data-node-type="blockContainer"][data-id="${pluginState.block.id}"]` // )!; - + // // this.imageToolbarState = { // show: true, // referencePos: blockElement.getBoundingClientRect(), // block: pluginState.block, // }; - + // // this.updateImageToolbar(); - + // // return; // } @@ -181,10 +289,7 @@ export class TableHandlesView { destroy() { this.pmView.dom.removeEventListener("mousedown", this.mouseMoveHandler); - - // this.pmView.dom.removeEventListener("dragstart", this.dragstartHandler); - - // this.pmView.dom.removeEventListener("blur", this.blurHandler); + document.removeEventListener("dragover", this.dragOverHandler); // document.removeEventListener("scroll", this.scrollHandler); } @@ -198,11 +303,9 @@ export class TableHandlesProsemirrorPlugin< private view: TableHandlesView | undefined; public readonly plugin: Plugin; - constructor(editor: BlockNoteEditor) { + constructor(private readonly editor: BlockNoteEditor) { super(); - this.plugin = new Plugin<{ - block: Block<(typeof Image)["config"]> | undefined; - }>({ + this.plugin = new Plugin({ key: tableHandlesPluginKey, view: (editorView) => { this.view = new TableHandlesView( @@ -215,19 +318,138 @@ export class TableHandlesProsemirrorPlugin< ); return this.view; }, + // We use decorations to render the drop cursor when dragging a table row + // or column. The decorations are updated in the `dragOverHandler` method. + props: { + decorations: (state) => { + const pluginState = tableHandlesPluginKey.getState(state); + + if (pluginState === undefined) { + return; + } + + const decorations: Decoration[] = []; + + if (pluginState.newIndex === pluginState.originalIndex) { + return DecorationSet.create(state.doc, decorations); + } + + // Gets the table to show the drop cursor in. + const tableResolvedPos = state.doc.resolve(pluginState.tablePos + 1); + const tableNode = tableResolvedPos.node(); + + if (pluginState.draggedCellOrientation === "row") { + // Gets the row at the new index. + const rowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(pluginState.newIndex) + 1 + ); + const rowNode = rowResolvedPos.node(); + + // Iterates over all cells in the row. + for (let i = 0; i < rowNode.childCount; i++) { + // Gets each cell in the row. + const cellResolvedPos = state.doc.resolve( + rowResolvedPos.posAtIndex(i) + 1 + ); + const cellNode = cellResolvedPos.node(); + + // Creates a decoration at the start or end of each cell, + // depending on whether the new index is before or after the + // original index. + const decorationPos = + cellResolvedPos.pos + + (pluginState.newIndex > pluginState.originalIndex + ? cellNode.nodeSize - 2 + : 0); + decorations.push( + // The widget is a small bar which spans the width of the cell. + Decoration.widget(decorationPos, () => { + const widget = document.createElement("div"); + widget.className = "bn-table-drop-cursor"; + widget.style.left = "0"; + widget.style.right = "0"; + if (pluginState.newIndex > pluginState.originalIndex) { + widget.style.bottom = "-2px"; + } else { + widget.style.top = "-3px"; + } + widget.style.height = "4px"; + + return widget; + }) + ); + } + } else { + // Iterates over all rows in the table. + for (let i = 0; i < tableNode.childCount; i++) { + // Gets each row in the table. + const rowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(i) + 1 + ); + + // Gets the cell at the new index in the row. + const cellResolvedPos = state.doc.resolve( + rowResolvedPos.posAtIndex(pluginState.newIndex) + 1 + ); + const cellNode = cellResolvedPos.node(); + + // Creates a decoration at the start or end of each cell, + // depending on whether the new index is before or after the + // original index. + const decorationPos = + cellResolvedPos.pos + + (pluginState.newIndex > pluginState.originalIndex + ? cellNode.nodeSize - 2 + : 0); + decorations.push( + // The widget is a small bar which spans the height of the cell. + Decoration.widget(decorationPos, () => { + const widget = document.createElement("div"); + widget.className = "bn-table-drop-cursor"; + widget.style.top = "0"; + widget.style.bottom = "0"; + if (pluginState.newIndex > pluginState.originalIndex) { + widget.style.right = "-2px"; + } else { + widget.style.left = "-3px"; + } + widget.style.width = "4px"; + + return widget; + }) + ); + } + } + + return DecorationSet.create(state.doc, decorations); + }, + }, + // For the view to be able to pass data to update the decorations, we need + // to use a state field, where we store drag & drop information. state: { - init: () => { - return { - block: undefined, - }; + init() { + return undefined; }, - apply: (transaction) => { - const block: Block<(typeof Image)["config"]> | undefined = - transaction.getMeta(tableHandlesPluginKey)?.block; + apply(transaction, prev) { + const isDragging = transaction.getMeta(tableHandlesPluginKey); + + // If the transaction contains new table drag & drop information, we + // update the existing state with the new information. + if (isDragging) { + return { + // The state may be undefined + ...(prev || {}), + ...isDragging, + }; + } - return { - block, - }; + // If the transaction contains null table drag & drop information, we + // clear the state. + if (isDragging === null) { + return undefined; + } + + return prev; }, }, }); @@ -236,4 +458,86 @@ export class TableHandlesProsemirrorPlugin< public onUpdate(callback: (state: TableHandlesState) => void) { return this.on("update", callback); } + + colDragStart = (event: { + dataTransfer: DataTransfer | null; + clientX: number; + }) => { + if (this.view!.state === undefined) { + throw new Error( + "Attempted to drag table column, but no table block was hovered prior." + ); + } + + this.view!.state.isDragging = { + draggedCellOrientation: "col", + mousePos: event.clientX, + }; + this.view!.updateState(); + + this.editor._tiptapEditor.view.dispatch( + this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { + draggedCellOrientation: + this.view!.state.isDragging.draggedCellOrientation, + originalIndex: this.view!.state.colIndex, + newIndex: this.view!.state.colIndex, + tablePos: this.view!.tablePos, + }) + ); + }; + + rowDragStart = (event: { + dataTransfer: DataTransfer | null; + clientY: number; + }) => { + if (this.view!.state === undefined) { + throw new Error( + "Attempted to drag table row, but no table block was hovered prior." + ); + } + + this.view!.state.isDragging = { + draggedCellOrientation: "row", + mousePos: event.clientY, + }; + this.view!.updateState(); + + this.editor._tiptapEditor.view.dispatch( + this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { + draggedCellOrientation: + this.view!.state.isDragging.draggedCellOrientation, + originalIndex: this.view!.state.rowIndex, + newIndex: this.view!.state.rowIndex, + tablePos: this.view!.tablePos, + }) + ); + }; + + dragEnd = () => { + if (this.view!.state === undefined) { + throw new Error( + "Attempted to drag table row, but no table block was hovered prior." + ); + } + + this.view!.state.isDragging = undefined; + this.view!.updateState(); + + this.editor._tiptapEditor.view.dispatch( + this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, null) + ); + }; + + // /** + // * Freezes the side menu. When frozen, the side menu will stay + // * attached to the same block regardless of which block is hovered by the + // * mouse cursor. + // */ + // freezeMenu = () => (this.sideMenuView!.menuFrozen = true); + // /** + // * Unfreezes the side menu. When frozen, the side menu will stay + // * attached to the same block regardless of which block is hovered by the + // * mouse cursor. + // */ + // unfreezeMenu = () => (this.sideMenuView!.menuFrozen = false); } diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx index 845a45ec9a..efc5de37c7 100644 --- a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx @@ -14,7 +14,10 @@ const DefaultTableHandleLeft = (props: TableHandlesProps) => { position={"right"}> -
+
@@ -88,7 +91,10 @@ const DefaultTableHandleTop = (props: TableHandlesProps) => { position={"bottom"}> -
+
& { - editor: BlockNoteEditor< - BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]["config"]> - >; - side: "top" | "left"; -}; +export type TableHandlesProps< + BSchema extends BlockSchema = DefaultBlockSchema +> = Pick< + TableHandlesProsemirrorPlugin, + "rowDragStart" | "colDragStart" | "dragEnd" +> & + Omit< + TableHandlesState, + "referencePosCell" | "referencePosTable" | "show" | "isDragging" + > & { + editor: BlockNoteEditor< + BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]["config"]> + >; + side: "top" | "left"; + }; export const TableHandlesPositioner = < BSchema extends BlockSchemaWithBlock< @@ -33,47 +41,94 @@ export const TableHandlesPositioner = < useState>(); const [colIndex, setColIndex] = useState(); const [rowIndex, setRowIndex] = useState(); + + const [draggedCellOrientation, setDraggedCellOrientation] = useState< + "row" | "col" | undefined + >(undefined); + const [mousePos, setMousePos] = useState(); + const [_, setForceUpdate] = useState(0); - const referencePosLeft = useRef(); - const referencePosTop = useRef(); + const referencePosCell = useRef(); + const referencePosTable = useRef(); useEffect(() => { tippy.setDefaultProps({ maxWidth: "" }); return props.editor.tableHandles.onUpdate((state) => { - console.log("update", state); + // console.log("update", state); setShow(state.show); setBlock(state.block); setColIndex(state.colIndex); setRowIndex(state.rowIndex); + + if (state.isDragging) { + setDraggedCellOrientation(state.isDragging.draggedCellOrientation); + setMousePos(state.isDragging.mousePos); + } else { + setDraggedCellOrientation(undefined); + setMousePos(undefined); + } + setForceUpdate(Math.random()); - referencePosLeft.current = state.referencePosLeft; - referencePosTop.current = state.referencePosTop; + referencePosCell.current = state.referencePosCell; + referencePosTable.current = state.referencePosTable; }); }, [props.editor]); - const getReferenceClientRectLeft = useMemo( + const getReferenceClientRectRow = useMemo( () => { - if (!referencePosLeft.current) { + if (!referencePosCell.current || !referencePosTable.current) { return undefined; } + + if (draggedCellOrientation === "row") { + return () => + new DOMRect( + referencePosTable.current!.x, + mousePos!, + referencePosTable.current!.width, + 0 + ); + } + return () => - ({ ...referencePosLeft.current!, width: 0, height: 0 } as DOMRect); + new DOMRect( + referencePosTable.current!.x, + referencePosCell.current!.y, + referencePosTable.current!.width, + referencePosCell.current!.height + ); }, - [referencePosLeft.current] // eslint-disable-line + [referencePosTable.current, draggedCellOrientation, mousePos] // eslint-disable-line ); - const getReferenceClientRectTop = useMemo( + const getReferenceClientRectColumn = useMemo( () => { - if (!referencePosTop.current) { + if (!referencePosCell.current || !referencePosTable.current) { return undefined; } + + if (draggedCellOrientation === "col") { + return () => + new DOMRect( + mousePos!, + referencePosTable.current!.y, + 0, + referencePosTable.current!.height + ); + } + return () => - ({ ...referencePosTop.current!, width: 0, height: 0 } as DOMRect); + new DOMRect( + referencePosCell.current!.x, + referencePosTable.current!.y, + referencePosCell.current!.width, + referencePosTable.current!.height + ); }, - [referencePosTop.current] // eslint-disable-line + [referencePosTable.current, draggedCellOrientation, mousePos] // eslint-disable-line ); const tableHandleElementTop = useMemo(() => { @@ -86,6 +141,9 @@ export const TableHandlesPositioner = < rowIndex={rowIndex!} colIndex={colIndex!} block={block!} + colDragStart={props.editor.tableHandles.colDragStart} + rowDragStart={props.editor.tableHandles.rowDragStart} + dragEnd={props.editor.tableHandles.dragEnd} /> ); }, [block, props.editor, props.tableHandle, rowIndex, colIndex]); @@ -101,6 +159,9 @@ export const TableHandlesPositioner = < rowIndex={rowIndex!} colIndex={colIndex!} block={block!} + rowDragStart={props.editor.tableHandles.rowDragStart} + colDragStart={props.editor.tableHandles.colDragStart} + dragEnd={props.editor.tableHandles.dragEnd} /> ); }, [block, props.editor, props.tableHandle, rowIndex, colIndex]); @@ -110,23 +171,28 @@ export const TableHandlesPositioner = < ); }; + +const rowOffset: [number, number] = [0, -12]; +const columnOffset: [number, number] = [0, -12]; From a2685e9c1f49e944ddd1141dad3a8faaff6f3c69 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 23 Nov 2023 19:31:53 +0100 Subject: [PATCH 18/30] Fixed table menus moving around, drag indicator flakiness, menu z-index issues, and drag preview --- .../TableHandles/TableHandlesPlugin.ts | 170 +++++++++++------- .../components/DefaultTableHandle.tsx | 22 ++- .../components/TableHandlePositioner.tsx | 28 ++- 3 files changed, 137 insertions(+), 83 deletions(-) diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index b42435c207..9cc7f4b88e 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -1,7 +1,6 @@ import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { - BaseUiElementCallbacks, BlockNoteEditor, BlockSchema, getDraggableBlockFromCoords, @@ -10,7 +9,26 @@ import { EventEmitter } from "../../shared/EventEmitter"; import { Block } from "../Blocks/api/blockTypes"; import { Table } from "../Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent"; import { nodeToBlock } from "../../api/nodeConversions/nodeConversions"; -export type TableHandlesCallbacks = BaseUiElementCallbacks; + +let dragImageElement: HTMLElement | undefined; + +function setHiddenDragImage() { + if (dragImageElement) { + return; + } + + dragImageElement = document.createElement("div"); + dragImageElement.innerHTML = "_"; + dragImageElement.style.visibility = "hidden"; + document.body.appendChild(dragImageElement); +} + +function unsetHiddenDragImage() { + if (dragImageElement) { + document.body.removeChild(dragImageElement); + dragImageElement = undefined; + } +} export type TableHandlesState = { show: boolean; @@ -29,18 +47,18 @@ export type TableHandlesState = { | undefined; }; -function getChildIndex(node: HTMLElement) { +function getChildIndex(node: Element) { return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); } // Finds the DOM element corresponding to the table cell that the target element // is currently in. If the target element is not in a table cell, returns null. -function domCellAround(target: HTMLElement | null): HTMLElement | null { +function domCellAround(target: Element | null): Element | null { while (target && target.nodeName !== "TD" && target.nodeName !== "TH") { target = target.classList && target.classList.contains("ProseMirror") ? null - : (target.parentNode as HTMLElement); + : (target.parentNode as Element); } return target; } @@ -88,6 +106,10 @@ export class TableHandlesView { } mouseMoveHandler = (event: MouseEvent) => { + if (this.menuFrozen) { + return; + } + const target = domCellAround(event.target as HTMLElement); if (!target) { @@ -161,84 +183,93 @@ export class TableHandlesView { return; } + event.preventDefault(); + event.dataTransfer!.dropEffect = "move"; + hideElementsWithClassNames([ "column-resize-handle", "prosemirror-dropcursor-block", "prosemirror-dropcursor-inline", ]); - // The mouse coordinates, bounded to the table's bounding box. + // The mouse cursor coordinates, bounded to the table's bounding box. The + // bounding box is shrunk by 1px on each side to ensure that the bounded + // coordinates are always inside a table cell. const boundedMouseCoords = { left: Math.min( - Math.max(event.clientX, this.state.referencePosTable.left), - this.state.referencePosTable.right + Math.max(event.clientX, this.state.referencePosTable.left + 1), + this.state.referencePosTable.right - 1 ), top: Math.min( - Math.max(event.clientY, this.state.referencePosTable.top), - this.state.referencePosTable.bottom + Math.max(event.clientY, this.state.referencePosTable.top + 1), + this.state.referencePosTable.bottom - 1 ), }; - const mousePos = - this.state.isDragging.draggedCellOrientation === "row" - ? boundedMouseCoords.top - : boundedMouseCoords.left; - // Gets the ProseMirror position corresponding to the projected mouse - // coordinates. - let proseMirrorPos = this.pmView.posAtCoords(boundedMouseCoords)!.pos; - let resolvedPos = this.pmView.state.doc.resolve(proseMirrorPos); - - // Gets the ProseMirror node type at `proseMirrorPos`. This node will always - // be either a `tableParagraph` or `tableCell`. - let nodeType = resolvedPos.node().type.name; - - // If the node type is `tableParagraph`, we need to get a position in its - // parent node (the `tableCell`). We use `before()` to get this position. - if (nodeType === "tableParagraph") { - proseMirrorPos = resolvedPos.before(); - resolvedPos = this.pmView.state.doc.resolve(proseMirrorPos); - nodeType = resolvedPos.node().type.name; + // Gets the table cell element that the bounded mouse cursor coordinates lie + // in. + const tableCellElements = document + .elementsFromPoint(boundedMouseCoords.left, boundedMouseCoords.top) + .filter( + (element) => element.tagName === "TD" || element.tagName === "TH" + ); + if (tableCellElements.length === 0) { + throw new Error( + "Could not find table cell element that the mouse cursor is hovering over." + ); } + const tableCellElement = tableCellElements[0]; + + let emitStateUpdate = false; + + // Gets current row and column index. + const rowIndex = getChildIndex(tableCellElement.parentElement!); + const colIndex = getChildIndex(tableCellElement); - // Finally, we get the row/column index corresponding to the mouse position. - const rowIndex = resolvedPos.index(resolvedPos.depth - 2); - const colIndex = resolvedPos.index(resolvedPos.depth - 1); + // Checks if the drop cursor needs to be updated and updates it. This + // affects decorations only so it doesn't trigger a state update. + const oldIndex = + this.state.isDragging.draggedCellOrientation === "row" + ? this.state.rowIndex + : this.state.colIndex; const newIndex = this.state.isDragging.draggedCellOrientation === "row" ? rowIndex : colIndex; + if (oldIndex !== newIndex) { + this.pmView.dispatch( + this.pmView.state.tr.setMeta(tableHandlesPluginKey, { + newIndex: newIndex, + }) + ); + } - const referencePosCell = ( - this.pmView.nodeDOM(resolvedPos.before()) as HTMLElement - ).getBoundingClientRect(); - - // Since `dragOver` events continually fire, we want to make sure that - // updates only trigger when the fields actually change. - const needsUpdate = - this.state.rowIndex !== rowIndex || - this.state.colIndex !== colIndex || - this.state.isDragging.mousePos !== mousePos; - - if (needsUpdate) { - // TODO: This should always be `tableCell`, but sometimes it's `table`. - // This happens in the topmost and leftmost cells, in the gap between the - // text and the edge of the table. This is a bit of a hack to prevent it - // from making the reference DOMRect incorrect. - if (nodeType === "tableCell") { - this.state.referencePosCell = referencePosCell; - } + // Checks if either the hovered cell has changed and updates the row and + // column index. Also updates the reference DOMRect. + if (this.state.rowIndex !== rowIndex || this.state.colIndex !== colIndex) { this.state.rowIndex = rowIndex; this.state.colIndex = colIndex; + this.state.referencePosCell = tableCellElement.getBoundingClientRect(); + + emitStateUpdate = true; + } + + // Checks if the mouse cursor position along the axis that the user is + // dragging on has changed and updates it. + const mousePos = + this.state.isDragging.draggedCellOrientation === "row" + ? boundedMouseCoords.top + : boundedMouseCoords.left; + if (this.state.isDragging.mousePos !== mousePos) { this.state.isDragging.mousePos = mousePos; - this.updateState(); + emitStateUpdate = true; + } - this.pmView.dispatch( - this.pmView.state.tr.setMeta(tableHandlesPluginKey, { - newIndex: newIndex, - }) - ); + // Emits a state update if any of the fields have changed. + if (emitStateUpdate) { + this.updateState(); } }; @@ -484,6 +515,10 @@ export class TableHandlesProsemirrorPlugin< tablePos: this.view!.tablePos, }) ); + + setHiddenDragImage(); + event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + event.dataTransfer!.effectAllowed = "move"; }; rowDragStart = (event: { @@ -511,6 +546,10 @@ export class TableHandlesProsemirrorPlugin< tablePos: this.view!.tablePos, }) ); + + setHiddenDragImage(); + event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + event.dataTransfer!.effectAllowed = "copyMove"; }; dragEnd = () => { @@ -526,18 +565,11 @@ export class TableHandlesProsemirrorPlugin< this.editor._tiptapEditor.view.dispatch( this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, null) ); + + unsetHiddenDragImage(); }; - // /** - // * Freezes the side menu. When frozen, the side menu will stay - // * attached to the same block regardless of which block is hovered by the - // * mouse cursor. - // */ - // freezeMenu = () => (this.sideMenuView!.menuFrozen = true); - // /** - // * Unfreezes the side menu. When frozen, the side menu will stay - // * attached to the same block regardless of which block is hovered by the - // * mouse cursor. - // */ - // unfreezeMenu = () => (this.sideMenuView!.menuFrozen = false); + freezeMenu = () => (this.view!.menuFrozen = true); + + unfreezeMenu = () => (this.view!.menuFrozen = false); } diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx index efc5de37c7..550b1f71d5 100644 --- a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx @@ -9,9 +9,14 @@ const DefaultTableHandleLeft = (props: TableHandlesProps) => { return ( { + props.freezeMenu(); + props.hideOtherSide(); + }} + onClose={() => { + props.unfreezeMenu(); + props.showOtherSide(); + }} position={"right"}>
{ return ( { + props.freezeMenu(); + props.hideOtherSide(); + }} + onClose={() => { + props.unfreezeMenu(); + props.showOtherSide(); + }} position={"bottom"}>
= Pick< TableHandlesProsemirrorPlugin, - "rowDragStart" | "colDragStart" | "dragEnd" + "rowDragStart" | "colDragStart" | "dragEnd" | "freezeMenu" | "unfreezeMenu" > & Omit< TableHandlesState, @@ -25,6 +25,8 @@ export type TableHandlesProps< BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]["config"]> >; side: "top" | "left"; + showOtherSide: () => void; + hideOtherSide: () => void; }; export const TableHandlesPositioner = < @@ -37,10 +39,12 @@ export const TableHandlesPositioner = < tableHandle?: FC; }) => { const [show, setShow] = useState(false); + const [hideRow, setHideRow] = useState(false); + const [hideCol, setHideCol] = useState(false); const [block, setBlock] = useState>(); - const [colIndex, setColIndex] = useState(); const [rowIndex, setRowIndex] = useState(); + const [colIndex, setColIndex] = useState(); const [draggedCellOrientation, setDraggedCellOrientation] = useState< "row" | "col" | undefined @@ -59,8 +63,8 @@ export const TableHandlesPositioner = < // console.log("update", state); setShow(state.show); setBlock(state.block); - setColIndex(state.colIndex); setRowIndex(state.rowIndex); + setColIndex(state.colIndex); if (state.isDragging) { setDraggedCellOrientation(state.isDragging.draggedCellOrientation); @@ -141,9 +145,13 @@ export const TableHandlesPositioner = < rowIndex={rowIndex!} colIndex={colIndex!} block={block!} - colDragStart={props.editor.tableHandles.colDragStart} rowDragStart={props.editor.tableHandles.rowDragStart} + colDragStart={props.editor.tableHandles.colDragStart} dragEnd={props.editor.tableHandles.dragEnd} + freezeMenu={props.editor.tableHandles.freezeMenu} + unfreezeMenu={props.editor.tableHandles.unfreezeMenu} + showOtherSide={() => setHideRow(false)} + hideOtherSide={() => setHideRow(true)} /> ); }, [block, props.editor, props.tableHandle, rowIndex, colIndex]); @@ -162,6 +170,10 @@ export const TableHandlesPositioner = < rowDragStart={props.editor.tableHandles.rowDragStart} colDragStart={props.editor.tableHandles.colDragStart} dragEnd={props.editor.tableHandles.dragEnd} + freezeMenu={props.editor.tableHandles.freezeMenu} + unfreezeMenu={props.editor.tableHandles.unfreezeMenu} + showOtherSide={() => setHideCol(false)} + hideOtherSide={() => setHideCol(true)} /> ); }, [block, props.editor, props.tableHandle, rowIndex, colIndex]); @@ -173,22 +185,22 @@ export const TableHandlesPositioner = < content={tableHandleElementLeft} getReferenceClientRect={getReferenceClientRectRow} interactive={true} - visible={show && draggedCellOrientation !== "col"} + visible={show && draggedCellOrientation !== "col" && !hideRow} animation={"fade"} placement={"left"} offset={rowOffset} - zIndex={5000} + zIndex={1000} /> ); From da01f40ffaa70f014569aefc03ecfa40710ec609 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 23 Nov 2023 19:42:52 +0100 Subject: [PATCH 19/30] Implemented PR feedback --- .../TableHandles/TableHandlesPlugin.ts | 36 +++++++++++++++++-- .../components/DefaultTableHandle.tsx | 8 ++--- .../components/TableHandlePositioner.tsx | 14 +++++--- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index 9cc7f4b88e..fd227600dd 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -399,6 +399,11 @@ export class TableHandlesProsemirrorPlugin< widget.className = "bn-table-drop-cursor"; widget.style.left = "0"; widget.style.right = "0"; + // This is only necessary because the drop indicator's height + // is an even number of pixels, whereas the border between + // table cells is an odd number of pixels. So this makes the + // positioning slightly more consistent regardless of where + // the row is being dropped. if (pluginState.newIndex > pluginState.originalIndex) { widget.style.bottom = "-2px"; } else { @@ -439,6 +444,11 @@ export class TableHandlesProsemirrorPlugin< widget.className = "bn-table-drop-cursor"; widget.style.top = "0"; widget.style.bottom = "0"; + // This is only necessary because the drop indicator's width + // is an even number of pixels, whereas the border between + // table cells is an odd number of pixels. So this makes the + // positioning slightly more consistent regardless of where + // the column is being dropped. if (pluginState.newIndex > pluginState.originalIndex) { widget.style.right = "-2px"; } else { @@ -490,6 +500,10 @@ export class TableHandlesProsemirrorPlugin< return this.on("update", callback); } + /** + * Callback that should be set on the `dragStart` event for whichever element + * is used as the column drag handle. + */ colDragStart = (event: { dataTransfer: DataTransfer | null; clientX: number; @@ -521,6 +535,10 @@ export class TableHandlesProsemirrorPlugin< event.dataTransfer!.effectAllowed = "move"; }; + /** + * Callback that should be set on the `dragStart` event for whichever element + * is used as the row drag handle. + */ rowDragStart = (event: { dataTransfer: DataTransfer | null; clientY: number; @@ -552,6 +570,10 @@ export class TableHandlesProsemirrorPlugin< event.dataTransfer!.effectAllowed = "copyMove"; }; + /** + * Callback that should be set on the `dragEnd` event for both the element + * used as the row drag handle, and the one used as the column drag handle. + */ dragEnd = () => { if (this.view!.state === undefined) { throw new Error( @@ -569,7 +591,15 @@ export class TableHandlesProsemirrorPlugin< unsetHiddenDragImage(); }; - freezeMenu = () => (this.view!.menuFrozen = true); - - unfreezeMenu = () => (this.view!.menuFrozen = false); + /** + * Freezes the drag handles. When frozen, they will stay attached to the same + * cell regardless of which cell is hovered by the mouse cursor. + */ + freezeHandles = () => (this.view!.menuFrozen = true); + + /** + * Unfreezes the drag handles. When frozen, they will stay attached to the + * same cell regardless of which cell is hovered by the mouse cursor. + */ + unfreezeHandles = () => (this.view!.menuFrozen = false); } diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx index 550b1f71d5..da85e01b1a 100644 --- a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx @@ -10,11 +10,11 @@ const DefaultTableHandleLeft = (props: TableHandlesProps) => { { - props.freezeMenu(); + props.freezeHandles(); props.hideOtherSide(); }} onClose={() => { - props.unfreezeMenu(); + props.unfreezeHandles(); props.showOtherSide(); }} position={"right"}> @@ -92,11 +92,11 @@ const DefaultTableHandleTop = (props: TableHandlesProps) => { { - props.freezeMenu(); + props.freezeHandles(); props.hideOtherSide(); }} onClose={() => { - props.unfreezeMenu(); + props.unfreezeHandles(); props.showOtherSide(); }} position={"bottom"}> diff --git a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx index 1a1c895c40..cccd5979a6 100644 --- a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx +++ b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx @@ -15,7 +15,11 @@ export type TableHandlesProps< BSchema extends BlockSchema = DefaultBlockSchema > = Pick< TableHandlesProsemirrorPlugin, - "rowDragStart" | "colDragStart" | "dragEnd" | "freezeMenu" | "unfreezeMenu" + | "rowDragStart" + | "colDragStart" + | "dragEnd" + | "freezeHandles" + | "unfreezeHandles" > & Omit< TableHandlesState, @@ -148,8 +152,8 @@ export const TableHandlesPositioner = < rowDragStart={props.editor.tableHandles.rowDragStart} colDragStart={props.editor.tableHandles.colDragStart} dragEnd={props.editor.tableHandles.dragEnd} - freezeMenu={props.editor.tableHandles.freezeMenu} - unfreezeMenu={props.editor.tableHandles.unfreezeMenu} + freezeHandles={props.editor.tableHandles.freezeHandles} + unfreezeHandles={props.editor.tableHandles.unfreezeHandles} showOtherSide={() => setHideRow(false)} hideOtherSide={() => setHideRow(true)} /> @@ -170,8 +174,8 @@ export const TableHandlesPositioner = < rowDragStart={props.editor.tableHandles.rowDragStart} colDragStart={props.editor.tableHandles.colDragStart} dragEnd={props.editor.tableHandles.dragEnd} - freezeMenu={props.editor.tableHandles.freezeMenu} - unfreezeMenu={props.editor.tableHandles.unfreezeMenu} + freezeHandles={props.editor.tableHandles.freezeHandles} + unfreezeHandles={props.editor.tableHandles.unfreezeHandles} showOtherSide={() => setHideCol(false)} hideOtherSide={() => setHideCol(true)} /> From 77766cd14b3d5b000c8867b0c00ba5f5b6bf3618 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 23 Nov 2023 19:44:40 +0100 Subject: [PATCH 20/30] Implemented PR feedback --- .../TableHandles/TableHandlesPlugin.ts | 26 +++++++++---------- .../components/TableHandlePositioner.tsx | 8 +++--- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index fd227600dd..eaca6c6af8 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -39,7 +39,7 @@ export type TableHandlesState = { colIndex: number; rowIndex: number; - isDragging: + draggingState: | { draggedCellOrientation: "row" | "col"; mousePos: number; @@ -171,7 +171,7 @@ export class TableHandlesView { colIndex: colIndex, rowIndex: rowIndex, - isDragging: undefined, + draggingState: undefined, }; this.updateState(); @@ -179,7 +179,7 @@ export class TableHandlesView { }; dragOverHandler = (event: DragEvent) => { - if (this.state?.isDragging === undefined) { + if (this.state?.draggingState === undefined) { return; } @@ -229,11 +229,11 @@ export class TableHandlesView { // Checks if the drop cursor needs to be updated and updates it. This // affects decorations only so it doesn't trigger a state update. const oldIndex = - this.state.isDragging.draggedCellOrientation === "row" + this.state.draggingState.draggedCellOrientation === "row" ? this.state.rowIndex : this.state.colIndex; const newIndex = - this.state.isDragging.draggedCellOrientation === "row" + this.state.draggingState.draggedCellOrientation === "row" ? rowIndex : colIndex; if (oldIndex !== newIndex) { @@ -258,11 +258,11 @@ export class TableHandlesView { // Checks if the mouse cursor position along the axis that the user is // dragging on has changed and updates it. const mousePos = - this.state.isDragging.draggedCellOrientation === "row" + this.state.draggingState.draggedCellOrientation === "row" ? boundedMouseCoords.top : boundedMouseCoords.left; - if (this.state.isDragging.mousePos !== mousePos) { - this.state.isDragging.mousePos = mousePos; + if (this.state.draggingState.mousePos !== mousePos) { + this.state.draggingState.mousePos = mousePos; emitStateUpdate = true; } @@ -514,7 +514,7 @@ export class TableHandlesProsemirrorPlugin< ); } - this.view!.state.isDragging = { + this.view!.state.draggingState = { draggedCellOrientation: "col", mousePos: event.clientX, }; @@ -523,7 +523,7 @@ export class TableHandlesProsemirrorPlugin< this.editor._tiptapEditor.view.dispatch( this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { draggedCellOrientation: - this.view!.state.isDragging.draggedCellOrientation, + this.view!.state.draggingState.draggedCellOrientation, originalIndex: this.view!.state.colIndex, newIndex: this.view!.state.colIndex, tablePos: this.view!.tablePos, @@ -549,7 +549,7 @@ export class TableHandlesProsemirrorPlugin< ); } - this.view!.state.isDragging = { + this.view!.state.draggingState = { draggedCellOrientation: "row", mousePos: event.clientY, }; @@ -558,7 +558,7 @@ export class TableHandlesProsemirrorPlugin< this.editor._tiptapEditor.view.dispatch( this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { draggedCellOrientation: - this.view!.state.isDragging.draggedCellOrientation, + this.view!.state.draggingState.draggedCellOrientation, originalIndex: this.view!.state.rowIndex, newIndex: this.view!.state.rowIndex, tablePos: this.view!.tablePos, @@ -581,7 +581,7 @@ export class TableHandlesProsemirrorPlugin< ); } - this.view!.state.isDragging = undefined; + this.view!.state.draggingState = undefined; this.view!.updateState(); this.editor._tiptapEditor.view.dispatch( diff --git a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx index cccd5979a6..e7dd41ec9e 100644 --- a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx +++ b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx @@ -23,7 +23,7 @@ export type TableHandlesProps< > & Omit< TableHandlesState, - "referencePosCell" | "referencePosTable" | "show" | "isDragging" + "referencePosCell" | "referencePosTable" | "show" | "draggingState" > & { editor: BlockNoteEditor< BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]["config"]> @@ -70,9 +70,9 @@ export const TableHandlesPositioner = < setRowIndex(state.rowIndex); setColIndex(state.colIndex); - if (state.isDragging) { - setDraggedCellOrientation(state.isDragging.draggedCellOrientation); - setMousePos(state.isDragging.mousePos); + if (state.draggingState) { + setDraggedCellOrientation(state.draggingState.draggedCellOrientation); + setMousePos(state.draggingState.mousePos); } else { setDraggedCellOrientation(undefined); setMousePos(undefined); From 5c1123e51e854fe15f7036133a852c31007e8f5d Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 24 Nov 2023 14:17:59 +0100 Subject: [PATCH 21/30] Fixed drag handles sometimes not showing --- packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index eaca6c6af8..271e4a1b33 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -136,6 +136,7 @@ export class TableHandlesView { if ( this.state !== undefined && + this.state.show && this.tableId === blockEl.id && this.state.rowIndex === rowIndex && this.state.colIndex === colIndex From f5dfe739fa81b4d8c533efafa74be5275600afb7 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 24 Nov 2023 14:29:26 +0100 Subject: [PATCH 22/30] Fixed scrolling behaviour --- .../TableHandles/TableHandlesPlugin.ts | 66 ++++++------------- 1 file changed, 19 insertions(+), 47 deletions(-) diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index 271e4a1b33..42ee25de11 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -102,7 +102,7 @@ export class TableHandlesView { pmView.dom.addEventListener("mousemove", this.mouseMoveHandler); document.addEventListener("dragover", this.dragOverHandler); - // document.addEventListener("scroll", this.scrollHandler); + document.addEventListener("scroll", this.scrollHandler); } mouseMoveHandler = (event: MouseEvent) => { @@ -112,7 +112,7 @@ export class TableHandlesView { const target = domCellAround(event.target as HTMLElement); - if (!target) { + if (!target || !this.editor.isEditable) { if (this.state?.show) { this.state.show = false; this.updateState(); @@ -274,56 +274,28 @@ export class TableHandlesView { } }; - // scrollHandler = () => { - // if (this.imageToolbarState?.show) { - // const blockElement = document.querySelector( - // `[data-node-type="blockContainer"][data-id="${this.imageToolbarState.block.id}"]` - // )!; - - // this.imageToolbarState.referencePos = - // blockElement.getBoundingClientRect(); - // this.updateImageToolbar(); - // } - // }; - - // update(view: EditorView, prevState: EditorState) { - // const pluginState: { - // block: Block<(typeof Image)["config"]>; - // } = this.pluginKey.getState(view.state); - // - // if (!this.imageToolbarState?.show && pluginState.block) { - // const blockElement = document.querySelector( - // `[data-node-type="blockContainer"][data-id="${pluginState.block.id}"]` - // )!; - // - // this.imageToolbarState = { - // show: true, - // referencePos: blockElement.getBoundingClientRect(), - // block: pluginState.block, - // }; - // - // this.updateImageToolbar(); - // - // return; - // } - - // if ( - // !view.state.selection.eq(prevState.selection) || - // !view.state.doc.eq(prevState.doc) - // ) { - // if (this.imageToolbarState?.show) { - // this.imageToolbarState.show = false; - - // this.updateImageToolbar(); - // } - // } - // } + scrollHandler = () => { + if (this.state?.show) { + const tableElement = document.querySelector( + `[data-node-type="blockContainer"][data-id="${this.tableId}"] table` + )!; + const cellElement = tableElement.querySelector( + `tr:nth-child(${this.state.rowIndex + 1}) > td:nth-child(${ + this.state.colIndex + 1 + })` + )!; + + this.state.referencePosTable = tableElement.getBoundingClientRect(); + this.state.referencePosCell = cellElement.getBoundingClientRect(); + this.updateState(); + } + }; destroy() { this.pmView.dom.removeEventListener("mousedown", this.mouseMoveHandler); document.removeEventListener("dragover", this.dragOverHandler); - // document.removeEventListener("scroll", this.scrollHandler); + document.removeEventListener("scroll", this.scrollHandler); } } From 1a1385c68cc16c85010510bb14fbbc21b131b09c Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 24 Nov 2023 14:31:01 +0100 Subject: [PATCH 23/30] Small fixes --- .../TableHandles/TableHandlesPlugin.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index 42ee25de11..72645074fd 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -9,6 +9,7 @@ import { EventEmitter } from "../../shared/EventEmitter"; import { Block } from "../Blocks/api/blockTypes"; import { Table } from "../Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent"; import { nodeToBlock } from "../../api/nodeConversions/nodeConversions"; +import { PluginView } from "@tiptap/pm/state"; let dragImageElement: HTMLElement | undefined; @@ -73,7 +74,9 @@ function hideElementsWithClassNames(classNames: string[]) { }); } -export class TableHandlesView { +export class TableHandlesView + implements PluginView +{ public state?: TableHandlesState; public updateState: () => void; @@ -85,9 +88,7 @@ export class TableHandlesView { public prevWasEditable: boolean | null = null; constructor( - private readonly editor: BlockNoteEditor, - // @ts-ignore - private readonly pluginKey: PluginKey, + private readonly editor: BlockNoteEditor, private readonly pmView: EditorView, updateState: (state: TableHandlesState) => void ) { @@ -304,7 +305,7 @@ export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin"); export class TableHandlesProsemirrorPlugin< BSchema extends BlockSchema > extends EventEmitter { - private view: TableHandlesView | undefined; + private view: TableHandlesView | undefined; public readonly plugin: Plugin; constructor(private readonly editor: BlockNoteEditor) { @@ -312,14 +313,9 @@ export class TableHandlesProsemirrorPlugin< this.plugin = new Plugin({ key: tableHandlesPluginKey, view: (editorView) => { - this.view = new TableHandlesView( - editor, - tableHandlesPluginKey, - editorView, - (state) => { - this.emit("update", state); - } - ); + this.view = new TableHandlesView(editor, editorView, (state) => { + this.emit("update", state); + }); return this.view; }, // We use decorations to render the drop cursor when dragging a table row From 2ac67228d11a8cd4a79c3308b32706dea80f847c Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 24 Nov 2023 17:55:09 +0100 Subject: [PATCH 24/30] Fixed table handles UI --- packages/react/src/BlockNoteTheme.ts | 37 ++++ .../components/DefaultTableHandle.tsx | 192 ++---------------- .../TableHandles/components/TableHandle.tsx | 60 ++++++ .../DefaultButtons/AddButton.tsx | 73 +++++++ .../DefaultButtons/DeleteButton.tsx | 64 ++++++ .../DefaultTableHandleMenu.tsx | 33 +++ .../TableHandleMenu/TableHandleMenu.tsx | 24 +++ .../TableHandleMenu/TableHandleMenuItem.tsx | 9 + .../components/TableHandlePositioner.tsx | 82 ++++---- 9 files changed, 362 insertions(+), 212 deletions(-) create mode 100644 packages/react/src/TableHandles/components/TableHandle.tsx create mode 100644 packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx create mode 100644 packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx create mode 100644 packages/react/src/TableHandles/components/TableHandleMenu/DefaultTableHandleMenu.tsx create mode 100644 packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx create mode 100644 packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenuItem.tsx diff --git a/packages/react/src/BlockNoteTheme.ts b/packages/react/src/BlockNoteTheme.ts index c7302ca71f..276f30bb95 100644 --- a/packages/react/src/BlockNoteTheme.ts +++ b/packages/react/src/BlockNoteTheme.ts @@ -40,6 +40,10 @@ export type ComponentStyles = Partial<{ Editor: CSSObject; // Used in the Image Toolbar FileInput: CSSObject; + // Handle that appears next to tables and the menu that opens when clicking it + TableHandle: CSSObject; + TableHandleMenu: CSSObject; + // Used in the Image Toolbar Tabs: CSSObject; TextInput: CSSObject; // Wraps Formatting Toolbar & Hyperlink Toolbar @@ -130,6 +134,39 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { ), }), }, + TableHandle: { + styles: () => ({ + root: _.merge( + { + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.menu.background, + border: border, + borderRadius: innerBorderRadius, + boxShadow: shadow, + color: theme.colors.sideMenu, + "div:hover, div.bn-table-handle-dragging": { + backgroundColor: theme.colors.hovered.background, + }, + }, + theme.componentStyles?.(theme).TableHandle || {} + ), + }), + }, + TableHandleMenu: { + styles: () => ({ + root: _.merge( + { + ".mantine-Menu-item": { + fontSize: "12px", + height: "30px", + }, + }, + theme.componentStyles?.(theme).TableHandleMenu || {} + ), + }), + }, Tabs: { styles: () => ({ root: _.merge( diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx index da85e01b1a..4ba4a576ac 100644 --- a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx @@ -1,172 +1,22 @@ -import { TableContent } from "@blocknote/core"; - -import { Menu } from "@mantine/core"; +import { BlockSchema } from "@blocknote/core"; import { MdDragIndicator } from "react-icons/md"; -import { SideMenuButton } from "../../SideMenu/components/SideMenuButton"; -import { TableHandlesProps } from "./TableHandlePositioner"; - -const DefaultTableHandleLeft = (props: TableHandlesProps) => { - return ( - { - props.freezeHandles(); - props.hideOtherSide(); - }} - onClose={() => { - props.unfreezeHandles(); - props.showOtherSide(); - }} - position={"right"}> - -
- - - -
-
- - { - const content: TableContent = { - type: "tableContent", - rows: props.block.content.rows.filter( - (_, index) => index !== props.rowIndex - ), - }; - - props.editor.updateBlock(props.block, { type: "table", content }); - }}> - Delete row - - { - const emptyCol = props.block.content.rows[props.rowIndex].cells.map( - () => [] - ); - const rows = [...props.block.content.rows]; - rows.splice(props.rowIndex, 0, { - cells: emptyCol, - }); - - props.editor.updateBlock(props.block, { - type: "table", - content: { - type: "tableContent", - rows, - }, - }); - }}> - Add row above - - { - const emptyCol = props.block.content.rows[props.rowIndex].cells.map( - () => [] - ); - - const rows = [...props.block.content.rows]; - rows.splice(props.rowIndex + 1, 0, { - cells: emptyCol, - }); - - props.editor.updateBlock(props.block, { - content: { - type: "tableContent", - rows, - }, - }); - }}> - Add row below - - -
- ); -}; - -const DefaultTableHandleTop = (props: TableHandlesProps) => { - return ( - { - props.freezeHandles(); - props.hideOtherSide(); - }} - onClose={() => { - props.unfreezeHandles(); - props.showOtherSide(); - }} - position={"bottom"}> - -
- - - -
-
- - { - const content: TableContent = { - type: "tableContent", - rows: props.block.content.rows.map((row) => ({ - cells: row.cells.filter((_, index) => index !== props.colIndex), - })), - }; - - props.editor.updateBlock(props.block, { content }); - }}> - Delete column - - { - const content: TableContent = { - type: "tableContent", - rows: props.block.content.rows.map((row) => { - const cells = [...row.cells]; - cells.splice(props.colIndex, 0, []); - return { cells }; - }), - }; - - props.editor.updateBlock(props.block, { content }); - }}> - Add column left - - { - const content: TableContent = { - type: "tableContent", - rows: props.block.content.rows.map((row) => { - const cells = [...row.cells]; - cells.splice(props.colIndex + 1, 0, []); - return { cells }; - }), - }; - - props.editor.updateBlock(props.block, { content }); - }}> - Add column right - - -
- ); -}; - -export const DefaultTableHandle = (props: TableHandlesProps) => { - if (props.side === "left") { - return ; - } else { - return ; - } -}; +import { TableHandleProps } from "./TableHandlePositioner"; +import { TableHandle } from "./TableHandle"; + +export const DefaultTableHandle = ( + props: TableHandleProps +) => ( + +
+ +
+
+); diff --git a/packages/react/src/TableHandles/components/TableHandle.tsx b/packages/react/src/TableHandles/components/TableHandle.tsx new file mode 100644 index 0000000000..2648edfc56 --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandle.tsx @@ -0,0 +1,60 @@ +import { ReactNode, useState } from "react"; +import { BlockSchema } from "@blocknote/core"; +import { createStyles, Menu } from "@mantine/core"; +import { TableHandleProps } from "./TableHandlePositioner"; +import { DefaultTableHandleMenu } from "./TableHandleMenu/DefaultTableHandleMenu"; + +export const TableHandle = ( + props: TableHandleProps & { children: ReactNode } +) => { + const { classes } = createStyles({ root: {} })(undefined, { + name: "TableHandle", + }); + + const TableHandleMenu = props.tableHandleMenu || DefaultTableHandleMenu; + + const [isDragging, setIsDragging] = useState(false); + + return ( + { + props.freezeHandles(); + props.hideOtherSide(); + }} + onClose={() => { + props.unfreezeHandles(); + props.showOtherSide(); + }} + position={"right"}> + +
{ + setIsDragging(true); + props.dragStart(e); + }} + onDragEnd={() => { + props.dragEnd(); + setIsDragging(false); + }} + style={ + props.orientation === "column" + ? { transform: "rotate(0.25turn)" } + : undefined + }> +
+ {props.children} +
+
+
+ +
+ ); +}; diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx new file mode 100644 index 0000000000..c24350d73e --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx @@ -0,0 +1,73 @@ +import { + DefaultBlockSchema, + PartialBlock, + TableContent, +} from "@blocknote/core"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; +import { TableHandleMenuProps } from "../TableHandleMenu"; + +export const AddRowButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & { side: "above" | "below" } +) => ( + { + const emptyCol = props.block.content.rows[props.index].cells.map( + () => [] + ); + const rows = [...props.block.content.rows]; + rows.splice(props.index + (props.side === "below" ? 1 : 0), 0, { + cells: emptyCol, + }); + + props.editor.updateBlock(props.block, { + type: "table", + content: { + rows, + }, + } as PartialBlock); + }}> + {`Add row ${props.side}`} + +); + +export const AddColumnButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & { side: "left" | "right" } +) => ( + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.map((row) => { + const cells = [...row.cells]; + cells.splice(props.index + (props.side === "right" ? 1 : 0), 0, []); + return { cells }; + }), + }; + + props.editor.updateBlock(props.block, { + type: "table", + content: content, + } as PartialBlock); + }}> + {`Add column ${props.side}`} + +); + +export const AddButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & + ( + | { orientation: "row"; side: "above" | "below" } + | { orientation: "column"; side: "left" | "right" } + ) +) => + props.orientation === "row" ? ( + + ) : ( + + ); diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx new file mode 100644 index 0000000000..e314a617b3 --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx @@ -0,0 +1,64 @@ +import { + DefaultBlockSchema, + PartialBlock, + TableContent, +} from "@blocknote/core"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; +import { TableHandleMenuProps } from "../TableHandleMenu"; + +export const DeleteRowButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps +) => ( + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.filter( + (_, index) => index !== props.index + ), + }; + + props.editor.updateBlock(props.block, { + type: "table", + content, + } as PartialBlock); + }}> + Delete row + +); + +export const DeleteColumnButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps +) => ( + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.map((row) => ({ + cells: row.cells.filter((_, index) => index !== props.index), + })), + }; + + props.editor.updateBlock(props.block, { + type: "table", + content, + } as PartialBlock); + }}> + Delete column + +); + +export const DeleteButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & { orientation: "row" | "column" } +) => + props.orientation === "row" ? ( + + ) : ( + + ); diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultTableHandleMenu.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultTableHandleMenu.tsx new file mode 100644 index 0000000000..28b418abdd --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultTableHandleMenu.tsx @@ -0,0 +1,33 @@ +import { DefaultBlockSchema } from "@blocknote/core"; +import { TableHandleMenu, TableHandleMenuProps } from "./TableHandleMenu"; +import { AddButton } from "./DefaultButtons/AddButton"; +import { DeleteButton } from "./DefaultButtons/DeleteButton"; + +export const DefaultTableHandleMenu = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps +) => ( + + + + + +); diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx new file mode 100644 index 0000000000..682ad515b2 --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from "react"; +import { Block, BlockNoteEditor, DefaultBlockSchema } from "@blocknote/core"; +import { createStyles, Menu } from "@mantine/core"; + +export type TableHandleMenuProps< + BSchema extends { table: DefaultBlockSchema["table"] } +> = { + orientation: "row" | "column"; + editor: BlockNoteEditor; + block: Block; + index: number; +}; + +export const TableHandleMenu = (props: { children: ReactNode }) => { + const { classes } = createStyles({ root: {} })(undefined, { + name: "TableHandleMenu", + }); + + return ( + + {props.children} + + ); +}; diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenuItem.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenuItem.tsx new file mode 100644 index 0000000000..05518f027a --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenuItem.tsx @@ -0,0 +1,9 @@ +import { Menu, MenuItemProps } from "@mantine/core"; +import { PolymorphicComponentProps } from "@mantine/utils"; + +export const TableHandleMenuItem = ( + props: PolymorphicComponentProps<"button"> & MenuItemProps +) => { + const { children, ...remainingProps } = props; + return {children}; +}; diff --git a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx index e7dd41ec9e..ab8c2e2136 100644 --- a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx +++ b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx @@ -1,3 +1,4 @@ +import { DragEvent, FC, useEffect, useMemo, useRef, useState } from "react"; import { Block, BlockNoteEditor, @@ -8,30 +9,33 @@ import { TableHandlesState, } from "@blocknote/core"; import Tippy, { tippy } from "@tippyjs/react"; -import { FC, useEffect, useMemo, useRef, useState } from "react"; +import { DragHandleMenuProps } from "../../SideMenu/components/DragHandleMenu/DragHandleMenu"; import { DefaultTableHandle } from "./DefaultTableHandle"; -export type TableHandlesProps< - BSchema extends BlockSchema = DefaultBlockSchema -> = Pick< - TableHandlesProsemirrorPlugin, - | "rowDragStart" - | "colDragStart" - | "dragEnd" - | "freezeHandles" - | "unfreezeHandles" -> & - Omit< - TableHandlesState, - "referencePosCell" | "referencePosTable" | "show" | "draggingState" - > & { - editor: BlockNoteEditor< - BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]["config"]> - >; - side: "top" | "left"; - showOtherSide: () => void; - hideOtherSide: () => void; - }; +export type TableHandleProps = + Pick< + TableHandlesProsemirrorPlugin, + "dragEnd" | "freezeHandles" | "unfreezeHandles" + > & + Omit< + TableHandlesState, + | "rowIndex" + | "colIndex" + | "referencePosCell" + | "referencePosTable" + | "show" + | "draggingState" + > & { + orientation: "row" | "column"; + editor: BlockNoteEditor< + BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]["config"]> + >; + tableHandleMenu?: FC>; + dragStart: (e: DragEvent) => void; + index: number; + showOtherSide: () => void; + hideOtherSide: () => void; + }; export const TableHandlesPositioner = < BSchema extends BlockSchemaWithBlock< @@ -40,7 +44,7 @@ export const TableHandlesPositioner = < > >(props: { editor: BlockNoteEditor; - tableHandle?: FC; + tableHandle?: FC>; }) => { const [show, setShow] = useState(false); const [hideRow, setHideRow] = useState(false); @@ -64,7 +68,7 @@ export const TableHandlesPositioner = < tippy.setDefaultProps({ maxWidth: "" }); return props.editor.tableHandles.onUpdate((state) => { - // console.log("update", state); + // console.log("update", state.draggingState); setShow(state.show); setBlock(state.block); setRowIndex(state.rowIndex); @@ -139,18 +143,17 @@ export const TableHandlesPositioner = < [referencePosTable.current, draggedCellOrientation, mousePos] // eslint-disable-line ); - const tableHandleElementTop = useMemo(() => { + const columnTableHandle = useMemo(() => { const TableHandle = props.tableHandle || DefaultTableHandle; return ( setHideRow(true)} /> ); - }, [block, props.editor, props.tableHandle, rowIndex, colIndex]); + }, [block, props.editor, props.tableHandle, colIndex]); - const tableHandleElementLeft = useMemo(() => { + const rowTableHandle = useMemo(() => { const TableHandle = props.tableHandle || DefaultTableHandle; return ( setHideCol(true)} /> ); - }, [block, props.editor, props.tableHandle, rowIndex, colIndex]); + }, [block, props.editor, props.tableHandle, rowIndex]); return ( <> Date: Mon, 27 Nov 2023 13:28:57 +0100 Subject: [PATCH 25/30] Fixed remaining UX/UI issues --- packages/react/src/BlockNoteTheme.ts | 6 ++++-- .../src/TableHandles/components/DefaultTableHandle.tsx | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/react/src/BlockNoteTheme.ts b/packages/react/src/BlockNoteTheme.ts index 276f30bb95..156eaced8a 100644 --- a/packages/react/src/BlockNoteTheme.ts +++ b/packages/react/src/BlockNoteTheme.ts @@ -64,6 +64,7 @@ export type Theme = { export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { const shadow = `0 4px 12px ${theme.colors.shadow}`; + const lightShadow = `0 2px 6px ${theme.colors.border}`; const border = `1px solid ${theme.colors.border}`; const textColors = { @@ -144,11 +145,12 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { backgroundColor: theme.colors.menu.background, border: border, borderRadius: innerBorderRadius, - boxShadow: shadow, + boxShadow: lightShadow, color: theme.colors.sideMenu, - "div:hover, div.bn-table-handle-dragging": { + ":hover, div.bn-table-handle-dragging": { backgroundColor: theme.colors.hovered.background, }, + cursor: "pointer", }, theme.componentStyles?.(theme).TableHandle || {} ), diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx index 4ba4a576ac..7bc81482da 100644 --- a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx @@ -12,11 +12,12 @@ export const DefaultTableHandle = ( display: "flex", alignItems: "center", justifyContent: "center", - width: "16px", - height: "24px", - overflow: "hidden", + // width: "16px", + // height: "24px", + marginInline: "-4px", + overflow: "visible", }}> - +
); From 47fd63e534f2c941ecb0379c92c5bc678613f461 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 27 Nov 2023 13:49:38 +0100 Subject: [PATCH 26/30] Removed redundant state from table handles plugin --- .../TableHandles/TableHandlesPlugin.ts | 87 ++++++++----------- 1 file changed, 38 insertions(+), 49 deletions(-) diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index 72645074fd..b64454b42a 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -43,6 +43,7 @@ export type TableHandlesState = { draggingState: | { draggedCellOrientation: "row" | "col"; + originalIndex: number; mousePos: number; } | undefined; @@ -228,8 +229,8 @@ export class TableHandlesView const rowIndex = getChildIndex(tableCellElement.parentElement!); const colIndex = getChildIndex(tableCellElement); - // Checks if the drop cursor needs to be updated and updates it. This - // affects decorations only so it doesn't trigger a state update. + // Checks if the drop cursor needs to be updated. This affects decorations + // only so it doesn't trigger a state update. const oldIndex = this.state.draggingState.draggedCellOrientation === "row" ? this.state.rowIndex @@ -238,13 +239,7 @@ export class TableHandlesView this.state.draggingState.draggedCellOrientation === "row" ? rowIndex : colIndex; - if (oldIndex !== newIndex) { - this.pmView.dispatch( - this.pmView.state.tr.setMeta(tableHandlesPluginKey, { - newIndex: newIndex, - }) - ); - } + const dispatchDecorationsTransaction = newIndex !== oldIndex; // Checks if either the hovered cell has changed and updates the row and // column index. Also updates the reference DOMRect. @@ -273,6 +268,14 @@ export class TableHandlesView if (emitStateUpdate) { this.updateState(); } + + // Dispatches a dummy transaction to force a decorations update if + // necessary. + if (dispatchDecorationsTransaction) { + this.pmView.dispatch( + this.pmView.state.tr.setMeta(tableHandlesPluginKey, true) + ); + } }; scrollHandler = () => { @@ -322,26 +325,34 @@ export class TableHandlesProsemirrorPlugin< // or column. The decorations are updated in the `dragOverHandler` method. props: { decorations: (state) => { - const pluginState = tableHandlesPluginKey.getState(state); - - if (pluginState === undefined) { + if ( + this.view === undefined || + this.view.state === undefined || + this.view.state.draggingState === undefined || + this.view.tablePos === undefined + ) { return; } + const newIndex = + this.view.state.draggingState.draggedCellOrientation === "row" + ? this.view.state.rowIndex + : this.view.state.colIndex; + const decorations: Decoration[] = []; - if (pluginState.newIndex === pluginState.originalIndex) { + if (newIndex === this.view.state.draggingState.originalIndex) { return DecorationSet.create(state.doc, decorations); } // Gets the table to show the drop cursor in. - const tableResolvedPos = state.doc.resolve(pluginState.tablePos + 1); + const tableResolvedPos = state.doc.resolve(this.view.tablePos + 1); const tableNode = tableResolvedPos.node(); - if (pluginState.draggedCellOrientation === "row") { + if (this.view.state.draggingState.draggedCellOrientation === "row") { // Gets the row at the new index. const rowResolvedPos = state.doc.resolve( - tableResolvedPos.posAtIndex(pluginState.newIndex) + 1 + tableResolvedPos.posAtIndex(newIndex) + 1 ); const rowNode = rowResolvedPos.node(); @@ -358,7 +369,7 @@ export class TableHandlesProsemirrorPlugin< // original index. const decorationPos = cellResolvedPos.pos + - (pluginState.newIndex > pluginState.originalIndex + (newIndex > this.view.state.draggingState.originalIndex ? cellNode.nodeSize - 2 : 0); decorations.push( @@ -373,7 +384,9 @@ export class TableHandlesProsemirrorPlugin< // table cells is an odd number of pixels. So this makes the // positioning slightly more consistent regardless of where // the row is being dropped. - if (pluginState.newIndex > pluginState.originalIndex) { + if ( + newIndex > this.view!.state!.draggingState!.originalIndex + ) { widget.style.bottom = "-2px"; } else { widget.style.top = "-3px"; @@ -394,7 +407,7 @@ export class TableHandlesProsemirrorPlugin< // Gets the cell at the new index in the row. const cellResolvedPos = state.doc.resolve( - rowResolvedPos.posAtIndex(pluginState.newIndex) + 1 + rowResolvedPos.posAtIndex(newIndex) + 1 ); const cellNode = cellResolvedPos.node(); @@ -403,7 +416,7 @@ export class TableHandlesProsemirrorPlugin< // original index. const decorationPos = cellResolvedPos.pos + - (pluginState.newIndex > pluginState.originalIndex + (newIndex > this.view.state.draggingState.originalIndex ? cellNode.nodeSize - 2 : 0); decorations.push( @@ -418,7 +431,9 @@ export class TableHandlesProsemirrorPlugin< // table cells is an odd number of pixels. So this makes the // positioning slightly more consistent regardless of where // the column is being dropped. - if (pluginState.newIndex > pluginState.originalIndex) { + if ( + newIndex > this.view!.state!.draggingState!.originalIndex + ) { widget.style.right = "-2px"; } else { widget.style.left = "-3px"; @@ -434,34 +449,6 @@ export class TableHandlesProsemirrorPlugin< return DecorationSet.create(state.doc, decorations); }, }, - // For the view to be able to pass data to update the decorations, we need - // to use a state field, where we store drag & drop information. - state: { - init() { - return undefined; - }, - apply(transaction, prev) { - const isDragging = transaction.getMeta(tableHandlesPluginKey); - - // If the transaction contains new table drag & drop information, we - // update the existing state with the new information. - if (isDragging) { - return { - // The state may be undefined - ...(prev || {}), - ...isDragging, - }; - } - - // If the transaction contains null table drag & drop information, we - // clear the state. - if (isDragging === null) { - return undefined; - } - - return prev; - }, - }, }); } @@ -485,6 +472,7 @@ export class TableHandlesProsemirrorPlugin< this.view!.state.draggingState = { draggedCellOrientation: "col", + originalIndex: this.view!.state.colIndex, mousePos: event.clientX, }; this.view!.updateState(); @@ -520,6 +508,7 @@ export class TableHandlesProsemirrorPlugin< this.view!.state.draggingState = { draggedCellOrientation: "row", + originalIndex: this.view!.state.rowIndex, mousePos: event.clientY, }; this.view!.updateState(); From bd8b3be42e9d505dbcbac3e36a3a7d67bf79d201 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 27 Nov 2023 14:27:28 +0100 Subject: [PATCH 27/30] Implemented table drag & drop logic --- .../TableHandles/TableHandlesPlugin.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index b64454b42a..18e5bd42f5 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -4,6 +4,7 @@ import { BlockNoteEditor, BlockSchema, getDraggableBlockFromCoords, + PartialBlock, } from "../.."; import { EventEmitter } from "../../shared/EventEmitter"; import { Block } from "../Blocks/api/blockTypes"; @@ -102,7 +103,9 @@ export class TableHandlesView }; pmView.dom.addEventListener("mousemove", this.mouseMoveHandler); + document.addEventListener("dragover", this.dragOverHandler); + document.addEventListener("drop", this.dropHandler); document.addEventListener("scroll", this.scrollHandler); } @@ -278,6 +281,38 @@ export class TableHandlesView } }; + dropHandler = (event: DragEvent) => { + if (this.state === undefined || this.state.draggingState === undefined) { + return; + } + + event.preventDefault(); + + const rows = this.state.block.content.rows; + + if (this.state.draggingState.draggedCellOrientation === "row") { + const rowToMove = rows[this.state.draggingState.originalIndex]; + rows.splice(this.state.draggingState.originalIndex, 1); + rows.splice(this.state.rowIndex, 0, rowToMove); + } else { + const cellsToMove = rows.map( + (row) => row.cells[this.state!.draggingState!.originalIndex] + ); + rows.forEach((row, rowIndex) => { + row.cells.splice(this.state!.draggingState!.originalIndex, 1); + row.cells.splice(this.state!.colIndex, 0, cellsToMove[rowIndex]); + }); + } + + this.editor.updateBlock(this.state.block, { + type: "table", + content: { + type: "tableContent", + rows: rows, + }, + } as PartialBlock); + }; + scrollHandler = () => { if (this.state?.show) { const tableElement = document.querySelector( @@ -297,7 +332,9 @@ export class TableHandlesView destroy() { this.pmView.dom.removeEventListener("mousedown", this.mouseMoveHandler); + document.removeEventListener("dragover", this.dragOverHandler); + document.removeEventListener("drop", this.dropHandler); document.removeEventListener("scroll", this.scrollHandler); } From d6902f3812f1fbabd46b4f61cbe406c5ccdbfa4e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 27 Nov 2023 14:46:32 +0100 Subject: [PATCH 28/30] Added table enter handling --- .../nodes/BlockContent/TableBlockContent/TableExtension.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts index 1352bd6f09..14bc48310c 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts @@ -1,4 +1,4 @@ -import { Extension, callOrReturn, getExtensionField } from "@tiptap/core"; +import { callOrReturn, Extension, getExtensionField } from "@tiptap/core"; import { columnResizing, tableEditing } from "prosemirror-tables"; export const TableExtension = Extension.create({ @@ -15,6 +15,11 @@ export const TableExtension = Extension.create({ addKeyboardShortcuts() { return { + // Makes enter create a new line within the cell. + Enter: () => { + this.editor.commands.setHardBreak(); + return true; + }, // Ensures that backspace won't delete the table if the text cursor is at // the start of a cell and the selection is empty. Backspace: () => { From f5b4a0d25aef1c68aef9196c56c44e67a56d2fb1 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 27 Nov 2023 15:14:11 +0100 Subject: [PATCH 29/30] Small fix --- .../react/src/TableHandles/components/DefaultTableHandle.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx index 7bc81482da..ea62c15cd7 100644 --- a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx @@ -12,8 +12,6 @@ export const DefaultTableHandle = ( display: "flex", alignItems: "center", justifyContent: "center", - // width: "16px", - // height: "24px", marginInline: "-4px", overflow: "visible", }}> From 6ef5ea143b1c1dbe9ae141fc881ae59e154ac8f9 Mon Sep 17 00:00:00 2001 From: Yousef Date: Wed, 29 Nov 2023 11:21:00 +0100 Subject: [PATCH 30/30] feat: custom styles and custom inline content (#418) * wip custom styles * fix * fix tests * simplify PartialInlineContent * custom inline content * clean nodeconversions test * streamline tests * update tests * move schema files * add custom style test * inline content + tests * misc * clean imports * fix react tests * add react nodeconversions tests * move tests and add test for ReactStyles * fix react tests * basis of new examples * add react examples * fix bug * misc fixes * wip * clean * small cleanup * add comments * move funcs * fix tests * address PR feedback * fix inline content types * feat: HTML paste handling (#422) * refactor parse * fix parse-divsc * add test case * add extra test (that should be fixed) * readd markdown functions * fix tests * remove unused file * remove comments * add comment * nested list handling * add todos * added comment * use refs for blocks (#424) * use refs for blocks * update react htmlConversion test * Custom inline content and styles commands/copy & paste fixes (#425) * Fixed commands and internal copy/paste for inline content * Fixed internal copy/paste for styles * Small cleanup * fix some tests --------- Co-authored-by: yousefed --------- Co-authored-by: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> * use processSync --------- Co-authored-by: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> * fix build --------- Co-authored-by: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> --- .../{src/App.tsx => examples/Basic.tsx} | 12 +- examples/editor/examples/Collaboration.tsx | 48 + .../editor/examples/ReactInlineContent.tsx | 90 ++ examples/editor/examples/ReactStyles.tsx | 138 +++ examples/editor/package.json | 6 +- examples/editor/src/App.css | 9 + examples/editor/src/main.tsx | 108 ++- examples/editor/src/style.css | 13 + examples/editor/tsconfig.json | 2 +- examples/vanilla/src/main.tsx | 6 +- examples/vanilla/src/ui/addSlashMenu.ts | 4 +- package-lock.json | 817 +++++++++++++++--- packages/core/package.json | 1 + packages/core/src/BlockNoteEditor.test.ts | 2 +- packages/core/src/BlockNoteEditor.ts | 512 +++++++---- packages/core/src/BlockNoteExtensions.ts | 61 +- .../blockManipulation.test.ts | 34 +- .../blockManipulation/blockManipulation.ts | 52 +- .../copyExtension.ts} | 50 +- .../__snapshots__/complex/misc/external.html | 1 + .../__snapshots__/complex/misc/internal.html | 1 + .../customParagraph/basic/external.html | 0 .../customParagraph/basic/internal.html | 0 .../customParagraph/nested/external.html | 0 .../customParagraph/nested/internal.html | 0 .../customParagraph/styled/external.html | 0 .../customParagraph/styled/internal.html | 0 .../fontSize/basic/external.html | 1 + .../fontSize/basic/internal.html | 1 + .../hardbreak/basic/external.html | 1 + .../hardbreak/basic/internal.html | 1 + .../hardbreak/between-links/external.html | 1 + .../hardbreak/between-links/internal.html | 1 + .../__snapshots__/hardbreak/end/external.html | 1 + .../__snapshots__/hardbreak/end/internal.html | 1 + .../hardbreak/link/external.html | 1 + .../hardbreak/link/internal.html | 1 + .../hardbreak/multiple/external.html | 1 + .../hardbreak/multiple/internal.html | 1 + .../hardbreak/only/external.html | 1 + .../hardbreak/only/internal.html | 1 + .../hardbreak/start/external.html | 1 + .../hardbreak/start/internal.html | 1 + .../hardbreak/styles/external.html | 1 + .../hardbreak/styles/internal.html | 1 + .../__snapshots__/image/basic/external.html | 0 .../__snapshots__/image/basic/internal.html | 0 .../__snapshots__/image/button/external.html | 0 .../__snapshots__/image/button/internal.html | 0 .../__snapshots__/image/nested/external.html | 0 .../__snapshots__/image/nested/internal.html | 0 .../__snapshots__/link/adjacent/external.html | 1 + .../__snapshots__/link/adjacent/internal.html | 1 + .../__snapshots__/link/basic/external.html | 1 + .../__snapshots__/link/basic/internal.html | 1 + .../__snapshots__/link/styled/external.html | 1 + .../__snapshots__/link/styled/internal.html | 1 + .../__snapshots__/mention/basic/external.html | 1 + .../__snapshots__/mention/basic/internal.html | 1 + .../paragraph/basic/external.html | 0 .../paragraph/basic/internal.html | 0 .../paragraph/empty/external.html | 1 + .../paragraph/empty/internal.html | 1 + .../paragraph/nested/external.html | 0 .../paragraph/nested/internal.html | 0 .../paragraph/styled/external.html | 0 .../paragraph/styled/internal.html | 0 .../paste/parse-basic-block-types.json | 140 +++ .../paste/parse-deep-nested-content.json | 240 +++++ .../paste/parse-div-with-inline-content.json | 91 ++ .../html/__snapshots__/paste/parse-divs.json | 19 + .../paste/parse-fake-image-caption.json | 31 + .../paste/parse-mixed-nested-lists.json | 70 ++ .../parse-nested-lists-with-paragraphs.json | 70 ++ .../paste/parse-nested-lists.json | 70 ++ .../simpleCustomParagraph/basic/external.html | 0 .../simpleCustomParagraph/basic/internal.html | 0 .../nested/external.html | 0 .../nested/internal.html | 0 .../styled/external.html | 0 .../styled/internal.html | 0 .../simpleImage/basic/external.html | 0 .../simpleImage/basic/internal.html | 0 .../simpleImage/button/external.html | 0 .../simpleImage/button/internal.html | 0 .../simpleImage/nested/external.html | 0 .../simpleImage/nested/internal.html | 0 .../__snapshots__/small/basic/external.html | 1 + .../__snapshots__/small/basic/internal.html | 1 + .../__snapshots__/tag/basic/external.html | 1 + .../__snapshots__/tag/basic/internal.html | 1 + .../html/externalHTMLExporter.ts | 43 +- .../api/exporters/html/htmlConversion.test.ts | 383 ++++++++ .../html/internalHTMLSerializer.ts | 34 +- .../html/util}/sharedHTMLConversion.ts | 37 +- .../html/util}/simplifyBlocksRehypePlugin.ts | 0 .../formatConversions.test.ts.snap | 0 .../exporters/markdown/markdownExporter.ts | 43 + .../markdown}/removeUnderlinesRehypePlugin.ts | 0 .../formatConversions.testOld.ts | 749 ---------------- .../formatConversions/formatConversions.ts | 140 --- .../nodeConversions.test.ts.snap | 611 ++++++++++--- .../nodeConversions/nodeConversions.test.ts | 528 ++--------- .../api/nodeConversions/nodeConversions.ts | 348 +++++--- .../core/src/api/nodeConversions/testUtil.ts | 93 +- .../html/__snapshots__/paste/list-test.json | 105 +++ .../paste/parse-basic-block-types.json | 140 +++ .../paste/parse-deep-nested-content.json | 240 +++++ .../paste/parse-div-with-inline-content.json | 91 ++ .../html/__snapshots__/paste/parse-divs.json | 121 +++ .../paste/parse-fake-image-caption.json | 31 + .../paste/parse-mixed-nested-lists.json | 140 +++ .../parse-nested-lists-with-paragraphs.json | 140 +++ .../paste/parse-nested-lists.json | 157 ++++ .../__snapshots__/paste/parse-two-divs.json | 36 + .../src/api/parsers/html/parseHTML.test.ts | 267 ++++++ .../core/src/api/parsers/html/parseHTML.ts | 36 + .../__snapshots__/nestedLists.test.ts.snap | 129 +++ .../api/parsers/html/util/nestedLists.test.ts | 176 ++++ .../src/api/parsers/html/util/nestedLists.ts | 113 +++ .../src/api/parsers/markdown/parseMarkdown.ts | 80 ++ .../core/src/api/parsers/pasteExtension.ts | 61 ++ .../serialization/html/htmlConversion.test.ts | 467 ---------- .../testCases/cases/customInlineContent.ts | 114 +++ .../src/api/testCases/cases/customStyles.ts | 103 +++ .../src/api/testCases/cases/defaultSchema.ts | 399 +++++++++ packages/core/src/api/testCases/index.ts | 20 + .../BackgroundColorExtension.ts | 35 - .../BackgroundColor/BackgroundColorMark.ts | 39 +- .../src/extensions/Blocks/api/blockTypes.ts | 219 ----- .../{customBlocks.ts => blocks/createSpec.ts} | 94 +- .../api/{block.ts => blocks/internal.ts} | 104 +-- .../src/extensions/Blocks/api/blocks/types.ts | 284 ++++++ .../Blocks/api/cursorPositionTypes.ts | 16 +- .../extensions/Blocks/api/defaultBlocks.ts | 48 +- .../src/extensions/Blocks/api/defaultProps.ts | 2 +- .../Blocks/api/inlineContent/createSpec.ts | 107 +++ .../Blocks/api/inlineContent/internal.ts | 78 ++ .../Blocks/api/inlineContent/types.ts | 144 +++ .../Blocks/api/inlineContentTypes.ts | 36 - .../extensions/Blocks/api/selectionTypes.ts | 12 +- .../Blocks/api/styles/createSpec.ts | 79 ++ .../extensions/Blocks/api/styles/internal.ts | 89 ++ .../src/extensions/Blocks/api/styles/types.ts | 42 + .../extensions/Blocks/nodes/BlockContainer.ts | 70 +- .../HeadingBlockContent.ts | 26 +- .../ImageBlockContent/ImageBlockContent.ts | 49 +- .../BulletListItemBlockContent.ts | 12 +- .../NumberedListItemBlockContent.ts | 12 +- .../ParagraphBlockContent.ts | 3 +- .../TableBlockContent/TableBlockContent.ts | 20 +- .../nodes/BlockContent/defaultBlockHelpers.ts | 20 +- .../src/extensions/Blocks/nodes/BlockGroup.ts | 2 +- .../FormattingToolbarPlugin.ts | 40 +- .../HyperlinkToolbarPlugin.ts | 18 +- .../ImageToolbar/ImageToolbarPlugin.ts | 47 +- .../src/extensions/SideMenu/SideMenuPlugin.ts | 51 +- .../extensions/SlashMenu/BaseSlashMenuItem.ts | 13 +- .../extensions/SlashMenu/SlashMenuPlugin.ts | 12 +- .../SlashMenu/defaultSlashMenuItems.ts | 68 +- .../TableHandles/TableHandlesPlugin.ts | 61 +- .../TextAlignment/TextAlignmentExtension.ts | 54 +- .../TextColor/TextColorExtension.ts | 28 - .../src/extensions/TextColor/TextColorMark.ts | 34 +- packages/core/src/index.ts | 22 +- .../plugins/suggestion/SuggestionPlugin.ts | 22 +- packages/react/package.json | 3 +- packages/react/src/BlockNoteView.tsx | 28 +- .../DefaultButtons/ColorStyleButton.tsx | 19 +- .../DefaultButtons/ImageCaptionButton.tsx | 18 +- .../DefaultButtons/TextAlignButton.tsx | 4 +- .../DefaultButtons/ToggledStyleButton.tsx | 29 +- .../DefaultDropdowns/BlockTypeDropdown.tsx | 8 +- .../FormattingToolbarPositioner.tsx | 6 +- .../components/DefaultHyperlinkToolbar.tsx | 13 +- .../components/HyperlinkToolbarPositioner.tsx | 20 +- .../components/DefaultImageToolbar.tsx | 26 +- .../components/ImageToolbarPositioner.tsx | 21 +- packages/react/src/ReactBlockSpec.tsx | 139 ++- packages/react/src/ReactInlineContentSpec.tsx | 171 ++++ packages/react/src/ReactRenderUtil.ts | 37 + packages/react/src/ReactStyleSpec.tsx | 65 ++ .../DefaultButtons/AddBlockButton.tsx | 4 +- .../components/DefaultButtons/DragHandle.tsx | 8 +- .../SideMenu/components/DefaultSideMenu.tsx | 15 +- .../DefaultButtons/BlockColorsButton.tsx | 16 +- .../DefaultButtons/RemoveBlockButton.tsx | 4 +- .../DragHandleMenu/DefaultDragHandleMenu.tsx | 6 +- .../DragHandleMenu/DragHandleMenu.tsx | 20 +- .../components/SideMenuPositioner.tsx | 39 +- .../react/src/SlashMenu/ReactSlashMenuItem.ts | 10 +- .../components/SlashMenuPositioner.tsx | 6 +- .../SlashMenu/defaultReactSlashMenuItems.tsx | 16 +- .../components/DefaultTableHandle.tsx | 10 +- .../TableHandles/components/TableHandle.tsx | 12 +- .../DefaultButtons/AddButton.tsx | 8 +- .../DefaultButtons/DeleteButton.tsx | 10 +- .../TableHandleMenu/TableHandleMenu.tsx | 17 +- .../components/TableHandlePositioner.tsx | 90 +- .../reactCustomParagraph/nested/internal.html | 1 - .../reactCustomParagraph/styled/internal.html | 1 - .../basic/external.html | 1 - .../nested/external.html | 1 - .../nested/internal.html | 1 - .../styled/external.html | 1 - packages/react/src/hooks/useActiveStyles.ts | 22 + packages/react/src/hooks/useBlockNote.ts | 50 +- packages/react/src/hooks/useEditorChange.ts | 6 +- .../react/src/hooks/useEditorContentChange.ts | 6 +- .../src/hooks/useEditorSelectionChange.ts | 6 +- packages/react/src/hooks/useSelectedBlocks.ts | 20 +- packages/react/src/htmlConversion.test.tsx | 261 ------ packages/react/src/index.ts | 37 +- .../fontSize/basic/external.html | 1 + .../fontSize/basic/internal.html | 1 + .../__snapshots__/mention/basic/external.html | 1 + .../__snapshots__/mention/basic/internal.html | 1 + .../nodeConversion.test.tsx.snap | 461 ++++++++++ .../reactCustomParagraph/basic/external.html | 0 .../reactCustomParagraph/basic/internal.html | 2 +- .../reactCustomParagraph/nested/external.html | 0 .../reactCustomParagraph/nested/internal.html | 1 + .../reactCustomParagraph/styled/external.html | 0 .../reactCustomParagraph/styled/internal.html | 1 + .../basic/external.html | 1 + .../basic/internal.html | 2 +- .../nested/external.html | 1 + .../nested/internal.html | 1 + .../styled/external.html | 1 + .../styled/internal.html | 2 +- .../__snapshots__/small/basic/external.html | 1 + .../__snapshots__/small/basic/internal.html | 1 + .../__snapshots__/tag/basic/external.html | 1 + .../__snapshots__/tag/basic/internal.html | 1 + .../react/src/test/htmlConversion.test.tsx | 104 +++ .../react/src/test/nodeConversion.test.tsx | 83 ++ .../src/test/testCases/customReactBlocks.tsx | 203 +++++ .../testCases/customReactInlineContent.tsx | 101 +++ .../src/test/testCases/customReactStyles.tsx | 93 ++ packages/react/vite.config.ts | 14 +- packages/website/docs/docs/vanilla-js.md | 4 +- 241 files changed, 9642 insertions(+), 3770 deletions(-) rename examples/editor/{src/App.tsx => examples/Basic.tsx} (74%) create mode 100644 examples/editor/examples/Collaboration.tsx create mode 100644 examples/editor/examples/ReactInlineContent.tsx create mode 100644 examples/editor/examples/ReactStyles.tsx create mode 100644 examples/editor/src/style.css rename packages/core/src/api/{serialization/clipboardHandlerExtension.ts => exporters/copyExtension.ts} (63%) create mode 100644 packages/core/src/api/exporters/html/__snapshots__/complex/misc/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/complex/misc/internal.html rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/customParagraph/basic/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/customParagraph/basic/internal.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/customParagraph/nested/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/customParagraph/nested/internal.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/customParagraph/styled/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/customParagraph/styled/internal.html (100%) create mode 100644 packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/internal.html rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/image/basic/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/image/basic/internal.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/image/button/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/image/button/internal.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/image/nested/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/image/nested/internal.html (100%) create mode 100644 packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/link/adjacent/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/link/basic/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/paragraph/basic/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/paragraph/basic/internal.html (100%) create mode 100644 packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/paragraph/nested/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/paragraph/nested/internal.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/paragraph/styled/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/paragraph/styled/internal.html (100%) create mode 100644 packages/core/src/api/exporters/html/__snapshots__/paste/parse-basic-block-types.json create mode 100644 packages/core/src/api/exporters/html/__snapshots__/paste/parse-deep-nested-content.json create mode 100644 packages/core/src/api/exporters/html/__snapshots__/paste/parse-div-with-inline-content.json create mode 100644 packages/core/src/api/exporters/html/__snapshots__/paste/parse-divs.json create mode 100644 packages/core/src/api/exporters/html/__snapshots__/paste/parse-fake-image-caption.json create mode 100644 packages/core/src/api/exporters/html/__snapshots__/paste/parse-mixed-nested-lists.json create mode 100644 packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json create mode 100644 packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists.json rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleCustomParagraph/basic/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleCustomParagraph/basic/internal.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleCustomParagraph/nested/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleCustomParagraph/nested/internal.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleCustomParagraph/styled/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleCustomParagraph/styled/internal.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleImage/basic/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleImage/basic/internal.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleImage/button/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleImage/button/internal.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleImage/nested/external.html (100%) rename packages/core/src/api/{serialization => exporters}/html/__snapshots__/simpleImage/nested/internal.html (100%) create mode 100644 packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html rename packages/core/src/api/{serialization => exporters}/html/externalHTMLExporter.ts (75%) create mode 100644 packages/core/src/api/exporters/html/htmlConversion.test.ts rename packages/core/src/api/{serialization => exporters}/html/internalHTMLSerializer.ts (69%) rename packages/core/src/api/{serialization/html => exporters/html/util}/sharedHTMLConversion.ts (72%) rename packages/core/src/api/{formatConversions => exporters/html/util}/simplifyBlocksRehypePlugin.ts (100%) rename packages/core/src/api/{formatConversions => exporters/markdown}/__snapshots__/formatConversions.test.ts.snap (100%) create mode 100644 packages/core/src/api/exporters/markdown/markdownExporter.ts rename packages/core/src/api/{formatConversions => exporters/markdown}/removeUnderlinesRehypePlugin.ts (100%) delete mode 100644 packages/core/src/api/formatConversions/formatConversions.testOld.ts delete mode 100644 packages/core/src/api/formatConversions/formatConversions.ts create mode 100644 packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json create mode 100644 packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json create mode 100644 packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json create mode 100644 packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json create mode 100644 packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json create mode 100644 packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json create mode 100644 packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json create mode 100644 packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json create mode 100644 packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json create mode 100644 packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json create mode 100644 packages/core/src/api/parsers/html/parseHTML.test.ts create mode 100644 packages/core/src/api/parsers/html/parseHTML.ts create mode 100644 packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap create mode 100644 packages/core/src/api/parsers/html/util/nestedLists.test.ts create mode 100644 packages/core/src/api/parsers/html/util/nestedLists.ts create mode 100644 packages/core/src/api/parsers/markdown/parseMarkdown.ts create mode 100644 packages/core/src/api/parsers/pasteExtension.ts delete mode 100644 packages/core/src/api/serialization/html/htmlConversion.test.ts create mode 100644 packages/core/src/api/testCases/cases/customInlineContent.ts create mode 100644 packages/core/src/api/testCases/cases/customStyles.ts create mode 100644 packages/core/src/api/testCases/cases/defaultSchema.ts create mode 100644 packages/core/src/api/testCases/index.ts delete mode 100644 packages/core/src/extensions/Blocks/api/blockTypes.ts rename packages/core/src/extensions/Blocks/api/{customBlocks.ts => blocks/createSpec.ts} (60%) rename packages/core/src/extensions/Blocks/api/{block.ts => blocks/internal.ts} (75%) create mode 100644 packages/core/src/extensions/Blocks/api/blocks/types.ts create mode 100644 packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts create mode 100644 packages/core/src/extensions/Blocks/api/inlineContent/internal.ts create mode 100644 packages/core/src/extensions/Blocks/api/inlineContent/types.ts delete mode 100644 packages/core/src/extensions/Blocks/api/inlineContentTypes.ts create mode 100644 packages/core/src/extensions/Blocks/api/styles/createSpec.ts create mode 100644 packages/core/src/extensions/Blocks/api/styles/internal.ts create mode 100644 packages/core/src/extensions/Blocks/api/styles/types.ts create mode 100644 packages/react/src/ReactInlineContentSpec.tsx create mode 100644 packages/react/src/ReactRenderUtil.ts create mode 100644 packages/react/src/ReactStyleSpec.tsx delete mode 100644 packages/react/src/__snapshots__/reactCustomParagraph/nested/internal.html delete mode 100644 packages/react/src/__snapshots__/reactCustomParagraph/styled/internal.html delete mode 100644 packages/react/src/__snapshots__/simpleReactCustomParagraph/basic/external.html delete mode 100644 packages/react/src/__snapshots__/simpleReactCustomParagraph/nested/external.html delete mode 100644 packages/react/src/__snapshots__/simpleReactCustomParagraph/nested/internal.html delete mode 100644 packages/react/src/__snapshots__/simpleReactCustomParagraph/styled/external.html create mode 100644 packages/react/src/hooks/useActiveStyles.ts delete mode 100644 packages/react/src/htmlConversion.test.tsx create mode 100644 packages/react/src/test/__snapshots__/fontSize/basic/external.html create mode 100644 packages/react/src/test/__snapshots__/fontSize/basic/internal.html create mode 100644 packages/react/src/test/__snapshots__/mention/basic/external.html create mode 100644 packages/react/src/test/__snapshots__/mention/basic/internal.html create mode 100644 packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap rename packages/react/src/{ => test}/__snapshots__/reactCustomParagraph/basic/external.html (100%) rename packages/react/src/{ => test}/__snapshots__/reactCustomParagraph/basic/internal.html (53%) rename packages/react/src/{ => test}/__snapshots__/reactCustomParagraph/nested/external.html (100%) create mode 100644 packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html rename packages/react/src/{ => test}/__snapshots__/reactCustomParagraph/styled/external.html (100%) create mode 100644 packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html create mode 100644 packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html rename packages/react/src/{ => test}/__snapshots__/simpleReactCustomParagraph/basic/internal.html (52%) create mode 100644 packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html create mode 100644 packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html create mode 100644 packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html rename packages/react/src/{ => test}/__snapshots__/simpleReactCustomParagraph/styled/internal.html (55%) create mode 100644 packages/react/src/test/__snapshots__/small/basic/external.html create mode 100644 packages/react/src/test/__snapshots__/small/basic/internal.html create mode 100644 packages/react/src/test/__snapshots__/tag/basic/external.html create mode 100644 packages/react/src/test/__snapshots__/tag/basic/internal.html create mode 100644 packages/react/src/test/htmlConversion.test.tsx create mode 100644 packages/react/src/test/nodeConversion.test.tsx create mode 100644 packages/react/src/test/testCases/customReactBlocks.tsx create mode 100644 packages/react/src/test/testCases/customReactInlineContent.tsx create mode 100644 packages/react/src/test/testCases/customReactStyles.tsx diff --git a/examples/editor/src/App.tsx b/examples/editor/examples/Basic.tsx similarity index 74% rename from examples/editor/src/App.tsx rename to examples/editor/examples/Basic.tsx index 2ff77e8c34..6c3213b0dd 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/examples/Basic.tsx @@ -1,16 +1,12 @@ -// import logo from './logo.svg' -import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; import "@blocknote/core/style.css"; import { BlockNoteView, useBlockNote } from "@blocknote/react"; -import "./App.css"; + +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; -function App() { +export function App() { const editor = useBlockNote({ - onEditorContentChange: (editor) => { - console.log(editor.topLevelBlocks); - }, domAttributes: { editor: { class: "editor", @@ -23,7 +19,7 @@ function App() { // Give tests a way to get prosemirror instance (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; - return ; + return ; } export default App; diff --git a/examples/editor/examples/Collaboration.tsx b/examples/editor/examples/Collaboration.tsx new file mode 100644 index 0000000000..8bec4b84c9 --- /dev/null +++ b/examples/editor/examples/Collaboration.tsx @@ -0,0 +1,48 @@ +import "@blocknote/core/style.css"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; + +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; + +import YPartyKitProvider from "y-partykit/provider"; +import * as Y from "yjs"; + +const doc = new Y.Doc(); + +const provider = new YPartyKitProvider( + "blocknote-dev.yousefed.partykit.dev", + // use a unique name as a "room" for your application: + "your-project-name", + doc +); + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +export function App() { + const editor = useBlockNote({ + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + collaboration: { + // The Yjs Provider responsible for transporting updates: + provider, + // Where to store BlockNote data in the Y.Doc: + fragment: doc.getXmlFragment("document-storesss"), + // Information (name and color) for this user: + user: { + name: "My Username", + color: "#ff0000", + }, + }, + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + }); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ; +} + +export default App; diff --git a/examples/editor/examples/ReactInlineContent.tsx b/examples/editor/examples/ReactInlineContent.tsx new file mode 100644 index 0000000000..07ec3deb13 --- /dev/null +++ b/examples/editor/examples/ReactInlineContent.tsx @@ -0,0 +1,90 @@ +import { defaultInlineContentSpecs } from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { + BlockNoteView, + createReactInlineContentSpec, + useBlockNote, +} from "@blocknote/react"; + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +const mention = createReactInlineContentSpec( + { + type: "mention", + propSchema: { + user: { + default: "", + }, + }, + content: "none", + }, + { + render: (props) => { + return @{props.inlineContent.props.user}; + }, + } +); + +const tag = createReactInlineContentSpec( + { + type: "tag", + propSchema: {}, + content: "styled", + }, + { + render: (props) => { + return ( + + # + + ); + }, + } +); + +export function ReactInlineContent() { + const editor = useBlockNote({ + inlineContentSpecs: { + mention, + tag, + ...defaultInlineContentSpecs, + }, + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + initialContent: [ + { + type: "paragraph", + content: [ + "I enjoy working with ", + { + type: "mention", + props: { + user: "Matthew", + }, + content: undefined, + } as any, + ], + }, + { + type: "paragraph", + content: [ + "I love ", + { + type: "tag", + // props: {}, + content: "BlockNote", + } as any, + ], + }, + ], + }); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ; +} diff --git a/examples/editor/examples/ReactStyles.tsx b/examples/editor/examples/ReactStyles.tsx new file mode 100644 index 0000000000..6c82ca2bcf --- /dev/null +++ b/examples/editor/examples/ReactStyles.tsx @@ -0,0 +1,138 @@ +import "@blocknote/core/style.css"; +import { + BlockNoteView, + FormattingToolbarPositioner, + Toolbar, + ToolbarButton, + createReactStyleSpec, + useActiveStyles, + useBlockNote, +} from "@blocknote/react"; + +import { + BlockNoteEditor, + DefaultBlockSchema, + DefaultInlineContentSchema, + StyleSchemaFromSpecs, + defaultStyleSpecs, +} from "@blocknote/core"; + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +const small = createReactStyleSpec( + { + type: "small", + propSchema: "boolean", + }, + { + render: (props) => { + return ; + }, + } +); + +const fontSize = createReactStyleSpec( + { + type: "fontSize", + propSchema: "string", + }, + { + render: (props) => { + return ( + + ); + }, + } +); + +const customReactStyles = { + ...defaultStyleSpecs, + small, + fontSize, +}; + +type MyEditorType = BlockNoteEditor< + DefaultBlockSchema, + DefaultInlineContentSchema, + StyleSchemaFromSpecs +>; + +const CustomFormattingToolbar = (props: { editor: MyEditorType }) => { + const activeStyles = useActiveStyles(props.editor); + + return ( + + { + props.editor.toggleStyles({ + small: true, + }); + }} + isSelected={activeStyles.small}> + Small + + { + props.editor.toggleStyles({ + fontSize: "30px", + }); + }} + isSelected={!!activeStyles.fontSize}> + Font size + + + ); +}; + +export function ReactStyles() { + const editor = useBlockNote( + { + styleSpecs: customReactStyles, + onEditorContentChange: (editor) => { + console.log(editor.topLevelBlocks); + }, + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + initialContent: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "large text", + styles: { + fontSize: "30px", + }, + }, + { + type: "text", + text: "small text", + styles: { + small: true, + }, + }, + ], + }, + ], + }, + [] + ); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ( + + + + ); +} diff --git a/examples/editor/package.json b/examples/editor/package.json index 3ad74335a3..dad6b03ab9 100644 --- a/examples/editor/package.json +++ b/examples/editor/package.json @@ -11,8 +11,12 @@ "dependencies": { "@blocknote/core": "^0.9.6", "@blocknote/react": "^0.9.6", + "@mantine/core": "^5.6.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "y-partykit": "^0.0.0-4c022c1", + "yjs": "^13.6.10" }, "devDependencies": { "@types/react": "^18.0.25", diff --git a/examples/editor/src/App.css b/examples/editor/src/App.css index 8a90b5cd3f..8918687e58 100644 --- a/examples/editor/src/App.css +++ b/examples/editor/src/App.css @@ -2,3 +2,12 @@ margin: 0 calc((100% - 731px) / 2); height: 100%; } + +body { + margin: 0; +} + +.root { + height: 100%; + width: 100%; +} diff --git a/examples/editor/src/main.tsx b/examples/editor/src/main.tsx index f87c123a2c..0d2f29eefe 100644 --- a/examples/editor/src/main.tsx +++ b/examples/editor/src/main.tsx @@ -1,12 +1,112 @@ +import { AppShell, Navbar, ScrollArea } from "@mantine/core"; import React from "react"; import { createRoot } from "react-dom/client"; -import App from "./App"; +import { + Link, + Outlet, + RouterProvider, + createBrowserRouter, +} from "react-router-dom"; +import { App } from "../examples/Basic"; +import { ReactInlineContent } from "../examples/ReactInlineContent"; +import { ReactStyles } from "../examples/ReactStyles"; +import "./style.css"; window.React = React; +const editors = [ + { + title: "Basic", + path: "/simple", + component: App, + }, + { + title: "React custom styles", + path: "/react-styles", + component: ReactStyles, + }, + { + title: "React inline content", + path: "/react-inline-content", + component: ReactInlineContent, + }, +]; + +function Root() { + // const linkStyles = (theme) => ({ + // root: { + // // background: "red", + // ...theme.fn.hover({ + // backgroundColor: "#dfdfdd", + // }), + + // "&[data-active]": { + // backgroundColor: "rgba(0, 0, 0, 0.04)", + // }, + // }, + // // "root:hover": { background: "blue" }, + // }); + return ( + + + {editors.map((editor, i) => ( +
+ {editor.title} +
+ ))} + + {/* manitne } + // rightSection={} + /> + } + // rightSection={} + /> */} +
+ + } + header={<>} + // header={
+ // {/* Header content */} + //
} + styles={(theme) => ({ + main: { + backgroundColor: "white", + // theme.colorScheme === "dark" + // ? theme.colors.dark[8] + // : theme.colors.gray[0], + }, + })}> + +
+ ); +} +const router = createBrowserRouter([ + { + path: "/", + element: , + children: editors.map((editor) => ({ + path: editor.path, + element: , + })), + }, +]); + const root = createRoot(document.getElementById("root")!); root.render( - - - + // TODO: StrictMode is causing duplicate mounts and conflicts with collaboration + // + // + + // ); diff --git a/examples/editor/src/style.css b/examples/editor/src/style.css new file mode 100644 index 0000000000..8918687e58 --- /dev/null +++ b/examples/editor/src/style.css @@ -0,0 +1,13 @@ +.editor { + margin: 0 calc((100% - 731px) / 2); + height: 100%; +} + +body { + margin: 0; +} + +.root { + height: 100%; + width: 100%; +} diff --git a/examples/editor/tsconfig.json b/examples/editor/tsconfig.json index 4f17a5d5b9..41460fa792 100644 --- a/examples/editor/tsconfig.json +++ b/examples/editor/tsconfig.json @@ -17,7 +17,7 @@ "jsx": "react-jsx", "composite": true }, - "include": ["src"], + "include": ["src", "examples"], "references": [ { "path": "./tsconfig.node.json" }, { "path": "../../packages/core/" }, diff --git a/examples/vanilla/src/main.tsx b/examples/vanilla/src/main.tsx index 6f8a84712a..926d39d3fb 100644 --- a/examples/vanilla/src/main.tsx +++ b/examples/vanilla/src/main.tsx @@ -1,11 +1,11 @@ import { BlockNoteEditor } from "@blocknote/core"; import "./index.css"; -import { addSideMenu } from "./ui/addSideMenu"; import { addFormattingToolbar } from "./ui/addFormattingToolbar"; -import { addSlashMenu } from "./ui/addSlashMenu"; import { addHyperlinkToolbar } from "./ui/addHyperlinkToolbar"; +import { addSideMenu } from "./ui/addSideMenu"; +import { addSlashMenu } from "./ui/addSlashMenu"; -const editor = new BlockNoteEditor({ +const editor = BlockNoteEditor.create({ parentElement: document.getElementById("root")!, onEditorContentChange: () => { console.log(editor.topLevelBlocks); diff --git a/examples/vanilla/src/ui/addSlashMenu.ts b/examples/vanilla/src/ui/addSlashMenu.ts index 3ecbd7fc46..936fcbcb75 100644 --- a/examples/vanilla/src/ui/addSlashMenu.ts +++ b/examples/vanilla/src/ui/addSlashMenu.ts @@ -9,8 +9,8 @@ export const addSlashMenu = (editor: BlockNoteEditor) => { let element: HTMLElement; function updateItems( - items: BaseSlashMenuItem[], - onClick: (item: BaseSlashMenuItem) => void, + items: BaseSlashMenuItem[], + onClick: (item: BaseSlashMenuItem) => void, selected: number ) { element.innerHTML = ""; diff --git a/package-lock.json b/package-lock.json index 9da000e7e4..a14117e164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,12 @@ "dependencies": { "@blocknote/core": "^0.9.6", "@blocknote/react": "^0.9.6", + "@mantine/core": "^5.6.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "y-partykit": "^0.0.0-4c022c1", + "yjs": "^13.6.10" }, "devDependencies": { "@types/react": "^18.0.25", @@ -603,6 +607,23 @@ } } }, + "examples/playground": { + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "next": "14.0.3", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.0.3", + "typescript": "^5" + } + }, "examples/vanilla": { "name": "@blocknote/example-vanilla", "version": "0.9.6", @@ -6170,6 +6191,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@remix-run/router": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", + "integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@resvg/resvg-wasm": { "version": "2.4.1", "license": "MPL-2.0", @@ -6190,9 +6219,10 @@ } }, "node_modules/@rushstack/eslint-patch": { - "version": "1.3.0", - "dev": true, - "license": "MIT" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz", + "integrity": "sha512-2/U3GXA6YiPYQDLGwtGlnNgKYBSwCFIHf8Y9LUY5VATHdtbLlU0Y1R3QoBnT0aB4qv/BEiVVsj7LJXoQCgJ2vA==", + "dev": true }, "node_modules/@shuding/opentype.js": { "version": "1.4.0-beta.0", @@ -7727,14 +7757,15 @@ "license": "MIT" }, "node_modules/array-includes": { - "version": "3.1.6", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", "is-string": "^1.0.7" }, "engines": { @@ -7753,16 +7784,16 @@ } }, "node_modules/array.prototype.findlastindex": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.2.tgz", - "integrity": "sha512-tb5thFFlUcp7NdNF6/MpDk/1r/4awWG1FIz3YqDf+/zJSTezBb+/5WViH41obXULHVpDzoiCLpJ/ZO9YbJMsdw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" + "get-intrinsic": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -7772,13 +7803,14 @@ } }, "node_modules/array.prototype.flat": { - "version": "1.3.1", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -7789,13 +7821,14 @@ } }, "node_modules/array.prototype.flatmap": { - "version": "1.3.1", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -7817,6 +7850,27 @@ "get-intrinsic": "^1.1.3" } }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/arrify": { "version": "1.0.1", "dev": true, @@ -7848,6 +7902,15 @@ "dev": true, "license": "MIT" }, + "node_modules/asynciterator.prototype": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", + "integrity": "sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + } + }, "node_modules/asynckit": { "version": "0.4.0", "dev": true, @@ -8261,12 +8324,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dev": true, - "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9134,6 +9199,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "dev": true, @@ -9143,10 +9222,12 @@ } }, "node_modules/define-properties": { - "version": "1.2.0", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, - "license": "MIT", "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -9403,24 +9484,26 @@ } }, "node_modules/es-abstract": { - "version": "1.21.2", + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", "dev": true, - "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.5", "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.0", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", "get-symbol-description": "^1.0.0", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", "has-property-descriptors": "^1.0.0", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", + "hasown": "^2.0.0", "internal-slot": "^1.0.5", "is-array-buffer": "^3.0.2", "is-callable": "^1.2.7", @@ -9428,19 +9511,23 @@ "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", + "is-typed-array": "^1.1.12", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", "typed-array-length": "^1.0.4", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.9" + "which-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -9468,6 +9555,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-iterator-helpers": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", + "integrity": "sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==", + "dev": true, + "dependencies": { + "asynciterator.prototype": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.1", + "es-set-tostringtag": "^2.0.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.0.1" + } + }, "node_modules/es-set-tostringtag": { "version": "2.0.1", "dev": true, @@ -10053,13 +10162,14 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.7", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, - "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.11.0", - "resolve": "^1.22.1" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -10112,27 +10222,26 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.0.tgz", - "integrity": "sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", + "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", "dev": true, "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.findlastindex": "^1.2.2", - "array.prototype.flat": "^1.3.1", - "array.prototype.flatmap": "^1.3.1", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.7", + "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.8.0", - "has": "^1.0.3", - "is-core-module": "^2.12.1", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.fromentries": "^2.0.6", - "object.groupby": "^1.0.0", - "object.values": "^1.1.6", - "resolve": "^1.22.3", + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", "semver": "^6.3.1", "tsconfig-paths": "^3.14.2" }, @@ -10215,14 +10324,16 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.32.2", + "version": "7.33.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", + "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", "dev": true, - "license": "MIT", "dependencies": { "array-includes": "^3.1.6", "array.prototype.flatmap": "^1.3.1", "array.prototype.tosorted": "^1.1.1", "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.12", "estraverse": "^5.3.0", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", @@ -10232,7 +10343,7 @@ "object.values": "^1.1.6", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.4", - "semver": "^6.3.0", + "semver": "^6.3.1", "string.prototype.matchall": "^4.0.8" }, "engines": { @@ -10541,9 +10652,10 @@ "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.2.12", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -10807,18 +10919,23 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "license": "MIT" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { - "version": "1.1.5", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { "node": ">= 0.4" @@ -10877,14 +10994,15 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.1", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dev": true, - "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11219,6 +11337,7 @@ }, "node_modules/has": { "version": "1.0.3", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.1" @@ -11294,6 +11413,17 @@ "dev": true, "license": "ISC" }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-embedded": { "version": "2.0.1", "license": "MIT", @@ -11580,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, @@ -11990,6 +12129,21 @@ "version": "0.2.1", "license": "MIT" }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-bigint": { "version": "1.0.4", "dev": true, @@ -12071,10 +12225,11 @@ } }, "node_modules/is-core-module": { - "version": "2.12.1", - "license": "MIT", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12136,6 +12291,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "dev": true, @@ -12144,6 +12311,21 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "dev": true, @@ -12339,15 +12521,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.10", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", "dev": true, - "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "which-typed-array": "^1.1.11" }, "engines": { "node": ">= 0.4" @@ -12452,6 +12631,19 @@ "node": ">=0.12" } }, + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + } + }, "node_modules/jake": { "version": "10.8.7", "dev": true, @@ -12833,8 +13025,9 @@ } }, "node_modules/lib0": { - "version": "0.2.78", - "license": "MIT", + "version": "0.2.88", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.88.tgz", + "integrity": "sha512-KyroiEvCeZcZEMx5Ys+b4u4eEBbA1ch7XUaBhYpwa/nPMrzTjUhI4RfcytmQfYoTBPcdyx+FX6WFNIoNuJzJfQ==", "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -15382,9 +15575,10 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -15443,13 +15637,14 @@ } }, "node_modules/object.fromentries": { - "version": "2.0.6", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -15459,14 +15654,14 @@ } }, "node_modules/object.groupby": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.0.tgz", - "integrity": "sha512-70MWG6NfRH9GnbZOikuhPPYzpUpof9iW2J9E4dW7FXTqPNb6rllE6u39SKwwiNh8lCwX3DDb5OgcKGiEBrTTyw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", - "es-abstract": "^1.21.2", + "es-abstract": "^1.22.1", "get-intrinsic": "^1.2.1" } }, @@ -15503,13 +15698,14 @@ } }, "node_modules/object.values": { - "version": "1.1.6", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -16407,7 +16603,9 @@ } }, "node_modules/postcss": { - "version": "8.4.24", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { @@ -16423,7 +16621,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -16842,6 +17039,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz", + "integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==", + "dependencies": { + "@remix-run/router": "1.13.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz", + "integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==", + "dependencies": { + "@remix-run/router": "1.13.0", + "react-router": "6.20.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-textarea-autosize": { "version": "8.3.4", "license": "MIT", @@ -17183,6 +17410,26 @@ "node": ">=8" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", + "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regenerate": { "version": "1.4.2", "dev": true, @@ -17212,13 +17459,14 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.0", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" + "set-function-name": "^2.0.0" }, "engines": { "node": ">= 0.4" @@ -17261,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", @@ -17390,11 +17788,11 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.3.tgz", - "integrity": "sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dependencies": { - "is-core-module": "^2.12.0", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -17565,6 +17963,24 @@ "node": ">=6" } }, + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "dev": true, @@ -17774,6 +18190,35 @@ "dev": true, "license": "ISC" }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shallow-clone": { "version": "3.0.1", "dev": true, @@ -18064,13 +18509,14 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.7", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -18080,26 +18526,28 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.6", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.6", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -18540,6 +18988,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typed-array-length": { "version": "1.0.4", "dev": true, @@ -19377,6 +19876,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-builtin-type": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", + "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", + "dev": true, + "dependencies": { + "function.prototype.name": "^1.1.5", + "has-tostringtag": "^1.0.0", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-collection": { "version": "1.0.1", "dev": true, @@ -19392,16 +19917,16 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.9", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", "dev": true, - "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.4", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -19682,6 +20207,18 @@ "node": ">=0.4" } }, + "node_modules/y-partykit": { + "version": "0.0.0-4c022c1", + "resolved": "https://registry.npmjs.org/y-partykit/-/y-partykit-0.0.0-4c022c1.tgz", + "integrity": "sha512-DC4+2SdYjp4TfgcPjZFmHiMrhBkmBgapAN/KQ2ZlnSUYGnFtZZ11+Mkk6bAMCmwRKYKWA0lwVjznd7jpsoQe8g==", + "dependencies": { + "lib0": "^0.2.86", + "lodash.debounce": "^4.0.8", + "react": "^18.2.0", + "y-protocols": "^1.0.6", + "yjs": "^13.6.8" + } + }, "node_modules/y-prosemirror": { "version": "1.0.20", "license": "MIT", @@ -19701,14 +20238,22 @@ } }, "node_modules/y-protocols": { - "version": "1.0.5", - "license": "MIT", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", "dependencies": { - "lib0": "^0.2.42" + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" }, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" } }, "node_modules/y18n": { @@ -19756,10 +20301,11 @@ } }, "node_modules/yjs": { - "version": "13.6.1", - "license": "MIT", + "version": "13.6.10", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.10.tgz", + "integrity": "sha512-1JcyQek1vaMyrDm7Fqfa+pvHg/DURSbVo4VmeN7wjnTKB/lZrfIPhdCj7d8sboK6zLfRBJXegTjc9JlaDd8/Zw==", "dependencies": { - "lib0": "^0.2.74" + "lib0": "^0.2.86" }, "engines": { "node": ">=16.0.0", @@ -19828,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.test.ts b/packages/core/src/BlockNoteEditor.test.ts index 9c1b60fb12..f295c76fab 100644 --- a/packages/core/src/BlockNoteEditor.test.ts +++ b/packages/core/src/BlockNoteEditor.test.ts @@ -6,7 +6,7 @@ import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFro * @vitest-environment jsdom */ it("creates an editor", () => { - const editor = new BlockNoteEditor({}); + const editor = BlockNoteEditor.create(); const blockInfo = getBlockInfoFromPos(editor._tiptapEditor.state.doc, 2); expect(blockInfo?.contentNode.type.name).toEqual("paragraph"); }); diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index d11c03644f..bf41f4ba60 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"; @@ -20,23 +20,45 @@ import { BlockIdentifier, BlockNoteDOMAttributes, BlockSchema, + BlockSchemaFromSpecs, + BlockSchemaWithBlock, + BlockSpecs, PartialBlock, -} from "./extensions/Blocks/api/blockTypes"; -import { TextCursorPosition } from "./extensions/Blocks/api/cursorPositionTypes"; +} from "./extensions/Blocks/api/blocks/types"; import { DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, defaultBlockSchema, + defaultBlockSpecs, + defaultInlineContentSpecs, + defaultStyleSpecs, } from "./extensions/Blocks/api/defaultBlocks"; +import { Selection } from "./extensions/Blocks/api/selectionTypes"; import { - ColorStyle, + StyleSchema, + StyleSchemaFromSpecs, + StyleSpecs, Styles, - ToggledStyle, -} from "./extensions/Blocks/api/inlineContentTypes"; -import { Selection } from "./extensions/Blocks/api/selectionTypes"; +} from "./extensions/Blocks/api/styles/types"; 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, + InlineContentSchemaFromSpecs, + InlineContentSpecs, +} from "./extensions/Blocks/api/inlineContent/types"; +import { getStyleSchemaFromSpecs } from "./extensions/Blocks/api/styles/internal"; import { FormattingToolbarProsemirrorPlugin } from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; import { HyperlinkToolbarProsemirrorPlugin } from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; import { ImageToolbarProsemirrorPlugin } from "./extensions/ImageToolbar/ImageToolbarPlugin"; @@ -48,7 +70,11 @@ import { TableHandlesProsemirrorPlugin } from "./extensions/TableHandles/TableHa import { UniqueID } from "./extensions/UniqueID/UniqueID"; import { UnreachableCaseError, mergeCSSClasses } from "./shared/utils"; -export type BlockNoteEditorOptions = { +export type BlockNoteEditorOptions< + BSpecs extends BlockSpecs, + ISpecs extends InlineContentSpecs, + SSpecs extends StyleSpecs +> = { // TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them. enableBlockNoteExtensions: boolean; /** @@ -57,7 +83,11 @@ export type BlockNoteEditorOptions = { * * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ - slashMenuItems: BaseSlashMenuItem[]; + slashMenuItems: BaseSlashMenuItem< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + >[]; /** * The HTML element that should be used as the parent element for the editor. @@ -74,15 +104,33 @@ export type BlockNoteEditorOptions = { /** * A callback function that runs when the editor is ready to be used. */ - onEditorReady: (editor: BlockNoteEditor) => void; + onEditorReady: ( + editor: BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + ) => void; /** * A callback function that runs whenever the editor's contents change. */ - onEditorContentChange: (editor: BlockNoteEditor) => void; + onEditorContentChange: ( + editor: BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + ) => void; /** * A callback function that runs whenever the text cursor position changes. */ - onTextCursorPositionChange: (editor: BlockNoteEditor) => void; + onTextCursorPositionChange: ( + editor: BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + ) => void; /** * Locks the editor from being editable by the user if set to `false`. */ @@ -90,7 +138,11 @@ export type BlockNoteEditorOptions = { /** * The content that should be in the editor when it's created, represented as an array of partial block objects. */ - initialContent: PartialBlock[]; + initialContent: PartialBlock< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + >[]; /** * Use default BlockNote font and reset the styles of

  • elements etc., that are used in BlockNote. * @@ -101,7 +153,11 @@ export type BlockNoteEditorOptions = { /** * A list of block types that should be available in the editor. */ - blockSchema: BSchema; + blockSpecs: BSpecs; + + styleSpecs: SSpecs; + + inlineContentSpecs: ISpecs; /** * A custom function to handle file uploads. @@ -145,54 +201,115 @@ const blockNoteTipTapOptions = { enableCoreExtensions: false, }; -export class BlockNoteEditor { +export class BlockNoteEditor< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema +> { public readonly _tiptapEditor: TiptapEditor & { contentComponent: any }; - public blockCache = new WeakMap>(); - public readonly schema: BSchema; + public blockCache = new WeakMap>(); + public readonly blockSchema: BSchema; + public readonly inlineContentSchema: ISchema; + public readonly styleSchema: SSchema; + + public readonly blockImplementations: BlockSpecs; + public readonly inlineContentImplementations: InlineContentSpecs; + public readonly styleImplementations: StyleSpecs; + public ready = false; - public readonly sideMenu: SideMenuProsemirrorPlugin; - public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; - public readonly slashMenu: SlashMenuProsemirrorPlugin; - public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin; - public readonly imageToolbar: ImageToolbarProsemirrorPlugin; - public readonly tableHandles: TableHandlesProsemirrorPlugin; + public readonly sideMenu: SideMenuProsemirrorPlugin< + BSchema, + ISchema, + SSchema + >; + public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; + public readonly slashMenu: SlashMenuProsemirrorPlugin< + BSchema, + ISchema, + SSchema, + any + >; + public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin< + BSchema, + ISchema, + SSchema + >; + public readonly imageToolbar: ImageToolbarProsemirrorPlugin< + BSchema, + ISchema, + SSchema + >; + public readonly tableHandles: + | TableHandlesProsemirrorPlugin< + BSchema extends BlockSchemaWithBlock< + "table", + DefaultBlockSchema["table"] + > + ? BSchema + : any, + ISchema, + SSchema + > + | undefined; public readonly uploadFile: ((file: File) => Promise) | undefined; - constructor( - private readonly options: Partial> = {} + public static create< + BSpecs extends BlockSpecs = typeof defaultBlockSpecs, + ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs, + SSpecs extends StyleSpecs = typeof defaultStyleSpecs + >(options: Partial> = {}) { + return new BlockNoteEditor(options) as BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + >; + } + + private constructor( + private readonly options: Partial> ) { // apply defaults - const newOptions: Omit & { - defaultStyles: boolean; - blockSchema: BSchema; - } = { + const newOptions = { defaultStyles: true, - // TODO: There's a lot of annoying typing stuff to deal with here. If - // BSchema is specified, then options.blockSchema should also be required. - // If BSchema is not specified, then options.blockSchema should also not - // be defined. Unfortunately, trying to implement these constraints seems - // to be a huge pain, hence the `as any` casts. - blockSchema: options.blockSchema || (defaultBlockSchema as any), + blockSpecs: options.blockSpecs || defaultBlockSpecs, + styleSpecs: options.styleSpecs || defaultStyleSpecs, + inlineContentSpecs: + options.inlineContentSpecs || defaultInlineContentSpecs, ...options, }; + this.blockSchema = getBlockSchemaFromSpecs(newOptions.blockSpecs); + this.inlineContentSchema = getInlineContentSchemaFromSpecs( + newOptions.inlineContentSpecs + ); + this.styleSchema = getStyleSchemaFromSpecs(newOptions.styleSpecs); + this.blockImplementations = newOptions.blockSpecs; + this.inlineContentImplementations = newOptions.inlineContentSpecs; + this.styleImplementations = newOptions.styleSpecs; + this.sideMenu = new SideMenuProsemirrorPlugin(this); this.formattingToolbar = new FormattingToolbarProsemirrorPlugin(this); this.slashMenu = new SlashMenuProsemirrorPlugin( this, newOptions.slashMenuItems || - getDefaultSlashMenuItems(newOptions.blockSchema) + (getDefaultSlashMenuItems(this.blockSchema) as any) ); this.hyperlinkToolbar = new HyperlinkToolbarProsemirrorPlugin(this); this.imageToolbar = new ImageToolbarProsemirrorPlugin(this); - this.tableHandles = new TableHandlesProsemirrorPlugin(this); - const extensions = getBlockNoteExtensions({ + if (this.blockSchema.table === defaultBlockSchema.table) { + this.tableHandles = new TableHandlesProsemirrorPlugin(this as any); + } + + const extensions = getBlockNoteExtensions({ editor: this, domAttributes: newOptions.domAttributes || {}, - blockSchema: newOptions.blockSchema, + blockSchema: this.blockSchema, + blockSpecs: newOptions.blockSpecs, + styleSpecs: newOptions.styleSpecs, + inlineContentSpecs: newOptions.inlineContentSpecs, collaboration: newOptions.collaboration, }); @@ -206,14 +323,12 @@ export class BlockNoteEditor { this.slashMenu.plugin, this.hyperlinkToolbar.plugin, this.imageToolbar.plugin, - this.tableHandles.plugin, + ...(this.tableHandles ? [this.tableHandles.plugin] : []), ]; }, }); extensions.push(blockNoteUIExtension); - this.schema = newOptions.blockSchema; - this.uploadFile = newOptions.uploadFile; const initialContent = @@ -226,6 +341,7 @@ export class BlockNoteEditor { id: UniqueID.options.generateID(), }, ]); + const styleSchema = this.styleSchema; const tiptapOptions: Partial = { ...blockNoteTipTapOptions, @@ -245,7 +361,11 @@ export class BlockNoteEditor { "doc", undefined, schema.node("blockGroup", undefined, [ - blockToNode({ id: "initialBlock", type: "paragraph" }, schema), + blockToNode( + { id: "initialBlock", type: "paragraph" }, + schema, + styleSchema + ), ]) ); editor.editor.options.content = root.toJSON(); @@ -256,7 +376,7 @@ export class BlockNoteEditor { // initial content, as the schema may contain custom blocks which need // it to render. if (initialContent !== undefined) { - this.replaceBlocks(this.topLevelBlocks, initialContent); + this.replaceBlocks(this.topLevelBlocks, initialContent as any); } newOptions.onEditorReady?.(this); @@ -304,6 +424,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); + }, }, }; @@ -336,11 +500,19 @@ export class BlockNoteEditor { * Gets a snapshot of all top-level (non-nested) blocks in the editor. * @returns A snapshot of all top-level (non-nested) blocks in the editor. */ - public get topLevelBlocks(): Block[] { - const blocks: Block[] = []; + public get topLevelBlocks(): Block[] { + const blocks: Block[] = []; this._tiptapEditor.state.doc.firstChild!.descendants((node) => { - blocks.push(nodeToBlock(node, this.schema, this.blockCache)); + blocks.push( + nodeToBlock( + node, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this.blockCache + ) + ); return false; }); @@ -355,12 +527,12 @@ export class BlockNoteEditor { */ public getBlock( blockIdentifier: BlockIdentifier - ): Block | undefined { + ): Block | undefined { const id = typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; - let newBlock: Block | undefined = undefined; + let newBlock: Block | undefined = undefined; this._tiptapEditor.state.doc.firstChild!.descendants((node) => { if (typeof newBlock !== "undefined") { @@ -371,7 +543,13 @@ export class BlockNoteEditor { return true; } - newBlock = nodeToBlock(node, this.schema, this.blockCache); + newBlock = nodeToBlock( + node, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this.blockCache + ); return false; }); @@ -385,7 +563,7 @@ export class BlockNoteEditor { * @param reverse Whether the blocks should be traversed in reverse order. */ public forEachBlock( - callback: (block: Block) => boolean, + callback: (block: Block) => boolean, reverse = false ): void { const blocks = this.topLevelBlocks.slice(); @@ -394,7 +572,9 @@ export class BlockNoteEditor { blocks.reverse(); } - function traverseBlockArray(blockArray: Block[]): boolean { + function traverseBlockArray( + blockArray: Block[] + ): boolean { for (const block of blockArray) { if (!callback(block)) { return false; @@ -435,7 +615,11 @@ export class BlockNoteEditor { * Gets a snapshot of the current text cursor position. * @returns A snapshot of the current text cursor position. */ - public getTextCursorPosition(): TextCursorPosition { + public getTextCursorPosition(): TextCursorPosition< + BSchema, + ISchema, + SSchema + > { const { node, depth, startPos, endPos } = getBlockInfoFromPos( this._tiptapEditor.state.doc, this._tiptapEditor.state.selection.from @@ -463,15 +647,33 @@ export class BlockNoteEditor { } return { - block: nodeToBlock(node, this.schema, this.blockCache), + block: nodeToBlock( + node, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this.blockCache + ), prevBlock: prevNode === undefined ? undefined - : nodeToBlock(prevNode, this.schema, this.blockCache), + : nodeToBlock( + prevNode, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this.blockCache + ), nextBlock: nextNode === undefined ? undefined - : nodeToBlock(nextNode, this.schema, this.blockCache), + : nodeToBlock( + nextNode, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this.blockCache + ), }; } @@ -494,7 +696,7 @@ export class BlockNoteEditor { )!; const contentType: "none" | "inline" | "table" = - this.schema[contentNode.type.name]!.config.content; + this.blockSchema[contentNode.type.name]!.content; if (contentType === "none") { this._tiptapEditor.commands.setNodeSelection(startPos); @@ -528,7 +730,7 @@ export class BlockNoteEditor { /** * Gets a snapshot of the current selection. */ - public getSelection(): Selection | undefined { + public getSelection(): Selection | undefined { // Either the TipTap selection is empty, or it's a node selection. In either // case, it only spans one block, so we return undefined. if ( @@ -539,7 +741,7 @@ export class BlockNoteEditor { return undefined; } - const blocks: Block[] = []; + const blocks: Block[] = []; // TODO: This adds all child blocks to the same array. Needs to find min // depth and only add blocks at that depth. @@ -558,7 +760,9 @@ export class BlockNoteEditor { blocks.push( nodeToBlock( this._tiptapEditor.state.doc.resolve(pos).node(), - this.schema, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, this.blockCache ) ); @@ -594,11 +798,11 @@ export class BlockNoteEditor { * `referenceBlock`. Inserts the blocks at the start of the existing block's children if "nested" is used. */ public insertBlocks( - blocksToInsert: PartialBlock[], + blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, placement: "before" | "after" | "nested" = "before" ): void { - insertBlocks(blocksToInsert, referenceBlock, placement, this._tiptapEditor); + insertBlocks(blocksToInsert, referenceBlock, placement, this); } /** @@ -610,7 +814,7 @@ export class BlockNoteEditor { */ public updateBlock( blockToUpdate: BlockIdentifier, - update: PartialBlock + update: PartialBlock ) { updateBlock(blockToUpdate, update, this._tiptapEditor); } @@ -632,32 +836,28 @@ export class BlockNoteEditor { */ public replaceBlocks( blocksToRemove: BlockIdentifier[], - blocksToInsert: PartialBlock[] + blocksToInsert: PartialBlock[] ) { - replaceBlocks(blocksToRemove, blocksToInsert, this._tiptapEditor); + replaceBlocks(blocksToRemove, blocksToInsert, this); } /** * Gets the active text styles at the text cursor position or at the end of the current selection if it's active. */ public getActiveStyles() { - const styles: Styles = {}; + const styles: Styles = {}; const marks = this._tiptapEditor.state.selection.$to.marks(); - const toggleStyles = new Set([ - "bold", - "italic", - "underline", - "strike", - "code", - ]); - const colorStyles = new Set(["textColor", "backgroundColor"]); - for (const mark of marks) { - if (toggleStyles.has(mark.type.name as ToggledStyle)) { - styles[mark.type.name as ToggledStyle] = true; - } else if (colorStyles.has(mark.type.name as ColorStyle)) { - styles[mark.type.name as ColorStyle] = mark.attrs.color; + const config = this.styleSchema[mark.type.name]; + if (!config) { + console.warn("mark not found in styleschema", mark.type.name); + continue; + } + if (config.propSchema === "boolean") { + (styles as any)[config.type] = true; + } else { + (styles as any)[config.type] = mark.attrs.stringValue; } } @@ -668,23 +868,20 @@ export class BlockNoteEditor { * Adds styles to the currently selected content. * @param styles The styles to add. */ - public addStyles(styles: Styles) { - const toggleStyles = new Set([ - "bold", - "italic", - "underline", - "strike", - "code", - ]); - const colorStyles = new Set(["textColor", "backgroundColor"]); - + public addStyles(styles: Styles) { this._tiptapEditor.view.focus(); for (const [style, value] of Object.entries(styles)) { - if (toggleStyles.has(style as ToggledStyle)) { + const config = this.styleSchema[style]; + if (!config) { + throw new Error(`style ${style} not found in styleSchema`); + } + if (config.propSchema === "boolean") { this._tiptapEditor.commands.setMark(style); - } else if (colorStyles.has(style as ColorStyle)) { - this._tiptapEditor.commands.setMark(style, { color: value }); + } else if (config.propSchema === "string") { + this._tiptapEditor.commands.setMark(style, { stringValue: value }); + } else { + throw new UnreachableCaseError(config.propSchema); } } } @@ -693,7 +890,7 @@ export class BlockNoteEditor { * Removes styles from the currently selected content. * @param styles The styles to remove. */ - public removeStyles(styles: Styles) { + public removeStyles(styles: Styles) { this._tiptapEditor.view.focus(); for (const style of Object.keys(styles)) { @@ -705,23 +902,20 @@ export class BlockNoteEditor { * Toggles styles on the currently selected content. * @param styles The styles to toggle. */ - public toggleStyles(styles: Styles) { - const toggleStyles = new Set([ - "bold", - "italic", - "underline", - "strike", - "code", - ]); - const colorStyles = new Set(["textColor", "backgroundColor"]); - + public toggleStyles(styles: Styles) { this._tiptapEditor.view.focus(); for (const [style, value] of Object.entries(styles)) { - if (toggleStyles.has(style as ToggledStyle)) { + const config = this.styleSchema[style]; + if (!config) { + throw new Error(`style ${style} not found in styleSchema`); + } + if (config.propSchema === "boolean") { this._tiptapEditor.commands.toggleMark(style); - } else if (colorStyles.has(style as ColorStyle)) { - this._tiptapEditor.commands.toggleMark(style, { color: value }); + } else if (config.propSchema === "string") { + this._tiptapEditor.commands.toggleMark(style, { stringValue: value }); + } else { + throw new UnreachableCaseError(config.propSchema); } } } @@ -807,47 +1001,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 16ae7853a9..e7c52358fd 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -2,43 +2,49 @@ import { Extensions, extensions } from "@tiptap/core"; import { BlockNoteEditor } from "./BlockNoteEditor"; -import { Bold } from "@tiptap/extension-bold"; -import { Code } from "@tiptap/extension-code"; import Collaboration from "@tiptap/extension-collaboration"; import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; import { Dropcursor } from "@tiptap/extension-dropcursor"; import { Gapcursor } from "@tiptap/extension-gapcursor"; import { HardBreak } from "@tiptap/extension-hard-break"; import { History } from "@tiptap/extension-history"; -import { Italic } from "@tiptap/extension-italic"; import { Link } from "@tiptap/extension-link"; -import { Strike } from "@tiptap/extension-strike"; import { Text } from "@tiptap/extension-text"; -import { Underline } from "@tiptap/extension-underline"; 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 { BackgroundColorMark } from "./extensions/BackgroundColor/BackgroundColorMark"; import { BlockContainer, BlockGroup, Doc } from "./extensions/Blocks"; import { BlockNoteDOMAttributes, BlockSchema, -} from "./extensions/Blocks/api/blockTypes"; -import { TableExtension } from "./extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension"; + BlockSpecs, +} from "./extensions/Blocks/api/blocks/types"; +import { + InlineContentSchema, + InlineContentSpecs, +} from "./extensions/Blocks/api/inlineContent/types"; +import { StyleSchema, StyleSpecs } from "./extensions/Blocks/api/styles/types"; import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension"; import { TextAlignmentExtension } from "./extensions/TextAlignment/TextAlignmentExtension"; import { TextColorExtension } from "./extensions/TextColor/TextColorExtension"; -import { TextColorMark } from "./extensions/TextColor/TextColorMark"; import { TrailingNode } from "./extensions/TrailingNode/TrailingNodeExtension"; import UniqueID from "./extensions/UniqueID/UniqueID"; /** * Get all the Tiptap extensions BlockNote is configured with by default */ -export const getBlockNoteExtensions = (opts: { - editor: BlockNoteEditor; +export const getBlockNoteExtensions = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(opts: { + editor: BlockNoteEditor; domAttributes: Partial; blockSchema: BSchema; + blockSpecs: BlockSpecs; + inlineContentSpecs: InlineContentSpecs; + styleSpecs: StyleSpecs; collaboration?: { fragment: Y.XmlFragment; user: { @@ -74,32 +80,38 @@ export const getBlockNoteExtensions = (opts: { Text, // marks: - Bold, - Code, - Italic, - Strike, - Underline, Link, - TextColorMark, + ...Object.values(opts.styleSpecs).map((styleSpec) => { + return styleSpec.implementation.mark; + }), + TextColorExtension, - BackgroundColorMark, + BackgroundColorExtension, TextAlignmentExtension, // nodes Doc, BlockContainer.configure({ + editor: opts.editor as any, domAttributes: opts.domAttributes, }), BlockGroup.configure({ domAttributes: opts.domAttributes, }), - TableExtension, - ...Object.values(opts.blockSchema).flatMap((blockSpec) => { + ...Object.values(opts.inlineContentSpecs) + .filter((a) => a.config !== "link" && a.config !== "text") + .map((inlineContentSpec) => { + return inlineContentSpec.implementation!.node.configure({ + editor: opts.editor as any, + }); + }), + + ...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, }) @@ -111,7 +123,8 @@ export const getBlockNoteExtensions = (opts: { }), ]; }), - 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/blockManipulation/blockManipulation.test.ts b/packages/core/src/api/blockManipulation/blockManipulation.test.ts index cc6733d5d2..3f8acaa97b 100644 --- a/packages/core/src/api/blockManipulation/blockManipulation.test.ts +++ b/packages/core/src/api/blockManipulation/blockManipulation.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Block, BlockNoteEditor, PartialBlock } from "../.."; +import { BlockNoteEditor } from "../../BlockNoteEditor"; +import { Block, PartialBlock } from "../../extensions/Blocks/api/blocks/types"; +import { + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "../../extensions/Blocks/api/defaultBlocks"; let editor: BlockNoteEditor; @@ -14,14 +20,28 @@ function waitForEditor() { }); } -let singleBlock: PartialBlock; - -let multipleBlocks: PartialBlock[]; - -let insert: (placement: "before" | "nested" | "after") => Block[]; +let singleBlock: PartialBlock< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema +>; + +let multipleBlocks: PartialBlock< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema +>[]; + +let insert: ( + placement: "before" | "nested" | "after" +) => Block< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema +>[]; beforeEach(() => { - editor = new BlockNoteEditor(); + editor = BlockNoteEditor.create(); singleBlock = { type: "paragraph", diff --git a/packages/core/src/api/blockManipulation/blockManipulation.ts b/packages/core/src/api/blockManipulation/blockManipulation.ts index 3b763f85aa..1054e0e35b 100644 --- a/packages/core/src/api/blockManipulation/blockManipulation.ts +++ b/packages/core/src/api/blockManipulation/blockManipulation.ts @@ -1,30 +1,42 @@ import { Editor } from "@tiptap/core"; import { Node } from "prosemirror-model"; + +import { BlockNoteEditor } from "../../BlockNoteEditor"; import { BlockIdentifier, BlockSchema, PartialBlock, -} from "../../extensions/Blocks/api/blockTypes"; +} from "../../extensions/Blocks/api/blocks/types"; +import { InlineContentSchema } from "../../extensions/Blocks/api/inlineContent/types"; +import { StyleSchema } from "../../extensions/Blocks/api/styles/types"; import { blockToNode } from "../nodeConversions/nodeConversions"; import { getNodeById } from "../util/nodeUtil"; -export function insertBlocks( - blocksToInsert: PartialBlock[], +export function insertBlocks< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, placement: "before" | "after" | "nested" = "before", - editor: Editor + editor: BlockNoteEditor ): void { + const ttEditor = editor._tiptapEditor; + const id = typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id; const nodesToInsert: Node[] = []; for (const blockSpec of blocksToInsert) { - nodesToInsert.push(blockToNode(blockSpec, editor.schema)); + nodesToInsert.push( + blockToNode(blockSpec, ttEditor.schema, editor.styleSchema) + ); } let insertionPos = -1; - const { node, posBeforeNode } = getNodeById(id, editor.state.doc); + const { node, posBeforeNode } = getNodeById(id, ttEditor.state.doc); if (placement === "before") { insertionPos = posBeforeNode; @@ -39,13 +51,13 @@ export function insertBlocks( if (node.childCount < 2) { insertionPos = posBeforeNode + node.firstChild!.nodeSize + 1; - const blockGroupNode = editor.state.schema.nodes["blockGroup"].create( + const blockGroupNode = ttEditor.state.schema.nodes["blockGroup"].create( {}, nodesToInsert ); - editor.view.dispatch( - editor.state.tr.insert(insertionPos, blockGroupNode) + ttEditor.view.dispatch( + ttEditor.state.tr.insert(insertionPos, blockGroupNode) ); return; @@ -54,12 +66,16 @@ export function insertBlocks( insertionPos = posBeforeNode + node.firstChild!.nodeSize + 2; } - editor.view.dispatch(editor.state.tr.insert(insertionPos, nodesToInsert)); + ttEditor.view.dispatch(ttEditor.state.tr.insert(insertionPos, nodesToInsert)); } -export function updateBlock( +export function updateBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( blockToUpdate: BlockIdentifier, - update: PartialBlock, + update: PartialBlock, editor: Editor ) { const id = @@ -116,11 +132,15 @@ export function removeBlocks( } } -export function replaceBlocks( +export function replaceBlocks< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( blocksToRemove: BlockIdentifier[], - blocksToInsert: PartialBlock[], - editor: Editor + blocksToInsert: PartialBlock[], + editor: BlockNoteEditor ) { insertBlocks(blocksToInsert, blocksToRemove[0], "before", editor); - removeBlocks(blocksToRemove, editor); + removeBlocks(blocksToRemove, editor._tiptapEditor); } diff --git a/packages/core/src/api/serialization/clipboardHandlerExtension.ts b/packages/core/src/api/exporters/copyExtension.ts similarity index 63% rename from packages/core/src/api/serialization/clipboardHandlerExtension.ts rename to packages/core/src/api/exporters/copyExtension.ts index ef19fef292..4b580b1f86 100644 --- a/packages/core/src/api/serialization/clipboardHandlerExtension.ts +++ b/packages/core/src/api/exporters/copyExtension.ts @@ -1,21 +1,23 @@ -import { BlockSchema } from "../../extensions/Blocks/api/blockTypes"; -import { BlockNoteEditor } from "../../BlockNoteEditor"; import { Extension } from "@tiptap/core"; import { Plugin } from "prosemirror-state"; -import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; -import { createExternalHTMLExporter } from "./html/externalHTMLExporter"; -import { markdown } from "../formatConversions/formatConversions"; -const acceptedMIMETypes = [ - "blocknote/html", - "text/html", - "text/plain", -] as const; +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 { createExternalHTMLExporter } from "./html/externalHTMLExporter"; +import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; +import { cleanHTMLToMarkdown } from "./markdown/markdownExporter"; -export const createClipboardHandlerExtension = ( - editor: BlockNoteEditor +export const createCopyToClipboardExtension = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor ) => - Extension.create<{ editor: BlockNoteEditor }, undefined>({ + Extension.create<{ editor: BlockNoteEditor }, undefined>({ + name: "copyToClipboard", addProseMirrorPlugins() { const tiptap = this.editor; const schema = this.editor.schema; @@ -49,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. @@ -60,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/exporters/html/__snapshots__/complex/misc/external.html b/packages/core/src/api/exporters/html/__snapshots__/complex/misc/external.html new file mode 100644 index 0000000000..c6f43c11b1 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/complex/misc/external.html @@ -0,0 +1 @@ +

    Heading 2

    Paragraph

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

    Heading 2

    Paragraph

    \ No newline at end of file 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/exporters/html/__snapshots__/fontSize/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html new file mode 100644 index 0000000000..3fe864246c --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html @@ -0,0 +1 @@ +

    This is text with a custom fontSize

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

    Text1
    Text2

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

    Text1
    Text2

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

    Link1
    Link2

    \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html new file mode 100644 index 0000000000..f710f08741 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html new file mode 100644 index 0000000000..755d65be05 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html @@ -0,0 +1 @@ +

    Text1

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

    Text1

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

    Link1
    Link1

    \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html new file mode 100644 index 0000000000..eb0b99808d --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html new file mode 100644 index 0000000000..db553727c0 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html @@ -0,0 +1 @@ +

    Text1
    Text2
    Text3

    \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html new file mode 100644 index 0000000000..5ae6ac8b30 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html @@ -0,0 +1 @@ +

    Text1
    Text2
    Text3

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


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


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


    Text1

    \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html new file mode 100644 index 0000000000..436596e499 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html @@ -0,0 +1 @@ +


    Text1

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

    Text1
    Text2

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

    Text1
    Text2

    \ No newline at end of file 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/exporters/html/__snapshots__/link/adjacent/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html new file mode 100644 index 0000000000..8876f46341 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html @@ -0,0 +1 @@ +

    WebsiteWebsite2

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

    Website

    \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html new file mode 100644 index 0000000000..5d7d50c2bc --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html new file mode 100644 index 0000000000..36a369a5e4 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html @@ -0,0 +1 @@ +

    Website

    \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html new file mode 100644 index 0000000000..84e54b7e4a --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html @@ -0,0 +1 @@ + \ No newline at end of file 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/exporters/html/__snapshots__/mention/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html new file mode 100644 index 0000000000..6ca7d81c2c --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.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__/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/exporters/html/__snapshots__/paragraph/empty/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html new file mode 100644 index 0000000000..c659260f6e --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html @@ -0,0 +1 @@ +

    \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html new file mode 100644 index 0000000000..96547312cd --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html @@ -0,0 +1 @@ +

    \ No newline at end of file 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/exporters/html/__snapshots__/small/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html new file mode 100644 index 0000000000..73836f647d --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html @@ -0,0 +1 @@ +

    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/exporters/html/__snapshots__/tag/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html new file mode 100644 index 0000000000..bac28633b0 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html @@ -0,0 +1 @@ +

    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 75% rename from packages/core/src/api/serialization/html/externalHTMLExporter.ts rename to packages/core/src/api/exporters/html/externalHTMLExporter.ts index 9cbb149634..8d62dd587c 100644 --- a/packages/core/src/api/serialization/html/externalHTMLExporter.ts +++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts @@ -1,18 +1,21 @@ import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; -import { blockToNode } from "../../nodeConversions/nodeConversions"; +import rehypeParse from "rehype-parse"; +import rehypeStringify from "rehype-stringify"; +import { unified } from "unified"; + import { BlockNoteEditor } from "../../../BlockNoteEditor"; import { BlockSchema, PartialBlock, -} from "../../../extensions/Blocks/api/blockTypes"; -import { unified } from "unified"; -import rehypeParse from "rehype-parse"; -import { simplifyBlocks } from "../../formatConversions/simplifyBlocksRehypePlugin"; -import rehypeStringify from "rehype-stringify"; +} from "../../../extensions/Blocks/api/blocks/types"; +import { InlineContentSchema } from "../../../extensions/Blocks/api/inlineContent/types"; +import { StyleSchema } from "../../../extensions/Blocks/api/styles/types"; +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 @@ -33,15 +36,23 @@ import { // `exportFragment`: Exports a ProseMirror fragment to HTML. This is mostly // useful if you want to export a selection which may not start/end at the // start/end of a block. -export interface ExternalHTMLExporter { - exportBlocks: (blocks: PartialBlock[]) => string; +export interface ExternalHTMLExporter< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> { + exportBlocks: (blocks: PartialBlock[]) => string; exportProseMirrorFragment: (fragment: Fragment) => string; } -export const createExternalHTMLExporter = ( +export const createExternalHTMLExporter = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( schema: Schema, - editor: BlockNoteEditor -): ExternalHTMLExporter => { + editor: BlockNoteEditor +): ExternalHTMLExporter => { const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { serializeNodeInner: ( node: Node, @@ -50,7 +61,7 @@ export const createExternalHTMLExporter = ( // TODO: Should not be async, but is since we're using a rehype plugin to // convert internal HTML to external HTML. exportProseMirrorFragment: (fragment: Fragment) => string; - exportBlocks: (blocks: PartialBlock[]) => string; + exportBlocks: (blocks: PartialBlock[]) => string; }; serializer.serializeNodeInner = ( @@ -74,8 +85,10 @@ export const createExternalHTMLExporter = ( return externalHTML.value as string; }; - serializer.exportBlocks = (blocks: PartialBlock[]) => { - const nodes = blocks.map((block) => blockToNode(block, schema)); + serializer.exportBlocks = (blocks: PartialBlock[]) => { + const nodes = blocks.map((block) => + blockToNode(block, schema, editor.styleSchema) + ); const blockGroup = schema.nodes["blockGroup"].create(null, nodes); return serializer.exportProseMirrorFragment(Fragment.from(blockGroup)); diff --git a/packages/core/src/api/exporters/html/htmlConversion.test.ts b/packages/core/src/api/exporters/html/htmlConversion.test.ts new file mode 100644 index 0000000000..f6592f1bb7 --- /dev/null +++ b/packages/core/src/api/exporters/html/htmlConversion.test.ts @@ -0,0 +1,383 @@ +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, + BlockSchemaFromSpecs, + BlockSpecs, + PartialBlock, +} from "../../../extensions/Blocks/api/blocks/types"; +import { + DefaultInlineContentSchema, + DefaultStyleSchema, + defaultBlockSpecs, +} from "../../../extensions/Blocks/api/defaultBlocks"; +import { defaultProps } from "../../../extensions/Blocks/api/defaultProps"; +import { InlineContentSchema } from "../../../extensions/Blocks/api/inlineContent/types"; +import { StyleSchema } from "../../../extensions/Blocks/api/styles/types"; +import { + imagePropSchema, + renderImage, +} from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent"; +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY"; +import { EditorTestCases } from "../../testCases"; +import { customInlineContentTestCases } from "../../testCases/cases/customInlineContent"; +import { customStylesTestCases } from "../../testCases/cases/customStyles"; +import { defaultSchemaTestCases } from "../../testCases/cases/defaultSchema"; +import { createExternalHTMLExporter } from "./externalHTMLExporter"; +import { createInternalHTMLSerializer } from "./internalHTMLSerializer"; + +// This is a modified version of the default image block that does not implement +// a `serialize` function. It's used to test if the custom serializer by default +// serializes custom blocks using their `render` function. +const SimpleImage = createBlockSpec( + { + type: "simpleImage" as const, + propSchema: imagePropSchema, + content: "none", + }, + { render: renderImage as any } +); + +const CustomParagraph = createBlockSpec( + { + type: "customParagraph" as const, + propSchema: defaultProps, + content: "inline", + }, + { + render: () => { + const paragraph = document.createElement("p"); + paragraph.className = "custom-paragraph"; + + return { + dom: paragraph, + contentDOM: paragraph, + }; + }, + toExternalHTML: () => { + const paragraph = document.createElement("p"); + paragraph.className = "custom-paragraph"; + paragraph.innerHTML = "Hello World"; + + return { + dom: paragraph, + }; + }, + } +); + +const SimpleCustomParagraph = createBlockSpec( + { + type: "simpleCustomParagraph" as const, + propSchema: defaultProps, + content: "inline", + }, + { + render: () => { + const paragraph = document.createElement("p"); + paragraph.className = "simple-custom-paragraph"; + + return { + dom: paragraph, + contentDOM: paragraph, + }; + }, + } +); + +const customSpecs = { + ...defaultBlockSpecs, + simpleImage: SimpleImage, + customParagraph: CustomParagraph, + simpleCustomParagraph: SimpleCustomParagraph, +} satisfies BlockSpecs; + +const editorTestCases: EditorTestCases< + BlockSchemaFromSpecs, + DefaultInlineContentSchema, + DefaultStyleSchema +> = { + name: "custom schema", + createEditor: () => { + return BlockNoteEditor.create({ + blockSpecs: customSpecs, + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + }); + }, + documents: [ + { + name: "simpleImage/button", + blocks: [ + { + type: "simpleImage" as const, + }, + ], + }, + { + name: "simpleImage/basic", + blocks: [ + { + type: "simpleImage" as const, + props: { + url: "exampleURL", + caption: "Caption", + width: 256, + } as const, + }, + ], + }, + { + name: "simpleImage/nested", + blocks: [ + { + type: "simpleImage" as const, + props: { + url: "exampleURL", + caption: "Caption", + width: 256, + } as const, + children: [ + { + type: "simpleImage" as const, + props: { + url: "exampleURL", + caption: "Caption", + width: 256, + } as const, + }, + ], + }, + ], + }, + { + name: "customParagraph/basic", + blocks: [ + { + type: "customParagraph" as const, + content: "Custom Paragraph", + }, + ], + }, + { + name: "customParagraph/styled", + blocks: [ + { + type: "customParagraph" as const, + props: { + textAlignment: "center", + textColor: "orange", + backgroundColor: "pink", + } as const, + content: [ + { + type: "text", + styles: {}, + text: "Plain ", + }, + { + type: "text", + styles: { + textColor: "red", + }, + text: "Red Text ", + }, + { + type: "text", + styles: { + backgroundColor: "blue", + }, + text: "Blue Background ", + }, + { + type: "text", + styles: { + textColor: "red", + backgroundColor: "blue", + }, + text: "Mixed Colors", + }, + ], + }, + ], + }, + { + name: "customParagraph/nested", + blocks: [ + { + type: "customParagraph" as const, + content: "Custom Paragraph", + children: [ + { + type: "customParagraph" as const, + content: "Nested Custom Paragraph 1", + }, + { + type: "customParagraph" as const, + content: "Nested Custom Paragraph 2", + }, + ], + }, + ], + }, + { + name: "simpleCustomParagraph/basic", + blocks: [ + { + type: "simpleCustomParagraph" as const, + content: "Custom Paragraph", + }, + ], + }, + { + name: "simpleCustomParagraph/styled", + blocks: [ + { + type: "simpleCustomParagraph" as const, + props: { + textAlignment: "center", + textColor: "orange", + backgroundColor: "pink", + } as const, + content: [ + { + type: "text", + styles: {}, + text: "Plain ", + }, + { + type: "text", + styles: { + textColor: "red", + }, + text: "Red Text ", + }, + { + type: "text", + styles: { + backgroundColor: "blue", + }, + text: "Blue Background ", + }, + { + type: "text", + styles: { + textColor: "red", + backgroundColor: "blue", + }, + text: "Mixed Colors", + }, + ], + }, + ], + }, + { + name: "simpleCustomParagraph/nested", + blocks: [ + { + type: "simpleCustomParagraph" as const, + content: "Custom Paragraph", + children: [ + { + type: "simpleCustomParagraph" as const, + content: "Nested Custom Paragraph 1", + }, + { + type: "simpleCustomParagraph" as const, + content: "Nested Custom Paragraph 2", + }, + ], + }, + ], + }, + ], +}; + +async function convertToHTMLAndCompareSnapshots< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + blocks: PartialBlock[], + snapshotDirectory: string, + snapshotName: string +) { + addIdsToBlocks(blocks); + const serializer = createInternalHTMLSerializer( + editor._tiptapEditor.schema, + editor + ); + const internalHTML = serializer.serializeBlocks(blocks); + const internalHTMLSnapshotPath = + "./__snapshots__/" + + snapshotDirectory + + "/" + + snapshotName + + "/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 + ); + const externalHTML = exporter.exportBlocks(blocks); + const externalHTMLSnapshotPath = + "./__snapshots__/" + + snapshotDirectory + + "/" + + snapshotName + + "/external.html"; + expect(externalHTML).toMatchFileSnapshot(externalHTMLSnapshotPath); +} + +const testCases = [ + defaultSchemaTestCases, + editorTestCases, + customStylesTestCases, + customInlineContentTestCases, +]; + +describe("Test HTML conversion", () => { + for (const testCase of testCases) { + describe("Case: " + testCase.name, () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = testCase.createEditor(); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + }); + + for (const document of testCase.documents) { + // eslint-disable-next-line no-loop-func + it("Convert " + document.name + " to HTML", async () => { + const nameSplit = document.name.split("/"); + await convertToHTMLAndCompareSnapshots( + editor, + document.blocks, + nameSplit[0], + nameSplit[1] + ); + }); + } + }); + } +}); diff --git a/packages/core/src/api/serialization/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts similarity index 69% rename from packages/core/src/api/serialization/html/internalHTMLSerializer.ts rename to packages/core/src/api/exporters/html/internalHTMLSerializer.ts index 77ed002d23..77785dd0ac 100644 --- a/packages/core/src/api/serialization/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -3,12 +3,14 @@ import { BlockNoteEditor } from "../../../BlockNoteEditor"; import { BlockSchema, PartialBlock, -} from "../../../extensions/Blocks/api/blockTypes"; +} from "../../../extensions/Blocks/api/blocks/types"; +import { InlineContentSchema } from "../../../extensions/Blocks/api/inlineContent/types"; +import { StyleSchema } from "../../../extensions/Blocks/api/styles/types"; +import { blockToNode } from "../../nodeConversions/nodeConversions"; import { serializeNodeInner, serializeProseMirrorFragment, -} from "./sharedHTMLConversion"; -import { blockToNode } from "../../nodeConversions/nodeConversions"; +} 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 @@ -25,23 +27,31 @@ import { blockToNode } from "../../nodeConversions/nodeConversions"; // mostly useful if you want to serialize a selection which may not start/end at // the start/end of a block. // `serializeBlocks`: Serializes an array of blocks to HTML. -export interface InternalHTMLSerializer { +export interface InternalHTMLSerializer< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> { // TODO: Ideally we would expand the BlockNote API to support partial // selections so we don't need this. serializeProseMirrorFragment: (fragment: Fragment) => string; - serializeBlocks: (blocks: PartialBlock[]) => string; + serializeBlocks: (blocks: PartialBlock[]) => string; } -export const createInternalHTMLSerializer = ( +export const createInternalHTMLSerializer = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( schema: Schema, - editor: BlockNoteEditor -): InternalHTMLSerializer => { + editor: BlockNoteEditor +): InternalHTMLSerializer => { const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { serializeNodeInner: ( node: Node, options: { document?: Document } ) => HTMLElement; - serializeBlocks: (blocks: PartialBlock[]) => string; + serializeBlocks: (blocks: PartialBlock[]) => string; serializeProseMirrorFragment: ( fragment: Fragment, options?: { document?: Document | undefined } | undefined, @@ -57,8 +67,10 @@ export const createInternalHTMLSerializer = ( serializer.serializeProseMirrorFragment = (fragment: Fragment) => serializeProseMirrorFragment(fragment, serializer); - serializer.serializeBlocks = (blocks: PartialBlock[]) => { - const nodes = blocks.map((block) => blockToNode(block, schema)); + serializer.serializeBlocks = (blocks: PartialBlock[]) => { + const nodes = blocks.map((block) => + blockToNode(block, schema, editor.styleSchema) + ); const blockGroup = schema.nodes["blockGroup"].create(null, nodes); return serializer.serializeProseMirrorFragment(Fragment.from(blockGroup)); diff --git a/packages/core/src/api/serialization/html/sharedHTMLConversion.ts b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts similarity index 72% rename from packages/core/src/api/serialization/html/sharedHTMLConversion.ts rename to packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts index dfe1216b68..79413388ad 100644 --- a/packages/core/src/api/serialization/html/sharedHTMLConversion.ts +++ b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts @@ -1,7 +1,10 @@ import { DOMSerializer, Fragment, Node } from "prosemirror-model"; -import { BlockNoteEditor } from "../../../BlockNoteEditor"; -import { BlockSchema } from "../../../extensions/Blocks/api/blockTypes"; -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; @@ -13,13 +16,20 @@ function doc(options: { document?: Document }) { // `blockContent` node, the `toInternalHTML` or `toExternalHTML` function of its // corresponding block is used for serialization instead of the node's // `renderHTML` method. -export const serializeNodeInner = ( +export const serializeNodeInner = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( node: Node, options: { document?: Document }, serializer: DOMSerializer, - editor: BlockNoteEditor, + editor: BlockNoteEditor, toExternalHTML: boolean ) => { + if (!serializer.nodes[node.type.name]) { + throw new Error("Serializer is missing a node type: " + node.type.name); + } const { dom, contentDOM } = DOMSerializer.renderSpec( doc(options), serializer.nodes[node.type.name](node) @@ -34,14 +44,21 @@ export const serializeNodeInner = ( if (node.type.name === "blockContainer") { // Converts `blockContent` node using the custom `blockSpec`'s // `toExternalHTML` or `toInternalHTML` function. - const blockSpec = - editor.schema[node.firstChild!.type.name as keyof BSchema]; + const blockImpl = + editor.blockImplementations[node.firstChild!.type.name as string] + .implementation; const toHTML = toExternalHTML - ? blockSpec.implementation.toExternalHTML - : blockSpec.implementation.toInternalHTML; + ? blockImpl.toExternalHTML + : blockImpl.toInternalHTML; const blockContent = toHTML( - nodeToBlock(node, editor.schema, editor.blockCache), + nodeToBlock( + node, + editor.blockSchema, + editor.inlineContentSchema, + editor.styleSchema, + editor.blockCache + ), editor as any ); 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.testOld.ts b/packages/core/src/api/formatConversions/formatConversions.testOld.ts deleted file mode 100644 index ddb9908858..0000000000 --- a/packages/core/src/api/formatConversions/formatConversions.testOld.ts +++ /dev/null @@ -1,749 +0,0 @@ -// import { afterEach, beforeEach, describe, expect, it } from "vitest"; -// import { Block, BlockNoteEditor } from "../.."; -// import UniqueID from "../../extensions/UniqueID/UniqueID"; -// -// let editor: BlockNoteEditor; -// -// const getNonNestedBlocks = (): Block[] => [ -// { -// id: UniqueID.options.generateID(), -// type: "heading", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// level: 1, -// }, -// content: [ -// { -// type: "text", -// text: "Heading", -// styles: {}, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "paragraph", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Paragraph", -// styles: {}, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "bulletListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Bullet List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "numberedListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Numbered List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// ]; -// -// const getNestedBlocks = (): Block[] => [ -// { -// id: UniqueID.options.generateID(), -// type: "heading", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// level: 1, -// }, -// content: [ -// { -// type: "text", -// text: "Heading", -// styles: {}, -// }, -// ], -// children: [ -// { -// id: UniqueID.options.generateID(), -// type: "paragraph", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Paragraph", -// styles: {}, -// }, -// ], -// children: [ -// { -// id: UniqueID.options.generateID(), -// type: "bulletListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Bullet List Item", -// styles: {}, -// }, -// ], -// children: [ -// { -// id: UniqueID.options.generateID(), -// type: "numberedListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Numbered List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// ], -// }, -// ], -// }, -// ], -// }, -// ]; -// -// const getStyledBlocks = (): Block[] => [ -// { -// id: UniqueID.options.generateID(), -// type: "paragraph", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// 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: "TextColor", -// styles: { -// textColor: "red", -// }, -// }, -// { -// type: "text", -// text: "BackgroundColor", -// styles: { -// backgroundColor: "red", -// }, -// }, -// { -// type: "text", -// text: "Multiple", -// styles: { -// bold: true, -// italic: true, -// }, -// }, -// ], -// children: [], -// }, -// ]; -// -// const getComplexBlocks = (): Block[] => [ -// { -// id: UniqueID.options.generateID(), -// type: "heading", -// props: { -// backgroundColor: "red", -// textColor: "yellow", -// textAlignment: "right", -// level: 1, -// }, -// content: [ -// { -// type: "text", -// text: "Heading 1", -// styles: {}, -// }, -// ], -// children: [ -// { -// id: UniqueID.options.generateID(), -// type: "heading", -// props: { -// backgroundColor: "orange", -// textColor: "orange", -// textAlignment: "center", -// level: 2, -// }, -// content: [ -// { -// type: "text", -// text: "Heading 2", -// styles: {}, -// }, -// ], -// children: [ -// { -// id: UniqueID.options.generateID(), -// type: "heading", -// props: { -// backgroundColor: "yellow", -// textColor: "red", -// textAlignment: "left", -// level: 3, -// }, -// content: [ -// { -// type: "text", -// text: "Heading 3", -// styles: {}, -// }, -// ], -// children: [], -// }, -// ], -// }, -// ], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "paragraph", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Paragraph", -// styles: { -// textColor: "purple", -// backgroundColor: "green", -// }, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "paragraph", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "P", -// styles: {}, -// }, -// { -// type: "text", -// text: "ara", -// styles: { -// bold: true, -// }, -// }, -// { -// type: "text", -// text: "grap", -// styles: { -// italic: true, -// }, -// }, -// { -// type: "text", -// text: "h", -// styles: {}, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "paragraph", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "P", -// styles: {}, -// }, -// { -// type: "text", -// text: "ara", -// styles: { -// underline: true, -// }, -// }, -// { -// type: "text", -// text: "grap", -// styles: { -// strike: true, -// }, -// }, -// { -// type: "text", -// text: "h", -// styles: {}, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "bulletListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Bullet List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "bulletListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Bullet List Item", -// styles: {}, -// }, -// ], -// children: [ -// { -// id: UniqueID.options.generateID(), -// type: "bulletListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Bullet List Item", -// styles: {}, -// }, -// ], -// children: [ -// { -// id: UniqueID.options.generateID(), -// type: "bulletListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Bullet List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "paragraph", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Paragraph", -// styles: {}, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "numberedListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Numbered List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "numberedListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Numbered List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "numberedListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Numbered List Item", -// styles: {}, -// }, -// ], -// children: [ -// { -// id: UniqueID.options.generateID(), -// type: "numberedListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Numbered List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// ], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "bulletListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Bullet List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// ], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "bulletListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Bullet List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// ], -// }, -// { -// id: UniqueID.options.generateID(), -// type: "bulletListItem", -// props: { -// backgroundColor: "default", -// textColor: "default", -// textAlignment: "left", -// }, -// content: [ -// { -// type: "text", -// text: "Bullet List Item", -// styles: {}, -// }, -// ], -// children: [], -// }, -// ]; -// -// function removeInlineContentClass(html: string) { -// return html.replace(/ class="_inlineContent_([a-zA-Z0-9_-])+"/g, ""); -// } -// -// beforeEach(() => { -// editor = new BlockNoteEditor(); -// }); -// -// afterEach(() => { -// editor._tiptapEditor.destroy(); -// editor = undefined as any; -// }); -// -// describe("Non-Nested Block/HTML/Markdown Conversions", () => { -// it("Convert non-nested blocks to HTML", async () => { -// const output = await editor.blocksToHTML(getNonNestedBlocks()); -// -// expect(removeInlineContentClass(output)).toMatchSnapshot(); -// }); -// -// it("Convert non-nested blocks to Markdown", async () => { -// const output = await editor.blocksToMarkdown(getNonNestedBlocks()); -// -// expect(output).toMatchSnapshot(); -// }); -// -// it("Convert non-nested HTML to blocks", async () => { -// const html = `

    Heading

    Paragraph

    • Bullet List Item

    1. Numbered List Item

    `; -// const output = await editor.HTMLToBlocks(html); -// -// expect(output).toMatchSnapshot(); -// }); -// -// it("Convert non-nested Markdown to blocks", async () => { -// const markdown = `# Heading -// -// Paragraph -// -// * Bullet List Item -// -// 1. Numbered List Item -// `; -// const output = await editor.markdownToBlocks(markdown); -// -// expect(output).toMatchSnapshot(); -// }); -// }); -// -// describe("Nested Block/HTML/Markdown Conversions", () => { -// it("Convert nested blocks to HTML", async () => { -// const output = await editor.blocksToHTML(getNestedBlocks()); -// -// expect(removeInlineContentClass(output)).toMatchSnapshot(); -// }); -// -// it("Convert nested blocks to Markdown", async () => { -// const output = await editor.blocksToMarkdown(getNestedBlocks()); -// -// expect(output).toMatchSnapshot(); -// }); -// // // Failing due to nested block parsing bug. -// // it("Convert nested HTML to blocks", async () => { -// // const html = `

    Heading

    Paragraph

    • Bullet List Item

      1. Numbered List Item

    `; -// // const output = await editor.HTMLToBlocks(html); -// // -// // expect(output).toMatchSnapshot(); -// // }); -// // // Failing due to nested block parsing bug. -// // it("Convert nested Markdown to blocks", async () => { -// // const markdown = `# Heading -// // -// // Paragraph -// // -// // * Bullet List Item -// // -// // 1. Numbered List Item -// // `; -// // const output = await editor.markdownToBlocks(markdown); -// // -// // expect(output).toMatchSnapshot(); -// // }); -// }); -// -// describe("Styled Block/HTML/Markdown Conversions", () => { -// it("Convert styled blocks to HTML", async () => { -// const output = await editor.blocksToHTML(getStyledBlocks()); -// -// expect(removeInlineContentClass(output)).toMatchSnapshot(); -// }); -// -// it("Convert styled blocks to Markdown", async () => { -// const output = await editor.blocksToMarkdown(getStyledBlocks()); -// -// expect(output).toMatchSnapshot(); -// }); -// -// it("Convert styled HTML to blocks", async () => { -// const html = `

    BoldItalicUnderlineStrikethroughTextColorBackgroundColorMultiple

    `; -// const output = await editor.HTMLToBlocks(html); -// -// expect(output).toMatchSnapshot(); -// }); -// -// it("Convert styled Markdown to blocks", async () => { -// const markdown = `**Bold***Italic*Underline~~Strikethrough~~TextColorBackgroundColor***Multiple***`; -// const output = await editor.markdownToBlocks(markdown); -// -// expect(output).toMatchSnapshot(); -// }); -// }); -// -// describe("Complex Block/HTML/Markdown Conversions", () => { -// it("Convert complex blocks to HTML", async () => { -// const output = await editor.blocksToHTML(getComplexBlocks()); -// -// expect(removeInlineContentClass(output)).toMatchSnapshot(); -// }); -// -// it("Convert complex blocks to Markdown", async () => { -// const output = await editor.blocksToMarkdown(getComplexBlocks()); -// -// expect(output).toMatchSnapshot(); -// }); -// // // Failing due to nested block parsing bug. -// // it("Convert complex HTML to blocks", async () => { -// // const html = `

    Heading 1

    Heading 2

    Heading 3

    Paragraph

    Paragraph

    Paragraph

    • Bullet List Item

    • Bullet List Item

      • Bullet List Item

        • Bullet List Item

        Paragraph

        1. Numbered List Item

        2. Numbered List Item

        3. Numbered List Item

          1. Numbered List Item

        • Bullet List Item

      • Bullet List Item

    • Bullet List Item

    `; -// // const output = await editor.HTMLToBlocks(html); -// // -// // expect(output).toMatchSnapshot(); -// // }); -// // // Failing due to nested block parsing bug. -// // it("Convert complex Markdown to blocks", async () => { -// // const markdown = `# Heading 1 -// // -// // ## Heading 2 -// // -// // ### Heading 3 -// // -// // Paragraph -// // -// // P**ara***grap*h -// // -// // P*ara*~~grap~~h -// // -// // * Bullet List Item -// // -// // * Bullet List Item -// // -// // * Bullet List Item -// // -// // * Bullet List Item -// // -// // Paragraph -// // -// // 1. Numbered List Item -// // -// // 2. Numbered List Item -// // -// // 3. Numbered List Item -// // -// // 1. Numbered List Item -// // -// // * Bullet List Item -// // -// // * Bullet List Item -// // -// // * Bullet List Item -// // `; -// // const output = await editor.markdownToBlocks(markdown); -// // -// // expect(output).toMatchSnapshot(); -// // }); -// }); 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 92be1196e5..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,134 @@
     // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
     
    -exports[`Complex ProseMirror Node Conversions > Convert complex block to node 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: custom inline content schema > Convert mention/basic to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
    +        {
    +          "text": "I enjoy working with",
    +          "type": "text",
    +        },
    +        {
    +          "attrs": {
    +            "user": "Matthew",
    +          },
    +          "type": "mention",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: custom inline content schema > Convert tag/basic to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
    +        {
    +          "text": "I love ",
    +          "type": "text",
    +        },
    +        {
    +          "content": [
    +            {
    +              "text": "BlockNote",
    +              "type": "text",
    +            },
    +          ],
    +          "type": "tag",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert fontSize/basic to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "stringValue": "18px",
    +              },
    +              "type": "fontSize",
    +            },
    +          ],
    +          "text": "This is text with a custom fontSize",
    +          "type": "text",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert small/basic to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
    +        {
    +          "marks": [
    +            {
    +              "type": "small",
    +            },
    +          ],
    +          "text": "This is a small text",
    +          "type": "text",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert complex/misc to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "blue",
    @@ -89,68 +217,91 @@ exports[`Complex ProseMirror Node Conversions > Convert complex block to node 1`
     }
     `;
     
    -exports[`Complex ProseMirror Node Conversions > Convert complex node to block 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/basic to/from prosemirror 1`] = `
     {
    -  "children": [
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
         {
    -      "children": [],
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
           "content": [
             {
    -          "styles": {},
    -          "text": "Paragraph",
    +          "text": "Text1",
    +          "type": "text",
    +        },
    +        {
    +          "type": "hardBreak",
    +        },
    +        {
    +          "text": "Text2",
               "type": "text",
             },
           ],
    -      "id": "2",
    -      "props": {
    -        "backgroundColor": "red",
    -        "textAlignment": "left",
    -        "textColor": "default",
    -      },
           "type": "paragraph",
         },
    -    {
    -      "children": [],
    -      "content": [],
    -      "id": "3",
    -      "props": {
    -        "backgroundColor": "default",
    -        "textAlignment": "left",
    -        "textColor": "default",
    -      },
    -      "type": "bulletListItem",
    -    },
       ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/between-links to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
       "content": [
         {
    -      "styles": {
    -        "bold": true,
    -        "underline": true,
    -      },
    -      "text": "Heading ",
    -      "type": "text",
    -    },
    -    {
    -      "styles": {
    -        "italic": true,
    -        "strike": true,
    +      "attrs": {
    +        "textAlignment": "left",
           },
    -      "text": "2",
    -      "type": "text",
    +      "content": [
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "class": null,
    +                "href": "https://www.website.com",
    +                "target": "_blank",
    +              },
    +              "type": "link",
    +            },
    +          ],
    +          "text": "Link1",
    +          "type": "text",
    +        },
    +        {
    +          "type": "hardBreak",
    +        },
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "class": null,
    +                "href": "https://www.website2.com",
    +                "target": "_blank",
    +              },
    +              "type": "link",
    +            },
    +          ],
    +          "text": "Link2",
    +          "type": "text",
    +        },
    +      ],
    +      "type": "paragraph",
         },
       ],
    -  "id": "1",
    -  "props": {
    -    "backgroundColor": "blue",
    -    "level": 2,
    -    "textAlignment": "right",
    -    "textColor": "yellow",
    -  },
    -  "type": "heading",
    +  "type": "blockContainer",
     }
     `;
     
    -exports[`Simple ProseMirror Node Conversions > Convert simple block to node 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/end to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -162,6 +313,15 @@ exports[`Simple ProseMirror Node Conversions > Convert simple block to node 1`]
           "attrs": {
             "textAlignment": "left",
           },
    +      "content": [
    +        {
    +          "text": "Text1",
    +          "type": "text",
    +        },
    +        {
    +          "type": "hardBreak",
    +        },
    +      ],
           "type": "paragraph",
         },
       ],
    @@ -169,21 +329,59 @@ exports[`Simple ProseMirror Node Conversions > Convert simple block to node 1`]
     }
     `;
     
    -exports[`Simple ProseMirror Node Conversions > Convert simple node to block 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/link to/from prosemirror 1`] = `
     {
    -  "children": [],
    -  "content": [],
    -  "id": "1",
    -  "props": {
    +  "attrs": {
         "backgroundColor": "default",
    -    "textAlignment": "left",
    +    "id": "1",
         "textColor": "default",
       },
    -  "type": "paragraph",
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "class": null,
    +                "href": "https://www.website.com",
    +                "target": "_blank",
    +              },
    +              "type": "link",
    +            },
    +          ],
    +          "text": "Link1",
    +          "type": "text",
    +        },
    +        {
    +          "type": "hardBreak",
    +        },
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "class": null,
    +                "href": "https://www.website.com",
    +                "target": "_blank",
    +              },
    +              "type": "link",
    +            },
    +          ],
    +          "text": "Link1",
    +          "type": "text",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/multiple to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -207,6 +405,13 @@ exports[`hard breaks > Convert a block with a hard break 1`] = `
               "text": "Text2",
               "type": "text",
             },
    +        {
    +          "type": "hardBreak",
    +        },
    +        {
    +          "text": "Text3",
    +          "type": "text",
    +        },
           ],
           "type": "paragraph",
         },
    @@ -215,7 +420,7 @@ exports[`hard breaks > Convert a block with a hard break 1`] = `
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break and different styles 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/only to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -229,19 +434,34 @@ exports[`hard breaks > Convert a block with a hard break and different styles 1`
           },
           "content": [
             {
    -          "text": "Text1",
    -          "type": "text",
    +          "type": "hardBreak",
             },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/start to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
             {
               "type": "hardBreak",
             },
             {
    -          "marks": [
    -            {
    -              "type": "bold",
    -            },
    -          ],
    -          "text": "Text2",
    +          "text": "Text1",
               "type": "text",
             },
           ],
    @@ -252,7 +472,7 @@ exports[`hard breaks > Convert a block with a hard break and different styles 1`
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break at the end 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/styles to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -272,6 +492,15 @@ exports[`hard breaks > Convert a block with a hard break at the end 1`] = `
             {
               "type": "hardBreak",
             },
    +        {
    +          "marks": [
    +            {
    +              "type": "bold",
    +            },
    +          ],
    +          "text": "Text2",
    +          "type": "text",
    +        },
           ],
           "type": "paragraph",
         },
    @@ -280,7 +509,7 @@ exports[`hard breaks > Convert a block with a hard break at the end 1`] = `
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break at the start 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert image/basic to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -290,25 +519,87 @@ exports[`hard breaks > Convert a block with a hard break at the start 1`] = `
       "content": [
         {
           "attrs": {
    +        "caption": "Caption",
             "textAlignment": "left",
    +        "url": "exampleURL",
    +        "width": 256,
           },
    +      "type": "image",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert image/button to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "caption": "",
    +        "textAlignment": "left",
    +        "url": "",
    +        "width": 512,
    +      },
    +      "type": "image",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert image/nested to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "caption": "Caption",
    +        "textAlignment": "left",
    +        "url": "exampleURL",
    +        "width": 256,
    +      },
    +      "type": "image",
    +    },
    +    {
           "content": [
             {
    -          "type": "hardBreak",
    -        },
    -        {
    -          "text": "Text1",
    -          "type": "text",
    +          "attrs": {
    +            "backgroundColor": "default",
    +            "id": "2",
    +            "textColor": "default",
    +          },
    +          "content": [
    +            {
    +              "attrs": {
    +                "caption": "Caption",
    +                "textAlignment": "left",
    +                "url": "exampleURL",
    +                "width": 256,
    +              },
    +              "type": "image",
    +            },
    +          ],
    +          "type": "blockContainer",
             },
           ],
    -      "type": "paragraph",
    +      "type": "blockGroup",
         },
       ],
       "type": "blockContainer",
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break between links 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert link/adjacent to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -332,12 +623,9 @@ exports[`hard breaks > Convert a block with a hard break between links 1`] = `
                   "type": "link",
                 },
               ],
    -          "text": "Link1",
    +          "text": "Website",
               "type": "text",
             },
    -        {
    -          "type": "hardBreak",
    -        },
             {
               "marks": [
                 {
    @@ -349,7 +637,7 @@ exports[`hard breaks > Convert a block with a hard break between links 1`] = `
                   "type": "link",
                 },
               ],
    -          "text": "Link2",
    +          "text": "Website2",
               "type": "text",
             },
           ],
    @@ -360,7 +648,7 @@ exports[`hard breaks > Convert a block with a hard break between links 1`] = `
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert link/basic to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -384,11 +672,46 @@ exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
                   "type": "link",
                 },
               ],
    -          "text": "Link1",
    +          "text": "Website",
               "type": "text",
             },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert link/styled to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
             {
    -          "type": "hardBreak",
    +          "marks": [
    +            {
    +              "type": "bold",
    +            },
    +            {
    +              "attrs": {
    +                "class": null,
    +                "href": "https://www.website.com",
    +                "target": "_blank",
    +              },
    +              "type": "link",
    +            },
    +          ],
    +          "text": "Web",
    +          "type": "text",
             },
             {
               "marks": [
    @@ -401,7 +724,7 @@ exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
                   "type": "link",
                 },
               ],
    -          "text": "Link1",
    +          "text": "site",
               "type": "text",
             },
           ],
    @@ -412,7 +735,7 @@ exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
     }
     `;
     
    -exports[`hard breaks > Convert a block with multiple hard breaks 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/basic to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -426,21 +749,7 @@ exports[`hard breaks > Convert a block with multiple hard breaks 1`] = `
           },
           "content": [
             {
    -          "text": "Text1",
    -          "type": "text",
    -        },
    -        {
    -          "type": "hardBreak",
    -        },
    -        {
    -          "text": "Text2",
    -          "type": "text",
    -        },
    -        {
    -          "type": "hardBreak",
    -        },
    -        {
    -          "text": "Text3",
    +          "text": "Paragraph",
               "type": "text",
             },
           ],
    @@ -451,7 +760,7 @@ exports[`hard breaks > Convert a block with multiple hard breaks 1`] = `
     }
     `;
     
    -exports[`hard breaks > Convert a block with only a hard break 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/empty to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -463,11 +772,6 @@ exports[`hard breaks > Convert a block with only a hard break 1`] = `
           "attrs": {
             "textAlignment": "left",
           },
    -      "content": [
    -        {
    -          "type": "hardBreak",
    -        },
    -      ],
           "type": "paragraph",
         },
       ],
    @@ -475,7 +779,7 @@ exports[`hard breaks > Convert a block with only a hard break 1`] = `
     }
     `;
     
    -exports[`links > Convert a block with link 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/nested to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -489,66 +793,123 @@ exports[`links > Convert a block with link 1`] = `
           },
           "content": [
             {
    -          "marks": [
    +          "text": "Paragraph",
    +          "type": "text",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +    {
    +      "content": [
    +        {
    +          "attrs": {
    +            "backgroundColor": "default",
    +            "id": "2",
    +            "textColor": "default",
    +          },
    +          "content": [
                 {
                   "attrs": {
    -                "class": null,
    -                "href": "https://www.website.com",
    -                "target": "_blank",
    +                "textAlignment": "left",
                   },
    -              "type": "link",
    +              "content": [
    +                {
    +                  "text": "Nested Paragraph 1",
    +                  "type": "text",
    +                },
    +              ],
    +              "type": "paragraph",
                 },
               ],
    -          "text": "Website",
    -          "type": "text",
    +          "type": "blockContainer",
    +        },
    +        {
    +          "attrs": {
    +            "backgroundColor": "default",
    +            "id": "3",
    +            "textColor": "default",
    +          },
    +          "content": [
    +            {
    +              "attrs": {
    +                "textAlignment": "left",
    +              },
    +              "content": [
    +                {
    +                  "text": "Nested Paragraph 2",
    +                  "type": "text",
    +                },
    +              ],
    +              "type": "paragraph",
    +            },
    +          ],
    +          "type": "blockContainer",
             },
           ],
    -      "type": "paragraph",
    +      "type": "blockGroup",
         },
       ],
       "type": "blockContainer",
     }
     `;
     
    -exports[`links > Convert two adjacent links in a block 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/styled to/from prosemirror 1`] = `
     {
       "attrs": {
    -    "backgroundColor": "default",
    +    "backgroundColor": "pink",
         "id": "1",
    -    "textColor": "default",
    +    "textColor": "orange",
       },
       "content": [
         {
           "attrs": {
    -        "textAlignment": "left",
    +        "textAlignment": "center",
           },
           "content": [
    +        {
    +          "text": "Plain ",
    +          "type": "text",
    +        },
             {
               "marks": [
                 {
                   "attrs": {
    -                "class": null,
    -                "href": "https://www.website.com",
    -                "target": "_blank",
    +                "stringValue": "red",
                   },
    -              "type": "link",
    +              "type": "textColor",
                 },
               ],
    -          "text": "Website",
    +          "text": "Red Text ",
               "type": "text",
             },
             {
               "marks": [
                 {
                   "attrs": {
    -                "class": null,
    -                "href": "https://www.website2.com",
    -                "target": "_blank",
    +                "stringValue": "blue",
                   },
    -              "type": "link",
    +              "type": "backgroundColor",
                 },
               ],
    -          "text": "Website2",
    +          "text": "Blue Background ",
    +          "type": "text",
    +        },
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "stringValue": "red",
    +              },
    +              "type": "textColor",
    +            },
    +            {
    +              "attrs": {
    +                "stringValue": "blue",
    +              },
    +              "type": "backgroundColor",
    +            },
    +          ],
    +          "text": "Mixed Colors",
               "type": "text",
             },
           ],
    diff --git a/packages/core/src/api/nodeConversions/nodeConversions.test.ts b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
    index 0207f27054..be1d1cfaf2 100644
    --- a/packages/core/src/api/nodeConversions/nodeConversions.test.ts
    +++ b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
    @@ -1,464 +1,70 @@
    -import { Editor } from "@tiptap/core";
     import { afterEach, beforeEach, describe, expect, it } from "vitest";
    -import { BlockNoteEditor, PartialBlock } from "../..";
    -import {
    -  DefaultBlockSchema,
    -  defaultBlockSchema,
    -} from "../../extensions/Blocks/api/defaultBlocks";
    -import UniqueID from "../../extensions/UniqueID/UniqueID";
    -import { blockToNode, nodeToBlock } from "./nodeConversions";
    -import { partialBlockToBlockForTesting } from "./testUtil";
    -
    -let editor: BlockNoteEditor;
    -let tt: Editor;
    -
    -beforeEach(() => {
    -  editor = new BlockNoteEditor();
    -  tt = editor._tiptapEditor;
    -});
    -
    -afterEach(() => {
    -  tt.destroy();
    -  editor = undefined as any;
    -  tt = undefined as any;
    -});
    -
    -describe("Simple ProseMirror Node Conversions", () => {
    -  it("Convert simple block to node", async () => {
    -    const block: PartialBlock = {
    -      type: "paragraph",
    -    };
    -    const firstNodeConversion = blockToNode(
    -      block,
    -      tt.schema
    -    );
    -
    -    expect(firstNodeConversion).toMatchSnapshot();
    -  });
    -
    -  it("Convert simple node to block", async () => {
    -    const node = tt.schema.nodes["blockContainer"].create(
    -      { id: UniqueID.options.generateID() },
    -      tt.schema.nodes["paragraph"].create()
    -    );
    -    const firstBlockConversion = nodeToBlock(node, defaultBlockSchema);
    -
    -    expect(firstBlockConversion).toMatchSnapshot();
    -
    -    const firstNodeConversion = blockToNode(
    -      firstBlockConversion,
    -      tt.schema
    -    );
    -
    -    expect(firstNodeConversion).toStrictEqual(node);
    -  });
    -});
    -
    -describe("Complex ProseMirror Node Conversions", () => {
    -  it("Convert complex block to node", async () => {
    -    const block: PartialBlock = {
    -      type: "heading",
    -      props: {
    -        backgroundColor: "blue",
    -        textColor: "yellow",
    -        textAlignment: "right",
    -        level: 2,
    -      },
    -      content: [
    -        {
    -          type: "text",
    -          text: "Heading ",
    -          styles: {
    -            bold: true,
    -            underline: true,
    -          },
    -        },
    -        {
    -          type: "text",
    -          text: "2",
    -          styles: {
    -            italic: true,
    -            strike: true,
    -          },
    -        },
    -      ],
    -      children: [
    -        {
    -          type: "paragraph",
    -          props: {
    -            backgroundColor: "red",
    -          },
    -          content: "Paragraph",
    -          children: [],
    -        },
    -        {
    -          type: "bulletListItem",
    -        },
    -      ],
    -    };
    -    const firstNodeConversion = blockToNode(
    -      block,
    -      tt.schema
    -    );
    -
    -    expect(firstNodeConversion).toMatchSnapshot();
    -  });
    -
    -  it("Convert complex node to block", async () => {
    -    const node = tt.schema.nodes["blockContainer"].create(
    -      {
    -        id: UniqueID.options.generateID(),
    -        backgroundColor: "blue",
    -        textColor: "yellow",
    -      },
    -      [
    -        tt.schema.nodes["heading"].create(
    -          { textAlignment: "right", level: 2 },
    -          [
    -            tt.schema.text("Heading ", [
    -              tt.schema.mark("bold"),
    -              tt.schema.mark("underline"),
    -            ]),
    -            tt.schema.text("2", [
    -              tt.schema.mark("italic"),
    -              tt.schema.mark("strike"),
    -            ]),
    -          ]
    -        ),
    -        tt.schema.nodes["blockGroup"].create({}, [
    -          tt.schema.nodes["blockContainer"].create(
    -            { id: UniqueID.options.generateID(), backgroundColor: "red" },
    -            [
    -              tt.schema.nodes["paragraph"].create(
    -                {},
    -                tt.schema.text("Paragraph")
    -              ),
    -            ]
    -          ),
    -          tt.schema.nodes["blockContainer"].create(
    -            { id: UniqueID.options.generateID() },
    -            [tt.schema.nodes["bulletListItem"].create()]
    -          ),
    -        ]),
    -      ]
    -    );
    -    const firstBlockConversion = nodeToBlock(node, defaultBlockSchema);
    -
    -    expect(firstBlockConversion).toMatchSnapshot();
    -
    -    const firstNodeConversion = blockToNode(
    -      firstBlockConversion,
    -      tt.schema
    -    );
    -
    -    expect(firstNodeConversion).toStrictEqual(node);
    -  });
    -});
    -
    -describe("links", () => {
    -  it("Convert a block with link", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "link",
    -          href: "https://www.website.com",
    -          content: "Website",
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert link block with marks", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "link",
    -          href: "https://www.website.com",
    -          content: [
    -            {
    -              type: "text",
    -              text: "Web",
    -              styles: {
    -                bold: true,
    -              },
    -            },
    -            {
    -              type: "text",
    -              text: "site",
    -              styles: {},
    -            },
    -          ],
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    // expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert two adjacent links in a block", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "link",
    -          href: "https://www.website.com",
    -          content: "Website",
    -        },
    -        {
    -          type: "link",
    -          href: "https://www.website2.com",
    -          content: "Website2",
    -        },
    -      ],
    -    };
    -
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
     
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -});
    -
    -describe("hard breaks", () => {
    -  it("Convert a block with a hard break", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "Text1\nText2",
    -          styles: {},
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with multiple hard breaks", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "Text1\nText2\nText3",
    -          styles: {},
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with a hard break at the start", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "\nText1",
    -          styles: {},
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with a hard break at the end", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "Text1\n",
    -          styles: {},
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with only a hard break", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "\n",
    -          styles: {},
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with a hard break and different styles", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "Text1\n",
    -          styles: {},
    -        },
    -        {
    -          type: "text",
    -          text: "Text2",
    -          styles: { bold: true },
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with a hard break in a link", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "link",
    -          href: "https://www.website.com",
    -          content: "Link1\nLink1",
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with a hard break between links", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "link",
    -          href: "https://www.website.com",
    -          content: "Link1\n",
    -        },
    -        {
    -          type: "link",
    -          href: "https://www.website2.com",
    -          content: "Link2",
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    +import { BlockNoteEditor } from "../../BlockNoteEditor";
    +import { PartialBlock } from "../../extensions/Blocks/api/blocks/types";
    +import { customInlineContentTestCases } from "../testCases/cases/customInlineContent";
    +import { customStylesTestCases } from "../testCases/cases/customStyles";
    +import { defaultSchemaTestCases } from "../testCases/cases/defaultSchema";
    +import { blockToNode, nodeToBlock } from "./nodeConversions";
    +import { addIdsToBlock, partialBlockToBlockForTesting } from "./testUtil";
    +
    +function validateConversion(
    +  block: PartialBlock,
    +  editor: BlockNoteEditor
    +) {
    +  addIdsToBlock(block);
    +  const node = blockToNode(
    +    block,
    +    editor._tiptapEditor.schema,
    +    editor.styleSchema
    +  );
    +
    +  expect(node).toMatchSnapshot();
    +
    +  const outputBlock = nodeToBlock(
    +    node,
    +    editor.blockSchema,
    +    editor.inlineContentSchema,
    +    editor.styleSchema
    +  );
    +
    +  const fullOriginalBlock = partialBlockToBlockForTesting(
    +    editor.blockSchema,
    +    block
    +  );
    +
    +  expect(outputBlock).toStrictEqual(fullOriginalBlock);
    +}
    +
    +const testCases = [
    +  defaultSchemaTestCases,
    +  customStylesTestCases,
    +  customInlineContentTestCases,
    +];
    +
    +describe("Test BlockNote-Prosemirror conversion", () => {
    +  for (const testCase of testCases) {
    +    describe("Case: " + testCase.name, () => {
    +      let editor: BlockNoteEditor;
    +
    +      beforeEach(() => {
    +        editor = testCase.createEditor();
    +      });
    +
    +      afterEach(() => {
    +        editor._tiptapEditor.destroy();
    +        editor = undefined as any;
    +
    +        delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
    +      });
    +
    +      for (const document of testCase.documents) {
    +        // eslint-disable-next-line no-loop-func
    +        it("Convert " + document.name + " to/from prosemirror", () => {
    +          // NOTE: only converts first block
    +          validateConversion(document.blocks[0], editor);
    +        });
    +      }
    +    });
    +  }
     });
    diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts
    index 2974824ebd..391fbe1e5d 100644
    --- a/packages/core/src/api/nodeConversions/nodeConversions.ts
    +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts
    @@ -2,47 +2,53 @@ import { Mark } from "@tiptap/pm/model";
     import { Node, Schema } from "prosemirror-model";
     import {
       Block,
    -  BlockConfig,
       BlockSchema,
    -  BlockSpec,
       PartialBlock,
       PartialTableContent,
       TableContent,
    -} from "../../extensions/Blocks/api/blockTypes";
    +} from "../../extensions/Blocks/api/blocks/types";
     import {
    -  ColorStyle,
    +  CustomInlineContentConfig,
    +  CustomInlineContentFromConfig,
       InlineContent,
    +  InlineContentFromConfig,
    +  InlineContentSchema,
    +  PartialCustomInlineContentFromConfig,
       PartialInlineContent,
       PartialLink,
       StyledText,
    -  Styles,
    -  ToggledStyle,
    -} from "../../extensions/Blocks/api/inlineContentTypes";
    +  isLinkInlineContent,
    +  isPartialLinkInlineContent,
    +  isStyledTextInlineContent,
    +} from "../../extensions/Blocks/api/inlineContent/types";
    +import { StyleSchema, Styles } from "../../extensions/Blocks/api/styles/types";
     import { getBlockInfo } from "../../extensions/Blocks/helpers/getBlockInfoFromPos";
     import UniqueID from "../../extensions/UniqueID/UniqueID";
     import { UnreachableCaseError } from "../../shared/utils";
     
    -const toggleStyles = new Set([
    -  "bold",
    -  "italic",
    -  "underline",
    -  "strike",
    -  "code",
    -]);
    -const colorStyles = new Set(["textColor", "backgroundColor"]);
    -
     /**
      * Convert a StyledText inline element to a
      * prosemirror text node with the appropriate marks
      */
    -function styledTextToNodes(styledText: StyledText, schema: Schema): Node[] {
    +function styledTextToNodes(
    +  styledText: StyledText,
    +  schema: Schema,
    +  styleSchema: T
    +): Node[] {
       const marks: Mark[] = [];
     
       for (const [style, value] of Object.entries(styledText.styles)) {
    -    if (toggleStyles.has(style as ToggledStyle)) {
    +    const config = styleSchema[style];
    +    if (!config) {
    +      throw new Error(`style ${style} not found in styleSchema`);
    +    }
    +
    +    if (config.propSchema === "boolean") {
           marks.push(schema.mark(style));
    -    } else if (colorStyles.has(style as ColorStyle)) {
    -      marks.push(schema.mark(style, { color: value }));
    +    } else if (config.propSchema === "string") {
    +      marks.push(schema.mark(style, { stringValue: value }));
    +    } else {
    +      throw new UnreachableCaseError(config.propSchema);
         }
       }
     
    @@ -68,42 +74,53 @@ function styledTextToNodes(styledText: StyledText, schema: Schema): Node[] {
      * Converts a Link inline content element to
      * prosemirror text nodes with the appropriate marks
      */
    -function linkToNodes(link: PartialLink, schema: Schema): Node[] {
    +function linkToNodes(
    +  link: PartialLink,
    +  schema: Schema,
    +  styleSchema: StyleSchema
    +): Node[] {
       const linkMark = schema.marks.link.create({
         href: link.href,
       });
     
    -  return styledTextArrayToNodes(link.content, schema).map((node) => {
    -    if (node.type.name === "text") {
    -      return node.mark([...node.marks, linkMark]);
    -    }
    +  return styledTextArrayToNodes(link.content, schema, styleSchema).map(
    +    (node) => {
    +      if (node.type.name === "text") {
    +        return node.mark([...node.marks, linkMark]);
    +      }
     
    -    if (node.type.name === "hardBreak") {
    -      return node;
    +      if (node.type.name === "hardBreak") {
    +        return node;
    +      }
    +      throw new Error("unexpected node type");
         }
    -    throw new Error("unexpected node type");
    -  });
    +  );
     }
     
     /**
      * Converts an array of StyledText inline content elements to
      * prosemirror text nodes with the appropriate marks
      */
    -function styledTextArrayToNodes(
    -  content: string | StyledText[],
    -  schema: Schema
    +function styledTextArrayToNodes(
    +  content: string | StyledText[],
    +  schema: Schema,
    +  styleSchema: S
     ): Node[] {
       const nodes: Node[] = [];
     
       if (typeof content === "string") {
         nodes.push(
    -      ...styledTextToNodes({ type: "text", text: content, styles: {} }, schema)
    +      ...styledTextToNodes(
    +        { type: "text", text: content, styles: {} },
    +        schema,
    +        styleSchema
    +      )
         );
         return nodes;
       }
     
       for (const styledText of content) {
    -    nodes.push(...styledTextToNodes(styledText, schema));
    +    nodes.push(...styledTextToNodes(styledText, schema, styleSchema));
       }
       return nodes;
     }
    @@ -111,19 +128,27 @@ function styledTextArrayToNodes(
     /**
      * converts an array of inline content elements to prosemirror nodes
      */
    -export function inlineContentToNodes(
    -  blockContent: PartialInlineContent[],
    -  schema: Schema
    +export function inlineContentToNodes<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  blockContent: PartialInlineContent,
    +  schema: Schema,
    +  styleSchema: S
     ): Node[] {
       const nodes: Node[] = [];
     
       for (const content of blockContent) {
    -    if (content.type === "link") {
    -      nodes.push(...linkToNodes(content, schema));
    -    } else if (content.type === "text") {
    -      nodes.push(...styledTextArrayToNodes([content], schema));
    +    if (typeof content === "string") {
    +      nodes.push(...styledTextArrayToNodes(content, schema, styleSchema));
    +    } else if (isPartialLinkInlineContent(content)) {
    +      nodes.push(...linkToNodes(content, schema, styleSchema));
    +    } else if (isStyledTextInlineContent(content)) {
    +      nodes.push(...styledTextArrayToNodes([content], schema, styleSchema));
         } else {
    -      throw new UnreachableCaseError(content);
    +      nodes.push(
    +        blockOrInlineContentToContentNode(content, schema, styleSchema)
    +      );
         }
       }
       return nodes;
    @@ -132,9 +157,13 @@ export function inlineContentToNodes(
     /**
      * converts an array of inline content elements to prosemirror nodes
      */
    -export function tableContentToNodes(
    -  tableContent: PartialTableContent,
    -  schema: Schema
    +export function tableContentToNodes<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  tableContent: PartialTableContent,
    +  schema: Schema,
    +  styleSchema: StyleSchema
     ): Node[] {
       const rowNodes: Node[] = [];
     
    @@ -147,7 +176,7 @@ export function tableContentToNodes(
           } else if (typeof cell === "string") {
             pNode = schema.nodes["tableParagraph"].create({}, schema.text(cell));
           } else {
    -        const textNodes = inlineContentToNodes(cell, schema);
    +        const textNodes = inlineContentToNodes(cell, schema, styleSchema);
             pNode = schema.nodes["tableParagraph"].create({}, textNodes);
           }
     
    @@ -160,26 +189,24 @@ export function tableContentToNodes(
       return rowNodes;
     }
     
    -/**
    - * Converts a BlockNote block to a TipTap node.
    - */
    -export function blockToNode(
    -  block: PartialBlock,
    -  schema: Schema
    +function blockOrInlineContentToContentNode(
    +  block:
    +    | PartialBlock
    +    | PartialCustomInlineContentFromConfig,
    +  schema: Schema,
    +  styleSchema: StyleSchema
     ) {
    -  let id = block.id;
    -
    -  if (id === undefined) {
    -    id = UniqueID.options.generateID();
    -  }
    -
    +  let contentNode: Node;
       let type = block.type;
     
    +  // TODO: needed? came from previous code
       if (type === undefined) {
         type = "paragraph";
       }
     
    -  let contentNode: Node;
    +  if (!schema.nodes[type]) {
    +    throw new Error(`node type ${type} not found in schema`);
    +  }
     
       if (!block.content) {
         contentNode = schema.nodes[type].create(block.props);
    @@ -189,20 +216,41 @@ export function blockToNode(
           schema.text(block.content)
         );
       } else if (Array.isArray(block.content)) {
    -    const nodes = inlineContentToNodes(block.content, schema);
    +    const nodes = inlineContentToNodes(block.content, schema, styleSchema);
         contentNode = schema.nodes[type].create(block.props, nodes);
       } else if (block.content.type === "tableContent") {
    -    const nodes = tableContentToNodes(block.content, schema);
    +    const nodes = tableContentToNodes(block.content, schema, styleSchema);
         contentNode = schema.nodes[type].create(block.props, nodes);
       } else {
         throw new UnreachableCaseError(block.content.type);
       }
    +  return contentNode;
    +}
    +/**
    + * Converts a BlockNote block to a TipTap node.
    + */
    +export function blockToNode(
    +  block: PartialBlock,
    +  schema: Schema,
    +  styleSchema: StyleSchema
    +) {
    +  let id = block.id;
    +
    +  if (id === undefined) {
    +    id = UniqueID.options.generateID();
    +  }
    +
    +  const contentNode = blockOrInlineContentToContentNode(
    +    block,
    +    schema,
    +    styleSchema
    +  );
     
       const children: Node[] = [];
     
       if (block.children) {
         for (const child of block.children) {
    -      children.push(blockToNode(child, schema));
    +      children.push(blockToNode(child, schema, styleSchema));
         }
       }
     
    @@ -220,19 +268,28 @@ export function blockToNode(
     /**
      * Converts an internal (prosemirror) table node contentto a BlockNote Tablecontent
      */
    -function contentNodeToTableContent(contentNode: Node) {
    -  const ret: TableContent = {
    +function contentNodeToTableContent<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
    +  const ret: TableContent = {
         type: "tableContent",
         rows: [],
       };
     
       contentNode.content.forEach((rowNode) => {
    -    const row: TableContent["rows"][0] = {
    +    const row: TableContent["rows"][0] = {
           cells: [],
         };
     
         rowNode.content.forEach((cellNode) => {
    -      row.cells.push(contentNodeToInlineContent(cellNode.firstChild!));
    +      row.cells.push(
    +        contentNodeToInlineContent(
    +          cellNode.firstChild!,
    +          inlineContentSchema,
    +          styleSchema
    +        )
    +      );
         });
     
         ret.rows.push(row);
    @@ -244,9 +301,12 @@ function contentNodeToTableContent(contentNode: Node) {
     /**
      * Converts an internal (prosemirror) content node to a BlockNote InlineContent array.
      */
    -function contentNodeToInlineContent(contentNode: Node) {
    -  const content: InlineContent[] = [];
    -  let currentContent: InlineContent | undefined = undefined;
    +export function contentNodeToInlineContent<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
    +  const content: InlineContent[] = [];
    +  let currentContent: InlineContent | undefined = undefined;
     
       // Most of the logic below is for handling links because in ProseMirror links are marks
       // while in BlockNote links are a type of inline content
    @@ -256,13 +316,15 @@ function contentNodeToInlineContent(contentNode: Node) {
         if (node.type.name === "hardBreak") {
           if (currentContent) {
             // Current content exists.
    -        if (currentContent.type === "text") {
    +        if (isStyledTextInlineContent(currentContent)) {
               // Current content is text.
               currentContent.text += "\n";
    -        } else if (currentContent.type === "link") {
    +        } else if (isLinkInlineContent(currentContent)) {
               // Current content is a link.
               currentContent.content[currentContent.content.length - 1].text +=
                 "\n";
    +        } else {
    +          throw new Error("unexpected");
             }
           } else {
             // Current content does not exist.
    @@ -276,18 +338,41 @@ function contentNodeToInlineContent(contentNode: Node) {
           return;
         }
     
    -    const styles: Styles = {};
    +    if (
    +      node.type.name !== "link" &&
    +      node.type.name !== "text" &&
    +      inlineContentSchema[node.type.name]
    +    ) {
    +      if (currentContent) {
    +        content.push(currentContent);
    +        currentContent = undefined;
    +      }
    +
    +      content.push(
    +        nodeToCustomInlineContent(node, inlineContentSchema, styleSchema)
    +      );
    +
    +      return;
    +    }
    +
    +    const styles: Styles = {};
         let linkMark: Mark | undefined;
     
         for (const mark of node.marks) {
           if (mark.type.name === "link") {
             linkMark = mark;
    -      } else if (toggleStyles.has(mark.type.name as ToggledStyle)) {
    -        styles[mark.type.name as ToggledStyle] = true;
    -      } else if (colorStyles.has(mark.type.name as ColorStyle)) {
    -        styles[mark.type.name as ColorStyle] = mark.attrs.color;
           } else {
    -        throw Error("Mark is of an unrecognized type: " + mark.type.name);
    +        const config = styleSchema[mark.type.name];
    +        if (!config) {
    +          throw new Error(`style ${mark.type.name} not found in styleSchema`);
    +        }
    +        if (config.propSchema === "boolean") {
    +          (styles as any)[config.type] = true;
    +        } else if (config.propSchema === "string") {
    +          (styles as any)[config.type] = mark.attrs.stringValue;
    +        } else {
    +          throw new UnreachableCaseError(config.propSchema);
    +        }
           }
         }
     
    @@ -295,7 +380,7 @@ function contentNodeToInlineContent(contentNode: Node) {
         // Current content exists.
         if (currentContent) {
           // Current content is text.
    -      if (currentContent.type === "text") {
    +      if (isStyledTextInlineContent(currentContent)) {
             if (!linkMark) {
               // Node is text (same type as current content).
               if (
    @@ -327,7 +412,7 @@ function contentNodeToInlineContent(contentNode: Node) {
                 ],
               };
             }
    -      } else if (currentContent.type === "link") {
    +      } else if (isLinkInlineContent(currentContent)) {
             // Current content is a link.
             if (linkMark) {
               // Node is a link (same type as current content).
    @@ -373,6 +458,8 @@ function contentNodeToInlineContent(contentNode: Node) {
                 styles,
               };
             }
    +      } else {
    +        // TODO
           }
         }
         // Current content does not exist.
    @@ -406,17 +493,66 @@ function contentNodeToInlineContent(contentNode: Node) {
         content.push(currentContent);
       }
     
    -  return content;
    +  return content as InlineContent[];
    +}
    +
    +export function nodeToCustomInlineContent<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(node: Node, inlineContentSchema: I, styleSchema: S): InlineContent {
    +  if (node.type.name === "text" || node.type.name === "link") {
    +    throw new Error("unexpected");
    +  }
    +  const props: any = {};
    +  const icConfig = inlineContentSchema[
    +    node.type.name
    +  ] as CustomInlineContentConfig;
    +  for (const [attr, value] of Object.entries(node.attrs)) {
    +    if (!icConfig) {
    +      throw Error("ic node is of an unrecognized type: " + node.type.name);
    +    }
    +
    +    const propSchema = icConfig.propSchema;
    +
    +    if (attr in propSchema) {
    +      props[attr] = value;
    +    }
    +  }
    +
    +  let content: CustomInlineContentFromConfig["content"];
    +
    +  if (icConfig.content === "styled") {
    +    content = contentNodeToInlineContent(
    +      node,
    +      inlineContentSchema,
    +      styleSchema
    +    ) as any; // TODO: is this safe? could we have Links here that are undesired?
    +  } else {
    +    content = undefined;
    +  }
    +
    +  const ic = {
    +    type: node.type.name,
    +    props,
    +    content,
    +  } as InlineContentFromConfig;
    +  return ic;
     }
     
     /**
      * Convert a TipTap node to a BlockNote block.
      */
    -export function nodeToBlock(
    +export function nodeToBlock<
    +  BSchema extends BlockSchema,
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
       node: Node,
       blockSchema: BSchema,
    -  blockCache?: WeakMap>
    -): Block {
    +  inlineContentSchema: I,
    +  styleSchema: S,
    +  blockCache?: WeakMap>
    +): Block {
       if (node.type.name !== "blockContainer") {
         throw Error(
           "Node must be of type blockContainer, but is of type" +
    @@ -445,9 +581,7 @@ export function nodeToBlock(
         ...node.attrs,
         ...blockInfo.contentNode.attrs,
       })) {
    -    const blockSpec = blockSchema[
    -      blockInfo.contentType.name
    -    ] as BlockSpec; // TODO: fix cast
    +    const blockSpec = blockSchema[blockInfo.contentType.name];
     
         if (!blockSpec) {
           throw Error(
    @@ -455,43 +589,55 @@ export function nodeToBlock(
           );
         }
     
    -    const propSchema = blockSpec.config.propSchema;
    +    const propSchema = blockSpec.propSchema;
     
         if (attr in propSchema) {
           props[attr] = value;
         }
       }
     
    -  const blockSpec = blockSchema[
    -    blockInfo.contentType.name
    -  ] as BlockSpec; // TODO: fix cast
    +  const blockConfig = blockSchema[blockInfo.contentType.name];
     
    -  const children: Block[] = [];
    +  const children: Block[] = [];
       for (let i = 0; i < blockInfo.numChildBlocks; i++) {
         children.push(
    -      nodeToBlock(node.lastChild!.child(i), blockSchema, blockCache)
    +      nodeToBlock(
    +        node.lastChild!.child(i),
    +        blockSchema,
    +        inlineContentSchema,
    +        styleSchema,
    +        blockCache
    +      )
         );
       }
     
    -  let content: Block["content"];
    +  let content: Block["content"];
     
    -  if (blockSpec.config.content === "inline") {
    -    content = contentNodeToInlineContent(blockInfo.contentNode);
    -  } else if (blockSpec.config.content === "table") {
    -    content = contentNodeToTableContent(blockInfo.contentNode);
    -  } else if (blockSpec.config.content === "none") {
    +  if (blockConfig.content === "inline") {
    +    content = contentNodeToInlineContent(
    +      blockInfo.contentNode,
    +      inlineContentSchema,
    +      styleSchema
    +    );
    +  } else if (blockConfig.content === "table") {
    +    content = contentNodeToTableContent(
    +      blockInfo.contentNode,
    +      inlineContentSchema,
    +      styleSchema
    +    );
    +  } else if (blockConfig.content === "none") {
         content = undefined;
       } else {
    -    throw new UnreachableCaseError(blockSpec.config.content);
    +    throw new UnreachableCaseError(blockConfig.content);
       }
     
       const block = {
         id,
    -    type: blockSpec.config.type,
    +    type: blockConfig.type,
         props,
         content,
         children,
    -  } as Block;
    +  } as Block;
     
       blockCache?.set(node, block);
     
    diff --git a/packages/core/src/api/nodeConversions/testUtil.ts b/packages/core/src/api/nodeConversions/testUtil.ts
    index 490f989958..3398e19d2d 100644
    --- a/packages/core/src/api/nodeConversions/testUtil.ts
    +++ b/packages/core/src/api/nodeConversions/testUtil.ts
    @@ -3,16 +3,21 @@ import {
       BlockSchema,
       PartialBlock,
       TableContent,
    -} from "../../extensions/Blocks/api/blockTypes";
    +} from "../../extensions/Blocks/api/blocks/types";
     import {
       InlineContent,
    +  InlineContentSchema,
       PartialInlineContent,
       StyledText,
    -} from "../../extensions/Blocks/api/inlineContentTypes";
    +  isPartialLinkInlineContent,
    +  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[] = ""
    -): StyledText[] {
    +  content: string | StyledText[] = ""
    +): StyledText[] {
       if (typeof content === "string") {
         return [
           {
    @@ -26,21 +31,31 @@ function textShorthandToStyledText(
     }
     
     function partialContentToInlineContent(
    -  content: string | PartialInlineContent[] | TableContent = ""
    -): InlineContent[] | TableContent {
    +  content: PartialInlineContent | TableContent | undefined
    +): InlineContent[] | TableContent | undefined {
       if (typeof content === "string") {
         return textShorthandToStyledText(content);
       }
     
       if (Array.isArray(content)) {
    -    return content.map((partialContent) => {
    -      if (partialContent.type === "link") {
    +    return content.flatMap((partialContent) => {
    +      if (typeof partialContent === "string") {
    +        return textShorthandToStyledText(partialContent);
    +      } else if (isPartialLinkInlineContent(partialContent)) {
             return {
               ...partialContent,
               content: textShorthandToStyledText(partialContent.content),
             };
    -      } else {
    +      } else if (isStyledTextInlineContent(partialContent)) {
             return partialContent;
    +      } else {
    +        // custom inline content
    +
    +        return {
    +          props: {},
    +          ...partialContent,
    +          content: partialContentToInlineContent(partialContent.content),
    +        } as any;
           }
         });
       }
    @@ -48,23 +63,65 @@ function partialContentToInlineContent(
       return content;
     }
     
    -export function partialBlockToBlockForTesting(
    -  partialBlock: PartialBlock
    -): Block {
    -  const withDefaults: Block = {
    +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,
    +  S extends StyleSchema
    +>(
    +  schema: BSchema,
    +  partialBlock: PartialBlock
    +): Block {
    +  const withDefaults: Block = {
         id: "",
    -    type: "paragraph",
    -    // because at this point we don't have an easy way to access default props at runtime,
    -    // partialBlockToBlockForTesting will not set them.
    +    type: partialBlock.type!,
         props: {} as any,
    -    content: [] as any,
    +    content:
    +      schema[partialBlock.type!].content === "inline" ? [] : (undefined as any),
         children: [] as any,
         ...partialBlock,
       };
     
    +  Object.entries(schema[partialBlock.type!].propSchema).forEach(
    +    ([propKey, propValue]) => {
    +      if (withDefaults.props[propKey] === undefined) {
    +        (withDefaults.props as any)[propKey] = propValue.default;
    +      }
    +    }
    +  );
    +
       return {
         ...withDefaults,
         content: partialContentToInlineContent(withDefaults.content),
    -    children: withDefaults.children.map(partialBlockToBlockForTesting),
    +    children: withDefaults.children.map((c) => {
    +      return partialBlockToBlockForTesting(schema, c);
    +    }),
       } 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/htmlConversion.test.ts b/packages/core/src/api/serialization/html/htmlConversion.test.ts
      deleted file mode 100644
      index d5cf5f4882..0000000000
      --- a/packages/core/src/api/serialization/html/htmlConversion.test.ts
      +++ /dev/null
      @@ -1,467 +0,0 @@
      -import { Editor } from "@tiptap/core";
      -import { afterEach, beforeEach, describe, expect, it } from "vitest";
      -import { BlockNoteEditor } from "../../../BlockNoteEditor";
      -
      -import {
      -  BlockSchema,
      -  PartialBlock,
      -} from "../../../extensions/Blocks/api/blockTypes";
      -import { createBlockSpec } from "../../../extensions/Blocks/api/customBlocks";
      -import { defaultBlockSchema } from "../../../extensions/Blocks/api/defaultBlocks";
      -import { defaultProps } from "../../../extensions/Blocks/api/defaultProps";
      -import {
      -  imagePropSchema,
      -  renderImage,
      -} from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent";
      -import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      -import { createExternalHTMLExporter } from "./externalHTMLExporter";
      -import { createInternalHTMLSerializer } from "./internalHTMLSerializer";
      -
      -// This is a modified version of the default image block that does not implement
      -// a `serialize` function. It's used to test if the custom serializer by default
      -// serializes custom blocks using their `render` function.
      -const SimpleImage = createBlockSpec(
      -  {
      -    type: "simpleImage" as const,
      -    propSchema: imagePropSchema,
      -    content: "none",
      -  },
      -  { render: renderImage as any }
      -);
      -
      -const CustomParagraph = createBlockSpec(
      -  {
      -    type: "customParagraph" as const,
      -    propSchema: defaultProps,
      -    content: "inline",
      -  },
      -  {
      -    render: () => {
      -      const paragraph = document.createElement("p");
      -      paragraph.className = "custom-paragraph";
      -
      -      return {
      -        dom: paragraph,
      -        contentDOM: paragraph,
      -      };
      -    },
      -    toExternalHTML: () => {
      -      const paragraph = document.createElement("p");
      -      paragraph.className = "custom-paragraph";
      -      paragraph.innerHTML = "Hello World";
      -
      -      return {
      -        dom: paragraph,
      -      };
      -    },
      -  }
      -);
      -
      -const SimpleCustomParagraph = createBlockSpec(
      -  {
      -    type: "simpleCustomParagraph" as const,
      -    propSchema: defaultProps,
      -    content: "inline",
      -  },
      -  {
      -    render: () => {
      -      const paragraph = document.createElement("p");
      -      paragraph.className = "simple-custom-paragraph";
      -
      -      return {
      -        dom: paragraph,
      -        contentDOM: paragraph,
      -      };
      -    },
      -  }
      -);
      -
      -const customSchema = {
      -  ...defaultBlockSchema,
      -  simpleImage: SimpleImage,
      -  customParagraph: CustomParagraph,
      -  simpleCustomParagraph: SimpleCustomParagraph,
      -} satisfies BlockSchema;
      -
      -let editor: BlockNoteEditor;
      -let tt: Editor;
      -
      -beforeEach(() => {
      -  editor = new BlockNoteEditor({
      -    blockSchema: customSchema,
      -    uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      -  });
      -  tt = editor._tiptapEditor;
      -});
      -
      -afterEach(() => {
      -  tt.destroy();
      -  editor = undefined as any;
      -  tt = undefined as any;
      -
      -  delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
      -});
      -
      -function convertToHTMLAndCompareSnapshots(
      -  blocks: PartialBlock[],
      -  snapshotDirectory: string,
      -  snapshotName: string
      -) {
      -  const serializer = createInternalHTMLSerializer(tt.schema, editor);
      -  const internalHTML = serializer.serializeBlocks(blocks);
      -  const internalHTMLSnapshotPath =
      -    "./__snapshots__/" +
      -    snapshotDirectory +
      -    "/" +
      -    snapshotName +
      -    "/internal.html";
      -  expect(internalHTML).toMatchFileSnapshot(internalHTMLSnapshotPath);
      -
      -  const exporter = createExternalHTMLExporter(tt.schema, editor);
      -  const externalHTML = exporter.exportBlocks(blocks);
      -  const externalHTMLSnapshotPath =
      -    "./__snapshots__/" +
      -    snapshotDirectory +
      -    "/" +
      -    snapshotName +
      -    "/external.html";
      -  expect(externalHTML).toMatchFileSnapshot(externalHTMLSnapshotPath);
      -}
      -
      -describe("Convert paragraphs to HTML", () => {
      -  it("Convert paragraph to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "paragraph",
      -        content: "Paragraph",
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "paragraph", "basic");
      -  });
      -
      -  it("Convert styled paragraph to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "paragraph",
      -        props: {
      -          textAlignment: "center",
      -          textColor: "orange",
      -          backgroundColor: "pink",
      -        },
      -        content: [
      -          {
      -            type: "text",
      -            styles: {},
      -            text: "Plain ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -            },
      -            text: "Red Text ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              backgroundColor: "blue",
      -            },
      -            text: "Blue Background ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -              backgroundColor: "blue",
      -            },
      -            text: "Mixed Colors",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "paragraph", "styled");
      -  });
      -
      -  it("Convert nested paragraph to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "paragraph",
      -        content: "Paragraph",
      -        children: [
      -          {
      -            type: "paragraph",
      -            content: "Nested Paragraph 1",
      -          },
      -          {
      -            type: "paragraph",
      -            content: "Nested Paragraph 2",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "paragraph", "nested");
      -  });
      -});
      -
      -describe("Convert images to HTML", () => {
      -  it("Convert add image button to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "image",
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "image", "button");
      -  });
      -
      -  it("Convert image to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "image",
      -        props: {
      -          url: "exampleURL",
      -          caption: "Caption",
      -          width: 256,
      -        },
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "image", "basic");
      -  });
      -
      -  it("Convert nested image to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "image",
      -        props: {
      -          url: "exampleURL",
      -          caption: "Caption",
      -          width: 256,
      -        },
      -        children: [
      -          {
      -            type: "image",
      -            props: {
      -              url: "exampleURL",
      -              caption: "Caption",
      -              width: 256,
      -            },
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "image", "nested");
      -  });
      -});
      -
      -describe("Convert simple images to HTML", () => {
      -  it("Convert simple add image button to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleImage",
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleImage", "button");
      -  });
      -
      -  it("Convert simple image to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleImage",
      -        props: {
      -          url: "exampleURL",
      -          caption: "Caption",
      -          width: 256,
      -        },
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleImage", "basic");
      -  });
      -
      -  it("Convert nested image to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleImage",
      -        props: {
      -          url: "exampleURL",
      -          caption: "Caption",
      -          width: 256,
      -        },
      -        children: [
      -          {
      -            type: "simpleImage",
      -            props: {
      -              url: "exampleURL",
      -              caption: "Caption",
      -              width: 256,
      -            },
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleImage", "nested");
      -  });
      -});
      -
      -describe("Convert custom blocks with inline content to HTML", () => {
      -  it("Convert custom block with inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "customParagraph",
      -        content: "Custom Paragraph",
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "customParagraph", "basic");
      -  });
      -
      -  it("Convert styled custom block with inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "customParagraph",
      -        props: {
      -          textAlignment: "center",
      -          textColor: "orange",
      -          backgroundColor: "pink",
      -        },
      -        content: [
      -          {
      -            type: "text",
      -            styles: {},
      -            text: "Plain ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -            },
      -            text: "Red Text ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              backgroundColor: "blue",
      -            },
      -            text: "Blue Background ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -              backgroundColor: "blue",
      -            },
      -            text: "Mixed Colors",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "customParagraph", "styled");
      -  });
      -
      -  it("Convert nested block with inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "customParagraph",
      -        content: "Custom Paragraph",
      -        children: [
      -          {
      -            type: "customParagraph",
      -            content: "Nested Custom Paragraph 1",
      -          },
      -          {
      -            type: "customParagraph",
      -            content: "Nested Custom Paragraph 2",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "customParagraph", "nested");
      -  });
      -});
      -
      -describe("Convert custom blocks with non-exported inline content to HTML", () => {
      -  it("Convert custom block with non-exported inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleCustomParagraph",
      -        content: "Custom Paragraph",
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleCustomParagraph", "basic");
      -  });
      -
      -  it("Convert styled custom block with non-exported inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleCustomParagraph",
      -        props: {
      -          textAlignment: "center",
      -          textColor: "orange",
      -          backgroundColor: "pink",
      -        },
      -        content: [
      -          {
      -            type: "text",
      -            styles: {},
      -            text: "Plain ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -            },
      -            text: "Red Text ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              backgroundColor: "blue",
      -            },
      -            text: "Blue Background ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -              backgroundColor: "blue",
      -            },
      -            text: "Mixed Colors",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleCustomParagraph", "styled");
      -  });
      -
      -  it("Convert nested block with non-exported inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleCustomParagraph",
      -        content: "Custom Paragraph",
      -        children: [
      -          {
      -            type: "simpleCustomParagraph",
      -            content: "Nested Custom Paragraph 1",
      -          },
      -          {
      -            type: "simpleCustomParagraph",
      -            content: "Nested Custom Paragraph 2",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleCustomParagraph", "nested");
      -  });
      -});
      diff --git a/packages/core/src/api/testCases/cases/customInlineContent.ts b/packages/core/src/api/testCases/cases/customInlineContent.ts
      new file mode 100644
      index 0000000000..a1603f4a87
      --- /dev/null
      +++ b/packages/core/src/api/testCases/cases/customInlineContent.ts
      @@ -0,0 +1,114 @@
      +import { EditorTestCases } from "..";
      +
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultStyleSchema,
      +  defaultInlineContentSpecs,
      +} from "../../../extensions/Blocks/api/defaultBlocks";
      +import { createInlineContentSpec } from "../../../extensions/Blocks/api/inlineContent/createSpec";
      +import {
      +  InlineContentSchemaFromSpecs,
      +  InlineContentSpecs,
      +} from "../../../extensions/Blocks/api/inlineContent/types";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +
      +const mention = createInlineContentSpec(
      +  {
      +    type: "mention" as const,
      +    propSchema: {
      +      user: {
      +        default: "",
      +      },
      +    },
      +    content: "none",
      +  },
      +  {
      +    render: (ic) => {
      +      const dom = document.createElement("span");
      +      dom.appendChild(document.createTextNode("@" + ic.props.user));
      +
      +      return {
      +        dom,
      +      };
      +    },
      +  }
      +);
      +
      +const tag = createInlineContentSpec(
      +  {
      +    type: "tag" as const,
      +    propSchema: {},
      +    content: "styled",
      +  },
      +  {
      +    render: () => {
      +      const dom = document.createElement("span");
      +      dom.textContent = "#";
      +
      +      const contentDOM = document.createElement("span");
      +      dom.appendChild(contentDOM);
      +
      +      return {
      +        dom,
      +        contentDOM,
      +      };
      +    },
      +  }
      +);
      +
      +const customInlineContent = {
      +  ...defaultInlineContentSpecs,
      +  mention,
      +  tag,
      +} satisfies InlineContentSpecs;
      +
      +export const customInlineContentTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  InlineContentSchemaFromSpecs,
      +  DefaultStyleSchema
      +> = {
      +  name: "custom inline content schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +      inlineContentSpecs: customInlineContent,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "mention/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            "I enjoy working with",
      +            {
      +              type: "mention",
      +              props: {
      +                user: "Matthew",
      +              },
      +              content: undefined,
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "tag/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            "I love ",
      +            {
      +              type: "tag",
      +              // props: {},
      +              content: "BlockNote",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testCases/cases/customStyles.ts b/packages/core/src/api/testCases/cases/customStyles.ts
      new file mode 100644
      index 0000000000..e7a4390e63
      --- /dev/null
      +++ b/packages/core/src/api/testCases/cases/customStyles.ts
      @@ -0,0 +1,103 @@
      +import { EditorTestCases } from "..";
      +
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  defaultStyleSpecs,
      +} from "../../../extensions/Blocks/api/defaultBlocks";
      +import { createStyleSpec } from "../../../extensions/Blocks/api/styles/createSpec";
      +import {
      +  StyleSchemaFromSpecs,
      +  StyleSpecs,
      +} from "../../../extensions/Blocks/api/styles/types";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +
      +const small = createStyleSpec(
      +  {
      +    type: "small",
      +    propSchema: "boolean",
      +  },
      +  {
      +    render: () => {
      +      const dom = document.createElement("small");
      +      return {
      +        dom,
      +        contentDOM: dom,
      +      };
      +    },
      +  }
      +);
      +
      +const fontSize = createStyleSpec(
      +  {
      +    type: "fontSize",
      +    propSchema: "string",
      +  },
      +  {
      +    render: (value) => {
      +      const dom = document.createElement("span");
      +      dom.setAttribute("style", "font-size: " + value);
      +      return {
      +        dom,
      +        contentDOM: dom,
      +      };
      +    },
      +  }
      +);
      +
      +const customStyles = {
      +  ...defaultStyleSpecs,
      +  small,
      +  fontSize,
      +} satisfies StyleSpecs;
      +
      +export const customStylesTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  StyleSchemaFromSpecs
      +> = {
      +  name: "custom style schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +      styleSpecs: customStyles,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "small/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "This is a small text",
      +              styles: {
      +                small: true,
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "fontSize/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "This is text with a custom fontSize",
      +              styles: {
      +                fontSize: "18px",
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testCases/cases/defaultSchema.ts b/packages/core/src/api/testCases/cases/defaultSchema.ts
      new file mode 100644
      index 0000000000..87aa6b01b1
      --- /dev/null
      +++ b/packages/core/src/api/testCases/cases/defaultSchema.ts
      @@ -0,0 +1,399 @@
      +import { EditorTestCases } from "..";
      +
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +} from "../../../extensions/Blocks/api/defaultBlocks";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +
      +export const defaultSchemaTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema
      +> = {
      +  name: "default schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "paragraph/empty",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: "Paragraph",
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/styled",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          props: {
      +            textAlignment: "center",
      +            textColor: "orange",
      +            backgroundColor: "pink",
      +          },
      +          content: [
      +            {
      +              type: "text",
      +              styles: {},
      +              text: "Plain ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +              },
      +              text: "Red Text ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                backgroundColor: "blue",
      +              },
      +              text: "Blue Background ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +                backgroundColor: "blue",
      +              },
      +              text: "Mixed Colors",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/nested",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: "Paragraph",
      +          children: [
      +            {
      +              type: "paragraph",
      +              content: "Nested Paragraph 1",
      +            },
      +            {
      +              type: "paragraph",
      +              content: "Nested Paragraph 2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/button",
      +      blocks: [
      +        {
      +          type: "image",
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/basic",
      +      blocks: [
      +        {
      +          type: "image",
      +          props: {
      +            url: "exampleURL",
      +            caption: "Caption",
      +            width: 256,
      +          },
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/nested",
      +      blocks: [
      +        {
      +          type: "image",
      +          props: {
      +            url: "exampleURL",
      +            caption: "Caption",
      +            width: 256,
      +          },
      +          children: [
      +            {
      +              type: "image",
      +              props: {
      +                url: "exampleURL",
      +                caption: "Caption",
      +                width: 256,
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/basic",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Website",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/styled",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: [
      +                {
      +                  type: "text",
      +                  text: "Web",
      +                  styles: {
      +                    bold: true,
      +                  },
      +                },
      +                {
      +                  type: "text",
      +                  text: "site",
      +                  styles: {},
      +                },
      +              ],
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/adjacent",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Website",
      +            },
      +            {
      +              type: "link",
      +              href: "https://www.website2.com",
      +              content: "Website2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/basic",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\nText2",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/multiple",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\nText2\nText3",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/start",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "\nText1",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/end",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\n",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/only",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "\n",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/styles",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\n",
      +              styles: {},
      +            },
      +            {
      +              type: "text",
      +              text: "Text2",
      +              styles: { bold: true },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/link",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Link1\nLink1",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/between-links",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Link1\n",
      +            },
      +            {
      +              type: "link",
      +              href: "https://www.website2.com",
      +              content: "Link2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "complex/misc",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "heading",
      +          props: {
      +            backgroundColor: "blue",
      +            textColor: "yellow",
      +            textAlignment: "right",
      +            level: 2,
      +          },
      +          content: [
      +            {
      +              type: "text",
      +              text: "Heading ",
      +              styles: {
      +                bold: true,
      +                underline: true,
      +              },
      +            },
      +            {
      +              type: "text",
      +              text: "2",
      +              styles: {
      +                italic: true,
      +                strike: true,
      +              },
      +            },
      +          ],
      +          children: [
      +            {
      +              // id: UniqueID.options.generateID(),
      +              type: "paragraph",
      +              props: {
      +                backgroundColor: "red",
      +              },
      +              content: "Paragraph",
      +              children: [],
      +            },
      +            {
      +              // id: UniqueID.options.generateID(),
      +              type: "bulletListItem",
      +              props: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testCases/index.ts b/packages/core/src/api/testCases/index.ts
      new file mode 100644
      index 0000000000..90e1f06005
      --- /dev/null
      +++ b/packages/core/src/api/testCases/index.ts
      @@ -0,0 +1,20 @@
      +import { BlockNoteEditor } from "../../BlockNoteEditor";
      +import {
      +  BlockSchema,
      +  PartialBlock,
      +} from "../../extensions/Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../../extensions/Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../../extensions/Blocks/api/styles/types";
      +
      +export type EditorTestCases<
      +  B extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  name: string;
      +  createEditor: () => BlockNoteEditor;
      +  documents: Array<{
      +    name: string;
      +    blocks: PartialBlock[];
      +  }>;
      +};
      diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts
      index caa76f6416..3f24ecdfea 100644
      --- a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts
      +++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts
      @@ -1,18 +1,6 @@
       import { Extension } from "@tiptap/core";
      -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos";
       import { defaultProps } from "../Blocks/api/defaultProps";
       
      -declare module "@tiptap/core" {
      -  interface Commands {
      -    blockBackgroundColor: {
      -      setBlockBackgroundColor: (
      -        posInBlock: number,
      -        color: string
      -      ) => ReturnType;
      -    };
      -  }
      -}
      -
       export const BackgroundColorExtension = Extension.create({
         name: "blockBackgroundColor",
       
      @@ -37,27 +25,4 @@ export const BackgroundColorExtension = Extension.create({
             },
           ];
         },
      -
      -  addCommands() {
      -    return {
      -      setBlockBackgroundColor:
      -        (posInBlock, color) =>
      -        ({ state, view }) => {
      -          const blockInfo = getBlockInfoFromPos(state.doc, posInBlock);
      -          if (blockInfo === undefined) {
      -            return false;
      -          }
      -
      -          state.tr.setNodeAttribute(
      -            blockInfo.startPos - 1,
      -            "backgroundColor",
      -            color
      -          );
      -
      -          view.focus();
      -
      -          return true;
      -        },
      -    };
      -  },
       });
      diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts
      index adcdca387f..df4b257588 100644
      --- a/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts
      +++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts
      @@ -1,24 +1,16 @@
       import { Mark } from "@tiptap/core";
      -import { defaultProps } from "../Blocks/api/defaultProps";
      +import { createStyleSpecFromTipTapMark } from "../Blocks/api/styles/internal";
       
      -declare module "@tiptap/core" {
      -  interface Commands {
      -    backgroundColor: {
      -      setBackgroundColor: (color: string) => ReturnType;
      -    };
      -  }
      -}
      -
      -export const BackgroundColorMark = Mark.create({
      +const BackgroundColorMark = Mark.create({
         name: "backgroundColor",
       
         addAttributes() {
           return {
      -      color: {
      +      stringValue: {
               default: undefined,
               parseHTML: (element) => element.getAttribute("data-background-color"),
               renderHTML: (attributes) => ({
      -          "data-background-color": attributes.color,
      +          "data-background-color": attributes.stringValue,
               }),
             },
           };
      @@ -34,7 +26,9 @@ export const BackgroundColorMark = Mark.create({
                 }
       
                 if (element.hasAttribute("data-background-color")) {
      -            return { color: element.getAttribute("data-background-color") };
      +            return {
      +              stringValue: element.getAttribute("data-background-color"),
      +            };
                 }
       
                 return false;
      @@ -46,18 +40,9 @@ export const BackgroundColorMark = Mark.create({
         renderHTML({ HTMLAttributes }) {
           return ["span", HTMLAttributes, 0];
         },
      -
      -  addCommands() {
      -    return {
      -      setBackgroundColor:
      -        (color) =>
      -        ({ commands }) => {
      -          if (color !== defaultProps.backgroundColor.default) {
      -            return commands.setMark(this.name, { color: color });
      -          }
      -
      -          return commands.unsetMark(this.name);
      -        },
      -    };
      -  },
       });
      +
      +export const BackgroundColor = createStyleSpecFromTipTapMark(
      +  BackgroundColorMark,
      +  "string"
      +);
      diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts
      deleted file mode 100644
      index 9042d348e7..0000000000
      --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts
      +++ /dev/null
      @@ -1,219 +0,0 @@
      -/** Define the main block types **/
      -import { Node } from "@tiptap/core";
      -import { BlockNoteEditor, DefaultBlockSchema } from "../../..";
      -import { InlineContent, PartialInlineContent } from "./inlineContentTypes";
      -
      -export type BlockNoteDOMElement =
      -  | "editor"
      -  | "blockContainer"
      -  | "blockGroup"
      -  | "blockContent"
      -  | "inlineContent";
      -
      -export type BlockNoteDOMAttributes = Partial<{
      -  [DOMElement in BlockNoteDOMElement]: Record;
      -}>;
      -
      -// Defines a single prop spec, which includes the default value the prop should
      -// take and possible values it can take.
      -export type PropSpec = {
      -  values?: readonly PType[];
      -  default: PType;
      -};
      -
      -// Defines multiple block prop specs. The key of each prop is the name of the
      -// prop, while the value is a corresponding prop spec. This should be included
      -// in a block config or schema. From a prop schema, we can derive both the props'
      -// internal implementation (as TipTap node attributes) and the type information
      -// for the external API.
      -export type PropSchema = Record>;
      -
      -// Defines Props objects for use in Block objects in the external API. Converts
      -// each prop spec into a union type of its possible values, or a string if no
      -// values are specified.
      -export type Props = {
      -  [PName in keyof PSchema]: PSchema[PName]["default"] extends boolean
      -    ? PSchema[PName]["values"] extends readonly boolean[]
      -      ? PSchema[PName]["values"][number]
      -      : boolean
      -    : PSchema[PName]["default"] extends number
      -    ? PSchema[PName]["values"] extends readonly number[]
      -      ? PSchema[PName]["values"][number]
      -      : number
      -    : PSchema[PName]["default"] extends string
      -    ? PSchema[PName]["values"] extends readonly string[]
      -      ? PSchema[PName]["values"][number]
      -      : string
      -    : never;
      -};
      -
      -// BlockConfig contains the "schema" info about a Block
      -export type BlockConfig = {
      -  type: string;
      -  readonly propSchema: PropSchema;
      -  content: "inline" | "none" | "table";
      -};
      -
      -// Block implementation contains the "implementation" info about a Block
      -// such as the functions / Nodes required to render and / or serialize it
      -export type TiptapBlockImplementation = {
      -  requiredNodes?: Node[];
      -  node: Node;
      -  toInternalHTML: (
      -    block: Block,
      -    editor: BlockNoteEditor>
      -  ) => {
      -    dom: HTMLElement;
      -    contentDOM?: HTMLElement;
      -  };
      -  toExternalHTML: (
      -    block: Block,
      -    editor: BlockNoteEditor>
      -  ) => {
      -    dom: HTMLElement;
      -    contentDOM?: HTMLElement;
      -  };
      -};
      -
      -// Container for both the config and implementation of a block,
      -// and the type of BlockImplementation is based on that of the config
      -export type BlockSpec = {
      -  config: T;
      -  implementation: TiptapBlockImplementation;
      -};
      -
      -// Utility type. For a given object block schema, ensures that the key of each
      -// block spec matches the name of the TipTap node in it.
      -type NamesMatch>> =
      -  Blocks extends {
      -    [Type in keyof Blocks]: Type extends string
      -      ? Blocks[Type]["config"] extends { type: Type }
      -        ? Blocks[Type]
      -        : never
      -      : never;
      -  }
      -    ? Blocks
      -    : never;
      -
      -// Defines multiple block specs. Also ensures that the key of each block schema
      -// is the same as name of the TipTap node in it. This should be passed in the
      -// `blocks` option of the BlockNoteEditor. From a block schema, we can derive
      -// both the blocks' internal implementation (as TipTap nodes) and the type
      -// information for the external API.
      -export type BlockSchema = NamesMatch>>;
      -
      -export type BlockSchemaWithBlock<
      -  BType extends string,
      -  C extends BlockConfig
      -> = {
      -  [k in BType]: BlockSpec;
      -};
      -
      -export type TableContent = {
      -  type: "tableContent";
      -  rows: {
      -    cells: InlineContent[][];
      -  }[];
      -};
      -
      -export type PartialTableContent = {
      -  type: "tableContent";
      -  rows: {
      -    cells: (PartialInlineContent[] | string)[];
      -  }[];
      -};
      -
      -// A BlockConfig has all the information to get the type of a Block (which is a specific instance of the BlockConfig.
      -// i.e.: paragraphConfig: BlockConfig defines what a "paragraph" is / supports, and BlockFromBlockConfig is the shape of a specific paragraph block.
      -// (for internal use)
      -type BlockFromBlockConfig = {
      -  id: string;
      -  type: B["type"];
      -  props: Props;
      -  content: B["content"] extends "inline"
      -    ? InlineContent[]
      -    : B["content"] extends "table"
      -    ? TableContent
      -    : B["content"] extends "none"
      -    ? undefined
      -    : never;
      -};
      -
      -// Converts each block spec into a Block object without children. We later merge
      -// them into a union type and add a children property to create the Block and
      -// PartialBlock objects we use in the external API.
      -type BlocksWithoutChildren = {
      -  [BType in keyof BSchema]: BlockFromBlockConfig;
      -};
      -
      -// Converts each block spec into a Block object without children, merges them
      -// into a union type, and adds a children property
      -export type Block =
      -  (T extends BlockSchema
      -    ? BlocksWithoutChildren[keyof T]
      -    : T extends BlockConfig
      -    ? BlockFromBlockConfig
      -    : never) & {
      -    children: Block<
      -      T extends BlockSchema ? T : any // any should probably be BlockSchemaWithBlock;
      -    >[];
      -  };
      -
      -export type SpecificBlock<
      -  BSchema extends BlockSchema,
      -  BType extends keyof BSchema
      -> = BlocksWithoutChildren[BType] & {
      -  children: Block[];
      -};
      -
      -/** CODE FOR PARTIAL BLOCKS, analogous to above */
      -
      -type PartialBlockFromBlockConfig = {
      -  id?: string;
      -  type?: B["type"];
      -  props?: Partial>;
      -  content?: B["content"] extends "inline"
      -    ? PartialInlineContent[] | string
      -    : B["content"] extends "table"
      -    ? PartialTableContent
      -    : undefined;
      -};
      -
      -// Same as BlockWithoutChildren, but as a partial type with some changes to make
      -// it easier to create/update blocks in the editor.
      -type PartialBlocksWithoutChildren = {
      -  [BType in keyof BSchema]: PartialBlockFromBlockConfig<
      -    BSchema[BType]["config"]
      -  >;
      -};
      -
      -// Same as Block, but as a partial type with some changes to make it easier to
      -// create/update blocks in the editor.
      -
      -export type PartialBlock =
      -  PartialBlocksWithoutChildren[keyof PartialBlocksWithoutChildren] &
      -    Partial<{
      -      children: PartialBlock[];
      -    }>;
      -
      -// export type PartialBlock =
      -//   T extends BlockSchema
      -//     ? PartialBlocksWithoutChildren[keyof T]
      -//     : T extends BlockConfig
      -//     ? PartialBlockFromBlockConfig
      -//     : never;
      -
      -// & {
      -//   children?: PartialBlock<
      -//     T extends BlockSchema ? T : any // any should probably be BlockSchemaWithBlock;
      -//   >[];
      -// };
      -
      -export type SpecificPartialBlock<
      -  BSchema extends BlockSchema,
      -  BType extends keyof BSchema
      -> = PartialBlocksWithoutChildren[BType] & {
      -  children?: Block[];
      -};
      -
      -export type BlockIdentifier = { id: string } | string;
      diff --git a/packages/core/src/extensions/Blocks/api/customBlocks.ts b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts
      similarity index 60%
      rename from packages/core/src/extensions/Blocks/api/customBlocks.ts
      rename to packages/core/src/extensions/Blocks/api/blocks/createSpec.ts
      index fcff18a666..18b0d780f4 100644
      --- a/packages/core/src/extensions/Blocks/api/customBlocks.ts
      +++ b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts
      @@ -1,31 +1,42 @@
      -import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import { ParseRule } from "@tiptap/pm/model";
      +import { BlockNoteEditor } from "../../../../BlockNoteEditor";
      +import { InlineContentSchema } from "../inlineContent/types";
      +import { StyleSchema } from "../styles/types";
       import {
         createInternalBlockSpec,
         createStronglyTypedTiptapNode,
         getBlockFromPos,
      -  parse,
         propsToAttributes,
         wrapInBlockStructure,
      -} from "./block";
      -import { Block, BlockConfig, BlockSchemaWithBlock } from "./blockTypes";
      +} from "./internal";
      +import {
      +  BlockConfig,
      +  BlockFromConfig,
      +  BlockSchemaWithBlock,
      +  PartialBlockFromConfig,
      +} from "./types";
       
       // restrict content to "inline" and "none" only
       export type CustomBlockConfig = BlockConfig & {
         content: "inline" | "none";
       };
       
      -export type CustomBlockImplementation = {
      +export type CustomBlockImplementation<
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
         render: (
           /**
            * The custom block to render
            */
      -    block: Block,
      +    block: BlockFromConfig,
           /**
            * The BlockNote editor instance
            * This is typed generically. If you want an editor with your custom schema, you need to
            * cast it manually, e.g.: `const e = editor as BlockNoteEditor;`
            */
      -    editor: BlockNoteEditor>
      +    editor: BlockNoteEditor, I, S>
           // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations
           // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics
         ) => {
      @@ -38,20 +49,73 @@ export type CustomBlockImplementation = {
         // BlockNote.
         // TODO: Maybe can return undefined to ignore when serializing?
         toExternalHTML?: (
      -    block: Block,
      -    editor: BlockNoteEditor>
      +    block: BlockFromConfig,
      +    editor: BlockNoteEditor, I, S>
         ) => {
           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(
      -  blockConfig: T,
      -  blockImplementation: CustomBlockImplementation
      -) {
      +export function createBlockSpec<
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(blockConfig: T, blockImplementation: CustomBlockImplementation) {
         const node = createStronglyTypedTiptapNode({
           name: blockConfig.type as T["type"],
           content: (blockConfig.content === "inline"
      @@ -61,11 +125,11 @@ export function createBlockSpec(
           selectable: true,
       
           addAttributes() {
      -      return propsToAttributes(blockConfig);
      +      return propsToAttributes(blockConfig.propSchema);
           },
       
           parseHTML() {
      -      return parse(blockConfig);
      +      return getParseRules(blockConfig, blockImplementation.parse);
           },
       
           addNodeView() {
      diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/blocks/internal.ts
      similarity index 75%
      rename from packages/core/src/extensions/Blocks/api/block.ts
      rename to packages/core/src/extensions/Blocks/api/blocks/internal.ts
      index 75a045a39c..58f8b84d50 100644
      --- a/packages/core/src/extensions/Blocks/api/block.ts
      +++ b/packages/core/src/extensions/Blocks/api/blocks/internal.ts
      @@ -1,18 +1,28 @@
      -import { Attribute, Attributes, Editor, Node, NodeConfig } from "@tiptap/core";
      -import { ParseRule } from "prosemirror-model";
      -import { BlockNoteEditor } from "../../../BlockNoteEditor";
      -import { mergeCSSClasses } from "../../../shared/utils";
      -import { defaultBlockToHTML } from "../nodes/BlockContent/defaultBlockHelpers";
      +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";
      +import { inheritedProps } from "../defaultProps";
      +import { InlineContentSchema } from "../inlineContent/types";
      +import { StyleSchema } from "../styles/types";
       import {
         BlockConfig,
      +  BlockSchemaFromSpecs,
         BlockSchemaWithBlock,
         BlockSpec,
      +  BlockSpecs,
         PropSchema,
         Props,
         SpecificBlock,
         TiptapBlockImplementation,
      -} from "./blockTypes";
      -import { inheritedProps } from "./defaultProps";
      +} from "./types";
       
       export function camelToDataKebab(str: string): string {
         return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
      @@ -20,10 +30,11 @@ export function camelToDataKebab(str: string): string {
       
       // Function that uses the 'propSchema' of a blockConfig to create a TipTap
       // node's `addAttributes` property.
      -export function propsToAttributes(blockConfig: BlockConfig): Attributes {
      +// TODO: extract function
      +export function propsToAttributes(propSchema: PropSchema): Attributes {
         const tiptapAttributes: Record = {};
       
      -  Object.entries(blockConfig.propSchema)
      +  Object.entries(propSchema)
           .filter(([name, _spec]) => !inheritedProps.includes(name))
           .forEach(([name, spec]) => {
             tiptapAttributes[name] = {
      @@ -77,60 +88,17 @@ export function propsToAttributes(blockConfig: BlockConfig): 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<
         BType extends string,
         Config extends BlockConfig,
      -  BSchema extends BlockSchemaWithBlock
      +  BSchema extends BlockSchemaWithBlock,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       >(
         getPos: (() => number) | boolean,
      -  editor: BlockNoteEditor,
      +  editor: BlockNoteEditor,
         tipTapEditor: Editor,
         type: BType
       ) {
      @@ -148,7 +116,9 @@ export function getBlockFromPos<
         // Gets the block
         const block = editor.getBlock(blockIdentifier)! as SpecificBlock<
           BSchema,
      -    BType
      +    BType,
      +    I,
      +    S
         >;
         if (block.type !== type) {
           throw new Error("Block type does not match");
      @@ -238,18 +208,23 @@ export function createStronglyTypedTiptapNode<
       // config and implementation that conform to the type of Config
       export function createInternalBlockSpec(
         config: T,
      -  implementation: TiptapBlockImplementation
      +  implementation: TiptapBlockImplementation<
      +    T,
      +    any,
      +    InlineContentSchema,
      +    StyleSchema
      +  >
       ) {
         return {
           config,
           implementation,
      -  } satisfies BlockSpec;
      +  } satisfies BlockSpec;
       }
       
       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"],
      @@ -266,9 +241,16 @@ export function createBlockSpecFromStronglyTypedTiptapNode<
           },
           {
             node,
      -      requiredNodes,
      +      requiredExtensions,
             toInternalHTML: defaultBlockToHTML,
             toExternalHTML: defaultBlockToHTML,
      +      // parse: () => undefined, // parse rules are in node already
           }
         );
       }
      +
      +export function getBlockSchemaFromSpecs(specs: T) {
      +  return Object.fromEntries(
      +    Object.entries(specs).map(([key, value]) => [key, value.config])
      +  ) as BlockSchemaFromSpecs;
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/blocks/types.ts b/packages/core/src/extensions/Blocks/api/blocks/types.ts
      new file mode 100644
      index 0000000000..29b4acfe79
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/blocks/types.ts
      @@ -0,0 +1,284 @@
      +/** Define the main block types **/
      +import { Extension, Node } from "@tiptap/core";
      +
      +import { BlockNoteEditor } from "../../../../BlockNoteEditor";
      +import {
      +  InlineContent,
      +  InlineContentSchema,
      +  PartialInlineContent,
      +} from "../inlineContent/types";
      +import { StyleSchema } from "../styles/types";
      +
      +export type BlockNoteDOMElement =
      +  | "editor"
      +  | "blockContainer"
      +  | "blockGroup"
      +  | "blockContent"
      +  | "inlineContent";
      +
      +export type BlockNoteDOMAttributes = Partial<{
      +  [DOMElement in BlockNoteDOMElement]: Record;
      +}>;
      +
      +// Defines a single prop spec, which includes the default value the prop should
      +// take and possible values it can take.
      +export type PropSpec = {
      +  values?: readonly PType[];
      +  default: PType;
      +};
      +
      +// Defines multiple block prop specs. The key of each prop is the name of the
      +// prop, while the value is a corresponding prop spec. This should be included
      +// in a block config or schema. From a prop schema, we can derive both the props'
      +// internal implementation (as TipTap node attributes) and the type information
      +// for the external API.
      +export type PropSchema = Record>;
      +
      +// Defines Props objects for use in Block objects in the external API. Converts
      +// each prop spec into a union type of its possible values, or a string if no
      +// values are specified.
      +export type Props = {
      +  [PName in keyof PSchema]: PSchema[PName]["default"] extends boolean
      +    ? PSchema[PName]["values"] extends readonly boolean[]
      +      ? PSchema[PName]["values"][number]
      +      : boolean
      +    : PSchema[PName]["default"] extends number
      +    ? PSchema[PName]["values"] extends readonly number[]
      +      ? PSchema[PName]["values"][number]
      +      : number
      +    : PSchema[PName]["default"] extends string
      +    ? PSchema[PName]["values"] extends readonly string[]
      +      ? PSchema[PName]["values"][number]
      +      : string
      +    : never;
      +};
      +
      +// BlockConfig contains the "schema" info about a Block type
      +// i.e. what props it supports, what content it supports, etc.
      +export type BlockConfig = {
      +  type: string;
      +  readonly propSchema: PropSchema;
      +  content: "inline" | "none" | "table";
      +};
      +
      +// Block implementation contains the "implementation" info about a Block
      +// such as the functions / Nodes required to render and / or serialize it
      +export type TiptapBlockImplementation<
      +  T extends BlockConfig,
      +  B extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  requiredExtensions?: Array;
      +  node: Node;
      +  toInternalHTML: (
      +    block: BlockFromConfigNoChildren & {
      +      children: Block[];
      +    },
      +    editor: BlockNoteEditor
      +  ) => {
      +    dom: HTMLElement;
      +    contentDOM?: HTMLElement;
      +  };
      +  toExternalHTML: (
      +    block: BlockFromConfigNoChildren & {
      +      children: Block[];
      +    },
      +    editor: BlockNoteEditor
      +  ) => {
      +    dom: HTMLElement;
      +    contentDOM?: HTMLElement;
      +  };
      +};
      +
      +// A Spec contains both the Config and Implementation
      +export type BlockSpec<
      +  T extends BlockConfig,
      +  B extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  config: T;
      +  implementation: TiptapBlockImplementation;
      +};
      +
      +// Utility type. For a given object block schema, ensures that the key of each
      +// block spec matches the name of the TipTap node in it.
      +type NamesMatch> = Blocks extends {
      +  [Type in keyof Blocks]: Type extends string
      +    ? Blocks[Type] extends { type: Type }
      +      ? Blocks[Type]
      +      : never
      +    : never;
      +}
      +  ? Blocks
      +  : never;
      +
      +// A Schema contains all the types (Configs) supported in an editor
      +// The keys are the "type" of a block
      +export type BlockSchema = NamesMatch>;
      +
      +export type BlockSpecs = Record<
      +  string,
      +  BlockSpec
      +>;
      +
      +export type BlockImplementations = Record<
      +  string,
      +  TiptapBlockImplementation
      +>;
      +
      +export type BlockSchemaFromSpecs = {
      +  [K in keyof T]: T[K]["config"];
      +};
      +
      +export type BlockSchemaWithBlock<
      +  BType extends string,
      +  C extends BlockConfig
      +> = {
      +  [k in BType]: C;
      +};
      +
      +export type TableContent<
      +  I extends InlineContentSchema,
      +  S extends StyleSchema = StyleSchema
      +> = {
      +  type: "tableContent";
      +  rows: {
      +    cells: InlineContent[][];
      +  }[];
      +};
      +
      +// A BlockConfig has all the information to get the type of a Block (which is a specific instance of the BlockConfig.
      +// i.e.: paragraphConfig: BlockConfig defines what a "paragraph" is / supports, and BlockFromConfigNoChildren is the shape of a specific paragraph block.
      +// (for internal use)
      +export type BlockFromConfigNoChildren<
      +  B extends BlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  id: string;
      +  type: B["type"];
      +  props: Props;
      +  content: B["content"] extends "inline"
      +    ? InlineContent[]
      +    : B["content"] extends "table"
      +    ? TableContent
      +    : B["content"] extends "none"
      +    ? undefined
      +    : never;
      +};
      +
      +export type BlockFromConfig<
      +  B extends BlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = BlockFromConfigNoChildren & {
      +  children: Block[];
      +};
      +
      +// Converts each block spec into a Block object without children. We later merge
      +// them into a union type and add a children property to create the Block and
      +// PartialBlock objects we use in the external API.
      +type BlocksWithoutChildren<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  [BType in keyof BSchema]: BlockFromConfigNoChildren;
      +};
      +
      +// Converts each block spec into a Block object without children, merges them
      +// into a union type, and adds a children property
      +export type Block<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = BlocksWithoutChildren[keyof BSchema] & {
      +  children: Block[];
      +};
      +
      +export type SpecificBlock<
      +  BSchema extends BlockSchema,
      +  BType extends keyof BSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = BlocksWithoutChildren[BType] & {
      +  children: Block[];
      +};
      +
      +/** CODE FOR PARTIAL BLOCKS, analogous to above
      + *
      + * Partial blocks are convenience-wrappers to make it easier to
      + *create/update blocks in the editor.
      + *
      + */
      +
      +export type PartialTableContent<
      +  I extends InlineContentSchema,
      +  S extends StyleSchema = StyleSchema
      +> = {
      +  type: "tableContent";
      +  rows: {
      +    cells: PartialInlineContent[];
      +  }[];
      +};
      +
      +type PartialBlockFromConfigNoChildren<
      +  B extends BlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  id?: string;
      +  type?: B["type"];
      +  props?: Partial>;
      +  content?: B["content"] extends "inline"
      +    ? PartialInlineContent
      +    : B["content"] extends "table"
      +    ? PartialTableContent
      +    : undefined;
      +};
      +
      +type PartialBlocksWithoutChildren<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  [BType in keyof BSchema]: PartialBlockFromConfigNoChildren<
      +    BSchema[BType],
      +    I,
      +    S
      +  >;
      +};
      +
      +export type PartialBlock<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = PartialBlocksWithoutChildren<
      +  BSchema,
      +  I,
      +  S
      +>[keyof PartialBlocksWithoutChildren] &
      +  Partial<{
      +    children: PartialBlock[];
      +  }>;
      +
      +export type SpecificPartialBlock<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  BType extends keyof BSchema,
      +  S extends StyleSchema
      +> = PartialBlocksWithoutChildren[BType] & {
      +  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/cursorPositionTypes.ts b/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts
      index eb17e098f3..ce21cda6f4 100644
      --- a/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts
      +++ b/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts
      @@ -1,7 +1,13 @@
      -import { Block, BlockSchema } from "./blockTypes";
      +import { Block, BlockSchema } from "./blocks/types";
      +import { InlineContentSchema } from "./inlineContent/types";
      +import { StyleSchema } from "./styles/types";
       
      -export type TextCursorPosition = {
      -  block: Block;
      -  prevBlock: Block | undefined;
      -  nextBlock: Block | undefined;
      +export type TextCursorPosition<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  block: Block;
      +  prevBlock: Block | undefined;
      +  nextBlock: Block | undefined;
       };
      diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts
      index 41a8c2906e..dd15f12f74 100644
      --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts
      +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts
      @@ -1,18 +1,60 @@
      +import Bold from "@tiptap/extension-bold";
      +import Code from "@tiptap/extension-code";
      +import Italic from "@tiptap/extension-italic";
      +import Strike from "@tiptap/extension-strike";
      +import Underline from "@tiptap/extension-underline";
      +import { BackgroundColor } from "../../BackgroundColor/BackgroundColorMark";
      +import { TextColor } from "../../TextColor/TextColorMark";
       import { Heading } from "../nodes/BlockContent/HeadingBlockContent/HeadingBlockContent";
       import { Image } from "../nodes/BlockContent/ImageBlockContent/ImageBlockContent";
       import { BulletListItem } from "../nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent";
       import { NumberedListItem } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent";
       import { Paragraph } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent";
       import { Table } from "../nodes/BlockContent/TableBlockContent/TableBlockContent";
      -import { BlockSchema } from "./blockTypes";
      +import { getBlockSchemaFromSpecs } from "./blocks/internal";
      +import { BlockSpecs } from "./blocks/types";
      +import { getInlineContentSchemaFromSpecs } from "./inlineContent/internal";
      +import { InlineContentSpecs } from "./inlineContent/types";
      +import {
      +  createStyleSpecFromTipTapMark,
      +  getStyleSchemaFromSpecs,
      +} from "./styles/internal";
      +import { StyleSpecs } from "./styles/types";
       
      -export const defaultBlockSchema = {
      +export const defaultBlockSpecs = {
         paragraph: Paragraph,
         heading: Heading,
         bulletListItem: BulletListItem,
         numberedListItem: NumberedListItem,
         image: Image,
         table: Table,
      -} satisfies BlockSchema;
      +} satisfies BlockSpecs;
      +
      +export const defaultBlockSchema = getBlockSchemaFromSpecs(defaultBlockSpecs);
       
       export type DefaultBlockSchema = typeof defaultBlockSchema;
      +
      +export const defaultStyleSpecs = {
      +  bold: createStyleSpecFromTipTapMark(Bold, "boolean"),
      +  italic: createStyleSpecFromTipTapMark(Italic, "boolean"),
      +  underline: createStyleSpecFromTipTapMark(Underline, "boolean"),
      +  strike: createStyleSpecFromTipTapMark(Strike, "boolean"),
      +  code: createStyleSpecFromTipTapMark(Code, "boolean"),
      +  textColor: TextColor,
      +  backgroundColor: BackgroundColor,
      +} satisfies StyleSpecs;
      +
      +export const defaultStyleSchema = getStyleSchemaFromSpecs(defaultStyleSpecs);
      +
      +export type DefaultStyleSchema = typeof defaultStyleSchema;
      +
      +export const defaultInlineContentSpecs = {
      +  text: { config: "text", implementation: {} as any },
      +  link: { config: "link", implementation: {} as any },
      +} satisfies InlineContentSpecs;
      +
      +export const defaultInlineContentSchema = getInlineContentSchemaFromSpecs(
      +  defaultInlineContentSpecs
      +);
      +
      +export type DefaultInlineContentSchema = typeof defaultInlineContentSchema;
      diff --git a/packages/core/src/extensions/Blocks/api/defaultProps.ts b/packages/core/src/extensions/Blocks/api/defaultProps.ts
      index 17783364a1..43f36d7a6b 100644
      --- a/packages/core/src/extensions/Blocks/api/defaultProps.ts
      +++ b/packages/core/src/extensions/Blocks/api/defaultProps.ts
      @@ -1,4 +1,4 @@
      -import { Props, PropSchema } from "./blockTypes";
      +import { Props, PropSchema } from "./blocks/types";
       
       export const defaultProps = {
         backgroundColor: {
      diff --git a/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts b/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts
      new file mode 100644
      index 0000000000..220e85c6a3
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts
      @@ -0,0 +1,107 @@
      +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 {
      +  addInlineContentAttributes,
      +  createInlineContentSpecFromTipTapNode,
      +} from "./internal";
      +import {
      +  CustomInlineContentConfig,
      +  InlineContentConfig,
      +  InlineContentFromConfig,
      +  InlineContentSpec,
      +} from "./types";
      +
      +// TODO: support serialization
      +
      +export type CustomInlineContentImplementation<
      +  T extends InlineContentConfig,
      +  // B extends BlockSchema,
      +  // I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  render: (
      +    /**
      +     * The custom inline content to render
      +     */
      +    inlineContent: InlineContentFromConfig
      +    /**
      +     * The BlockNote editor instance
      +     * This is typed generically. If you want an editor with your custom schema, you need to
      +     * cast it manually, e.g.: `const e = editor as BlockNoteEditor;`
      +     */
      +    // editor: BlockNoteEditor
      +    // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations
      +    // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics
      +  ) => {
      +    dom: HTMLElement;
      +    contentDOM?: HTMLElement;
      +    // destroy?: () => void;
      +  };
      +};
      +
      +export function getInlineContentParseRules(
      +  config: CustomInlineContentConfig
      +): ParseRule[] {
      +  return [
      +    {
      +      tag: `.bn-inline-content-section[data-inline-content-type="${config.type}"]`,
      +    },
      +  ];
      +}
      +
      +export function createInlineContentSpec<
      +  T extends CustomInlineContentConfig,
      +  S extends StyleSchema
      +>(
      +  inlineContentConfig: T,
      +  inlineContentImplementation: CustomInlineContentImplementation
      +): InlineContentSpec {
      +  const node = Node.create({
      +    name: inlineContentConfig.type,
      +    inline: true,
      +    group: "inline",
      +    content:
      +      inlineContentConfig.content === "styled"
      +        ? "inline*"
      +        : ("inline" as T["content"] extends "styled" ? "inline*" : "inline"),
      +
      +    addAttributes() {
      +      return propsToAttributes(inlineContentConfig.propSchema);
      +    },
      +
      +    parseHTML() {
      +      return getInlineContentParseRules(inlineContentConfig);
      +    },
      +
      +    renderHTML({ node }) {
      +      const editor = this.options.editor;
      +
      +      const output = inlineContentImplementation.render(
      +        nodeToCustomInlineContent(
      +          node,
      +          editor.inlineContentSchema,
      +          editor.styleSchema
      +        ) as any as InlineContentFromConfig // TODO: fix cast
      +      );
      +
      +      return {
      +        dom: addInlineContentAttributes(
      +          output.dom,
      +          inlineContentConfig.type,
      +          node.attrs as Props,
      +          inlineContentConfig.propSchema
      +        ),
      +        contentDOM: output.contentDOM,
      +      };
      +    },
      +  });
      +
      +  return createInlineContentSpecFromTipTapNode(
      +    node,
      +    inlineContentConfig.propSchema
      +  ) as InlineContentSpec; // TODO: fix cast
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts b/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts
      new file mode 100644
      index 0000000000..d081338be8
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts
      @@ -0,0 +1,78 @@
      +import { Node } from "@tiptap/core";
      +import { camelToDataKebab } from "../blocks/internal";
      +import { Props, PropSchema } from "../blocks/types";
      +import {
      +  InlineContentConfig,
      +  InlineContentImplementation,
      +  InlineContentSchemaFromSpecs,
      +  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
      +export function createInternalInlineContentSpec(
      +  config: T,
      +  implementation: InlineContentImplementation
      +) {
      +  return {
      +    config,
      +    implementation,
      +  } satisfies InlineContentSpec;
      +}
      +
      +export function createInlineContentSpecFromTipTapNode<
      +  T extends Node,
      +  P extends PropSchema
      +>(node: T, propSchema: P) {
      +  return createInternalInlineContentSpec(
      +    {
      +      type: node.name as T["name"],
      +      propSchema,
      +      content: node.config.content === "inline*" ? "styled" : "none",
      +    },
      +    {
      +      node,
      +    }
      +  );
      +}
      +
      +export function getInlineContentSchemaFromSpecs(
      +  specs: T
      +) {
      +  return Object.fromEntries(
      +    Object.entries(specs).map(([key, value]) => [key, value.config])
      +  ) as InlineContentSchemaFromSpecs;
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/inlineContent/types.ts b/packages/core/src/extensions/Blocks/api/inlineContent/types.ts
      new file mode 100644
      index 0000000000..b50622816d
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/inlineContent/types.ts
      @@ -0,0 +1,144 @@
      +import { Node } from "@tiptap/core";
      +import { PropSchema, Props } from "../blocks/types";
      +import { StyleSchema, Styles } from "../styles/types";
      +
      +export type CustomInlineContentConfig = {
      +  type: string;
      +  content: "styled" | "none"; // | "plain"
      +  readonly propSchema: PropSchema;
      +  // content: "inline" | "none" | "table";
      +};
      +// InlineContentConfig contains the "schema" info about an InlineContent type
      +// i.e. what props it supports, what content it supports, etc.
      +export type InlineContentConfig = CustomInlineContentConfig | "text" | "link";
      +
      +// InlineContentImplementation contains the "implementation" info about an InlineContent element
      +// such as the functions / Nodes required to render and / or serialize it
      +// @ts-ignore
      +export type InlineContentImplementation =
      +  T extends "link" | "text"
      +    ? undefined
      +    : {
      +        node: Node;
      +      };
      +
      +// Container for both the config and implementation of InlineContent,
      +// and the type of `implementation` is based on that of the config
      +export type InlineContentSpec = {
      +  config: T;
      +  implementation: InlineContentImplementation;
      +};
      +
      +// A Schema contains all the types (Configs) supported in an editor
      +// The keys are the "type" of InlineContent elements
      +export type InlineContentSchema = Record;
      +
      +export type InlineContentSpecs = {
      +  text: { config: "text"; implementation: undefined };
      +  link: { config: "link"; implementation: undefined };
      +} & Record>;
      +
      +export type InlineContentSchemaFromSpecs = {
      +  [K in keyof T]: T[K]["config"];
      +};
      +
      +export type CustomInlineContentFromConfig<
      +  I extends CustomInlineContentConfig,
      +  S extends StyleSchema
      +> = {
      +  type: I["type"];
      +  props: Props;
      +  content: I["content"] extends "styled"
      +    ? StyledText[]
      +    : I["content"] extends "plain"
      +    ? string
      +    : I["content"] extends "none"
      +    ? undefined
      +    : never;
      +};
      +
      +export type InlineContentFromConfig<
      +  I extends InlineContentConfig,
      +  S extends StyleSchema
      +> = I extends "text"
      +  ? StyledText
      +  : I extends "link"
      +  ? Link
      +  : I extends CustomInlineContentConfig
      +  ? CustomInlineContentFromConfig
      +  : never;
      +
      +export type PartialCustomInlineContentFromConfig<
      +  I extends CustomInlineContentConfig,
      +  S extends StyleSchema
      +> = {
      +  type: I["type"];
      +  props?: Props;
      +  content: I["content"] extends "styled"
      +    ? StyledText[] | string
      +    : I["content"] extends "plain"
      +    ? string
      +    : I["content"] extends "none"
      +    ? undefined
      +    : never;
      +};
      +
      +export type PartialInlineContentFromConfig<
      +  I extends InlineContentConfig,
      +  S extends StyleSchema
      +> = I extends "text"
      +  ? string | StyledText
      +  : I extends "link"
      +  ? PartialLink
      +  : I extends CustomInlineContentConfig
      +  ? PartialCustomInlineContentFromConfig
      +  : never;
      +
      +export type StyledText = {
      +  type: "text";
      +  text: string;
      +  styles: Styles;
      +};
      +
      +export type Link = {
      +  type: "link";
      +  href: string;
      +  content: StyledText[];
      +};
      +
      +export type PartialLink = Omit, "content"> & {
      +  content: string | Link["content"];
      +};
      +
      +export type InlineContent<
      +  I extends InlineContentSchema,
      +  T extends StyleSchema
      +> = InlineContentFromConfig;
      +
      +type PartialInlineContentElement<
      +  I extends InlineContentSchema,
      +  T extends StyleSchema
      +> = PartialInlineContentFromConfig;
      +
      +export type PartialInlineContent<
      +  I extends InlineContentSchema,
      +  T extends StyleSchema
      +> = PartialInlineContentElement[] | string;
      +
      +export function isLinkInlineContent(
      +  content: InlineContent
      +): content is Link {
      +  return content.type === "link";
      +}
      +
      +export function isPartialLinkInlineContent(
      +  content: PartialInlineContentElement
      +): content is PartialLink {
      +  return typeof content !== "string" && content.type === "link";
      +}
      +
      +export function isStyledTextInlineContent(
      +  content: PartialInlineContentElement
      +): content is StyledText {
      +  return typeof content !== "string" && content.type === "text";
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/inlineContentTypes.ts b/packages/core/src/extensions/Blocks/api/inlineContentTypes.ts
      deleted file mode 100644
      index 9d63930d95..0000000000
      --- a/packages/core/src/extensions/Blocks/api/inlineContentTypes.ts
      +++ /dev/null
      @@ -1,36 +0,0 @@
      -export type Styles = {
      -  bold?: true;
      -  italic?: true;
      -  underline?: true;
      -  strike?: true;
      -  code?: true;
      -  textColor?: string;
      -  backgroundColor?: string;
      -};
      -
      -export type ToggledStyle = {
      -  [K in keyof Styles]-?: Required[K] extends true ? K : never;
      -}[keyof Styles];
      -
      -export type ColorStyle = {
      -  [K in keyof Styles]-?: Required[K] extends string ? K : never;
      -}[keyof Styles];
      -
      -export type StyledText = {
      -  type: "text";
      -  text: string;
      -  styles: Styles;
      -};
      -
      -export type Link = {
      -  type: "link";
      -  href: string;
      -  content: StyledText[];
      -};
      -
      -export type PartialLink = Omit & {
      -  content: string | Link["content"];
      -};
      -
      -export type InlineContent = StyledText | Link;
      -export type PartialInlineContent = StyledText | PartialLink;
      diff --git a/packages/core/src/extensions/Blocks/api/selectionTypes.ts b/packages/core/src/extensions/Blocks/api/selectionTypes.ts
      index 8a23f48094..61d8086ed4 100644
      --- a/packages/core/src/extensions/Blocks/api/selectionTypes.ts
      +++ b/packages/core/src/extensions/Blocks/api/selectionTypes.ts
      @@ -1,5 +1,11 @@
      -import { Block, BlockSchema } from "./blockTypes";
      +import { Block, BlockSchema } from "./blocks/types";
      +import { InlineContentSchema } from "./inlineContent/types";
      +import { StyleSchema } from "./styles/types";
       
      -export type Selection = {
      -  blocks: Block[];
      +export type Selection<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  blocks: Block[];
       };
      diff --git a/packages/core/src/extensions/Blocks/api/styles/createSpec.ts b/packages/core/src/extensions/Blocks/api/styles/createSpec.ts
      new file mode 100644
      index 0000000000..14c1c2274f
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/styles/createSpec.ts
      @@ -0,0 +1,79 @@
      +import { Mark } from "@tiptap/core";
      +import { ParseRule } from "@tiptap/pm/model";
      +import { UnreachableCaseError } from "../../../../shared/utils";
      +import {
      +  addStyleAttributes,
      +  createInternalStyleSpec,
      +  stylePropsToAttributes,
      +} from "./internal";
      +import { StyleConfig, StyleSpec } from "./types";
      +
      +export type CustomStyleImplementation = {
      +  render: T["propSchema"] extends "boolean"
      +    ? () => {
      +        dom: HTMLElement;
      +        contentDOM?: HTMLElement;
      +      }
      +    : (value: string) => {
      +        dom: HTMLElement;
      +        contentDOM?: HTMLElement;
      +      };
      +};
      +
      +// 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
      +): StyleSpec {
      +  const mark = Mark.create({
      +    name: styleConfig.type,
      +
      +    addAttributes() {
      +      return stylePropsToAttributes(styleConfig.propSchema);
      +    },
      +
      +    parseHTML() {
      +      return getStyleParseRules(styleConfig);
      +    },
      +
      +    renderHTML({ mark }) {
      +      let renderResult: {
      +        dom: HTMLElement;
      +        contentDOM?: HTMLElement;
      +      };
      +
      +      if (styleConfig.propSchema === "boolean") {
      +        // @ts-ignore not sure why this is complaining
      +        renderResult = styleImplementation.render();
      +      } else if (styleConfig.propSchema === "string") {
      +        renderResult = styleImplementation.render(mark.attrs.stringValue);
      +      } else {
      +        throw new UnreachableCaseError(styleConfig.propSchema);
      +      }
      +
      +      // const renderResult = styleImplementation.render();
      +      return {
      +        dom: addStyleAttributes(
      +          renderResult.dom,
      +          styleConfig.type,
      +          mark.attrs.stringValue,
      +          styleConfig.propSchema
      +        ),
      +        contentDOM: renderResult.contentDOM,
      +      };
      +    },
      +  });
      +
      +  return createInternalStyleSpec(styleConfig, {
      +    mark,
      +  });
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/styles/internal.ts b/packages/core/src/extensions/Blocks/api/styles/internal.ts
      new file mode 100644
      index 0000000000..27b32a3f7a
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/styles/internal.ts
      @@ -0,0 +1,89 @@
      +import { Attributes, Mark } from "@tiptap/core";
      +import {
      +  StyleConfig,
      +  StyleImplementation,
      +  StylePropSchema,
      +  StyleSchemaFromSpecs,
      +  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
      +export function createInternalStyleSpec(
      +  config: T,
      +  implementation: StyleImplementation
      +) {
      +  return {
      +    config,
      +    implementation,
      +  } satisfies StyleSpec;
      +}
      +
      +export function createStyleSpecFromTipTapMark<
      +  T extends Mark,
      +  P extends StylePropSchema
      +>(mark: T, propSchema: P) {
      +  return createInternalStyleSpec(
      +    {
      +      type: mark.name as T["name"],
      +      propSchema,
      +    },
      +    {
      +      mark,
      +    }
      +  );
      +}
      +
      +export function getStyleSchemaFromSpecs(specs: T) {
      +  return Object.fromEntries(
      +    Object.entries(specs).map(([key, value]) => [key, value.config])
      +  ) as StyleSchemaFromSpecs;
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/styles/types.ts b/packages/core/src/extensions/Blocks/api/styles/types.ts
      new file mode 100644
      index 0000000000..69caf021c9
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/styles/types.ts
      @@ -0,0 +1,42 @@
      +import { Mark } from "@tiptap/core";
      +
      +export type StylePropSchema = "boolean" | "string"; // TODO: use PropSchema as name? Use objects as type similar to blocks?
      +
      +// StyleConfig contains the "schema" info about a Style type
      +// i.e. what props it supports, what content it supports, etc.
      +export type StyleConfig = {
      +  type: string;
      +  readonly propSchema: StylePropSchema;
      +  // content: "inline" | "none" | "table";
      +};
      +
      +// StyleImplementation contains the "implementation" info about a Style element.
      +// Currently, the implementation is always a TipTap Mark
      +export type StyleImplementation = {
      +  mark: Mark;
      +};
      +
      +// Container for both the config and implementation of a Style,
      +// and the type of `implementation` is based on that of the config
      +export type StyleSpec = {
      +  config: T;
      +  implementation: StyleImplementation;
      +};
      +
      +// A Schema contains all the types (Configs) supported in an editor
      +// The keys are the "type" of Styles supported
      +export type StyleSchema = Record;
      +
      +export type StyleSpecs = Record>;
      +
      +export type StyleSchemaFromSpecs = {
      +  [K in keyof T]: T[K]["config"];
      +};
      +
      +export type Styles = {
      +  [K in keyof T]?: T[K]["propSchema"] extends "boolean"
      +    ? boolean
      +    : T[K]["propSchema"] extends "string"
      +    ? string
      +    : never;
      +};
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts
      index 499f81c160..bba83b4308 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts
      @@ -2,6 +2,7 @@ import { Node } from "@tiptap/core";
       import { Fragment, Node as PMNode, Slice } from "prosemirror-model";
       import { NodeSelection, TextSelection } from "prosemirror-state";
       
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
       import {
         blockToNode,
         inlineContentToNodes,
      @@ -14,7 +15,9 @@ import {
         BlockNoteDOMAttributes,
         BlockSchema,
         PartialBlock,
      -} from "../api/blockTypes";
      +} from "../api/blocks/types";
      +import { InlineContentSchema } from "../api/inlineContent/types";
      +import { StyleSchema } from "../api/styles/types";
       import { getBlockInfoFromPos } from "../helpers/getBlockInfoFromPos";
       import BlockAttributes from "./BlockAttributes";
       
      @@ -25,13 +28,21 @@ declare module "@tiptap/core" {
             BNDeleteBlock: (posInBlock: number) => ReturnType;
             BNMergeBlocks: (posBetweenBlocks: number) => ReturnType;
             BNSplitBlock: (posInBlock: number, keepType: boolean) => ReturnType;
      -      BNUpdateBlock: (
      +      BNUpdateBlock: <
      +        BSchema extends BlockSchema,
      +        I extends InlineContentSchema,
      +        S extends StyleSchema
      +      >(
               posInBlock: number,
      -        block: PartialBlock
      +        block: PartialBlock
             ) => ReturnType;
      -      BNCreateOrUpdateBlock: (
      +      BNCreateOrUpdateBlock: <
      +        BSchema extends BlockSchema,
      +        I extends InlineContentSchema,
      +        S extends StyleSchema
      +      >(
               posInBlock: number,
      -        block: PartialBlock
      +        block: PartialBlock
             ) => ReturnType;
           };
         }
      @@ -42,6 +53,7 @@ declare module "@tiptap/core" {
        */
       export const BlockContainer = Node.create<{
         domAttributes?: BlockNoteDOMAttributes;
      +  editor: BlockNoteEditor;
       }>({
         name: "blockContainer",
         group: "blockContainer",
      @@ -158,7 +170,13 @@ export const BlockContainer = Node.create<{
       
                     // Creates ProseMirror nodes for each child block, including their descendants.
                     for (const child of block.children) {
      -                childNodes.push(blockToNode(child, state.schema));
      +                childNodes.push(
      +                  blockToNode(
      +                    child,
      +                    state.schema,
      +                    this.options.editor.styleSchema
      +                  )
      +                );
                     }
       
                     // Checks if a blockGroup node already exists.
      @@ -193,9 +211,17 @@ export const BlockContainer = Node.create<{
                     } else if (Array.isArray(block.content)) {
                       // Adds a text node with the provided styles converted into marks to the content,
                       // for each InlineContent object.
      -                content = inlineContentToNodes(block.content, state.schema);
      +                content = inlineContentToNodes(
      +                  block.content,
      +                  state.schema,
      +                  this.options.editor.styleSchema
      +                );
                     } else if (block.content.type === "tableContent") {
      -                content = tableContentToNodes(block.content, state.schema);
      +                content = tableContentToNodes(
      +                  block.content,
      +                  state.schema,
      +                  this.options.editor.styleSchema
      +                );
                     } else {
                       throw new UnreachableCaseError(block.content.type);
                     }
      @@ -457,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) {
      @@ -478,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");
      @@ -496,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;
      @@ -526,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 dcb82fa5b6..50a0b74197 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts
      @@ -2,8 +2,8 @@ import { InputRule } from "@tiptap/core";
       import {
         createBlockSpecFromStronglyTypedTiptapNode,
         createStronglyTypedTiptapNode,
      -} from "../../../api/block";
      -import { PropSchema } from "../../../api/blockTypes";
      +} from "../../../api/blocks/internal";
      +import { PropSchema } from "../../../api/blocks/types";
       import { defaultProps } from "../../../api/defaultProps";
       import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
       
      @@ -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 85f1d3592e..4a373c03e5 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts
      @@ -2,12 +2,17 @@ import { BlockNoteEditor } from "../../../../../BlockNoteEditor";
       import { imageToolbarPluginKey } from "../../../../ImageToolbar/ImageToolbarPlugin";
       
       import {
      -  Block,
      +  CustomBlockConfig,
      +  createBlockSpec,
      +} from "../../../api/blocks/createSpec";
      +import {
      +  BlockFromConfig,
         BlockSchemaWithBlock,
         PropSchema,
      -} from "../../../api/blockTypes";
      -import { CustomBlockConfig, createBlockSpec } from "../../../api/customBlocks";
      +} from "../../../api/blocks/types";
       import { defaultProps } from "../../../api/defaultProps";
      +import { InlineContentSchema } from "../../../api/inlineContent/types";
      +import { StyleSchema } from "../../../api/styles/types";
       
       export const imagePropSchema = {
         textAlignment: defaultProps.textAlignment,
      @@ -52,7 +57,7 @@ const blockConfig = {
       } satisfies CustomBlockConfig;
       
       export const renderImage = (
      -  block: Block,
      +  block: BlockFromConfig,
         editor: BlockNoteEditor>
       ) => {
         // Wrapper element to set the image alignment, contains both image/image
      @@ -366,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 46777fa1a6..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
      @@ -2,8 +2,8 @@ import { InputRule } from "@tiptap/core";
       import {
         createBlockSpecFromStronglyTypedTiptapNode,
         createStronglyTypedTiptapNode,
      -} from "../../../../api/block";
      -import { PropSchema } from "../../../../api/blockTypes";
      +} from "../../../../api/blocks/internal";
      +import { PropSchema } from "../../../../api/blocks/types";
       import { defaultProps } from "../../../../api/defaultProps";
       import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers";
       import { handleEnter } from "../ListItemKeyboardShortcuts";
      @@ -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 1ce586fd4b..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
      @@ -2,8 +2,8 @@ import { InputRule } from "@tiptap/core";
       import {
         createBlockSpecFromStronglyTypedTiptapNode,
         createStronglyTypedTiptapNode,
      -} from "../../../../api/block";
      -import { PropSchema } from "../../../../api/blockTypes";
      +} from "../../../../api/blocks/internal";
      +import { PropSchema } from "../../../../api/blocks/types";
       import { defaultProps } from "../../../../api/defaultProps";
       import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers";
       import { handleEnter } from "../ListItemKeyboardShortcuts";
      @@ -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 d0d4c5d34e..8c826f413e 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts
      @@ -1,7 +1,7 @@
       import {
         createBlockSpecFromStronglyTypedTiptapNode,
         createStronglyTypedTiptapNode,
      -} from "../../../api/block";
      +} from "../../../api/blocks/internal";
       import { defaultProps } from "../../../api/defaultProps";
       import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
       
      @@ -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 f24ff7ce4d..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,13 +1,14 @@
      -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";
       import {
         createBlockSpecFromStronglyTypedTiptapNode,
         createStronglyTypedTiptapNode,
      -} from "../../../api/block";
      +} 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/Blocks/nodes/BlockContent/defaultBlockHelpers.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts
      index e6e828c08f..b5ade2c570 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts
      @@ -1,7 +1,9 @@
      -import { Block } from "../../../..";
       import { BlockNoteEditor } from "../../../../BlockNoteEditor";
       import { blockToNode } from "../../../../api/nodeConversions/nodeConversions";
       import { mergeCSSClasses } from "../../../../shared/utils";
      +import { Block, BlockSchema } from "../../api/blocks/types";
      +import { InlineContentSchema } from "../../api/inlineContent/types";
      +import { StyleSchema } from "../../api/styles/types";
       
       // Function that creates a ProseMirror `DOMOutputSpec` for a default block.
       // Since all default blocks have the same structure (`blockContent` div with a
      @@ -51,14 +53,22 @@ export function createDefaultBlockDOMOutputSpec(
       // Function used to convert default blocks to HTML. It uses the corresponding
       // node's `renderHTML` method to do the conversion by using a default
       // `DOMSerializer`.
      -export const defaultBlockToHTML = (
      -  block: Block,
      -  editor: BlockNoteEditor
      +export const defaultBlockToHTML = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  block: Block,
      +  editor: BlockNoteEditor
       ): {
         dom: HTMLElement;
         contentDOM?: HTMLElement;
       } => {
      -  const node = blockToNode(block, editor._tiptapEditor.schema).firstChild!;
      +  const node = blockToNode(
      +    block,
      +    editor._tiptapEditor.schema,
      +    editor.styleSchema
      +  ).firstChild!;
         const toDOM = editor._tiptapEditor.schema.nodes[node.type.name].spec.toDOM;
       
         if (toDOM === undefined) {
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts b/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts
      index 31d9af1516..88f4a3025c 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts
      @@ -1,6 +1,6 @@
       import { Node } from "@tiptap/core";
      -import { BlockNoteDOMAttributes } from "../api/blockTypes";
       import { mergeCSSClasses } from "../../../shared/utils";
      +import { BlockNoteDOMAttributes } from "../api/blocks/types";
       
       export const BlockGroup = Node.create<{
         domAttributes?: BlockNoteDOMAttributes;
      diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts
      index 29d893fccb..1af1cc2328 100644
      --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts
      +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts
      @@ -1,19 +1,22 @@
       import { isNodeSelection, isTextSelection, posToDOMRect } from "@tiptap/core";
       import { EditorState, Plugin, PluginKey } from "prosemirror-state";
       import { EditorView } from "prosemirror-view";
      +
      +import { BlockNoteEditor } from "../../BlockNoteEditor";
       import {
         BaseUiElementCallbacks,
         BaseUiElementState,
      -  BlockNoteEditor,
      -  BlockSchema,
      -} from "../..";
      +} from "../../shared/BaseUiElementTypes";
       import { EventEmitter } from "../../shared/EventEmitter";
      +import { BlockSchema } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       
       export type FormattingToolbarCallbacks = BaseUiElementCallbacks;
       
       export type FormattingToolbarState = BaseUiElementState;
       
      -export class FormattingToolbarView {
      +export class FormattingToolbarView {
         private formattingToolbarState?: FormattingToolbarState;
         public updateFormattingToolbar: () => void;
       
      @@ -26,21 +29,22 @@ export class FormattingToolbarView {
           state: EditorState;
           from: number;
           to: number;
      -  }) => boolean = ({ view, state, from, to }) => {
      -    const { doc, selection } = state;
      +  }) => boolean = ({ state }) => {
      +    const { selection } = state;
           const { empty } = selection;
       
      -    // Sometime check for `empty` is not enough.
      -    // Doubleclick an empty paragraph returns a node size of 2.
      -    // So we check also for an empty text size.
      -    const isEmptyTextBlock =
      -      !doc.textBetween(from, to).length && isTextSelection(state.selection);
      -
      -    return !(!view.hasFocus() || empty || isEmptyTextBlock);
      +    if (!isTextSelection(selection)) {
      +      return false;
      +    }
      +    return !empty;
         };
       
         constructor(
      -    private readonly editor: BlockNoteEditor,
      +    private readonly editor: BlockNoteEditor<
      +      BlockSchema,
      +      InlineContentSchema,
      +      StyleSchema
      +    >,
           private readonly pmView: EditorView,
           updateFormattingToolbar: (
             formattingToolbarState: FormattingToolbarState
      @@ -216,13 +220,11 @@ export const formattingToolbarPluginKey = new PluginKey(
         "FormattingToolbarPlugin"
       );
       
      -export class FormattingToolbarProsemirrorPlugin<
      -  BSchema extends BlockSchema
      -> extends EventEmitter {
      -  private view: FormattingToolbarView | undefined;
      +export class FormattingToolbarProsemirrorPlugin extends EventEmitter {
      +  private view: FormattingToolbarView | undefined;
         public readonly plugin: Plugin;
       
      -  constructor(editor: BlockNoteEditor) {
      +  constructor(editor: BlockNoteEditor) {
           super();
           this.plugin = new Plugin({
             key: formattingToolbarPluginKey,
      diff --git a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts
      index 65ae274279..abdd1f4cab 100644
      --- a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts
      +++ b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts
      @@ -5,7 +5,9 @@ import { Plugin, PluginKey } from "prosemirror-state";
       import { BlockNoteEditor } from "../../BlockNoteEditor";
       import { BaseUiElementState } from "../../shared/BaseUiElementTypes";
       import { EventEmitter } from "../../shared/EventEmitter";
      -import { BlockSchema } from "../Blocks/api/blockTypes";
      +import { BlockSchema } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       
       export type HyperlinkToolbarState = BaseUiElementState & {
         // The hovered hyperlink's URL, and the text it's displayed with in the
      @@ -14,11 +16,11 @@ export type HyperlinkToolbarState = BaseUiElementState & {
         text: string;
       };
       
      -class HyperlinkToolbarView {
      +class HyperlinkToolbarView {
         private hyperlinkToolbarState?: HyperlinkToolbarState;
         public updateHyperlinkToolbar: () => void;
       
      -  menuUpdateTimer: NodeJS.Timeout | undefined;
      +  menuUpdateTimer: ReturnType | undefined;
         startMenuUpdateTimer: () => void;
         stopMenuUpdateTimer: () => void;
       
      @@ -32,7 +34,7 @@ class HyperlinkToolbarView {
         hyperlinkMarkRange: Range | undefined;
       
         constructor(
      -    private readonly editor: BlockNoteEditor,
      +    private readonly editor: BlockNoteEditor,
           private readonly pmView: EditorView,
           updateHyperlinkToolbar: (
             hyperlinkToolbarState: HyperlinkToolbarState
      @@ -275,12 +277,14 @@ export const hyperlinkToolbarPluginKey = new PluginKey(
       );
       
       export class HyperlinkToolbarProsemirrorPlugin<
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > extends EventEmitter {
      -  private view: HyperlinkToolbarView | undefined;
      +  private view: HyperlinkToolbarView | undefined;
         public readonly plugin: Plugin;
       
      -  constructor(editor: BlockNoteEditor) {
      +  constructor(editor: BlockNoteEditor) {
           super();
           this.plugin = new Plugin({
             key: hyperlinkToolbarPluginKey,
      diff --git a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts
      index acf7f015f8..9ecb162d3a 100644
      --- a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts
      +++ b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts
      @@ -1,22 +1,31 @@
       import { EditorState, Plugin, PluginKey } from "prosemirror-state";
       import { EditorView } from "prosemirror-view";
      +
      +import { BlockNoteEditor } from "../../BlockNoteEditor";
       import {
         BaseUiElementCallbacks,
         BaseUiElementState,
      -  BlockNoteEditor,
      -  BlockSchema,
      -} from "../..";
      -import { Block } from "../../extensions/Blocks/api/blockTypes";
      +} from "../../shared/BaseUiElementTypes";
       import { EventEmitter } from "../../shared/EventEmitter";
      -import { Image } from "../Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent";
      +import { BlockSchema, SpecificBlock } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       export type ImageToolbarCallbacks = BaseUiElementCallbacks;
       
      -export type ImageToolbarState = BaseUiElementState & {
      -  block: Block<(typeof Image)["config"]>;
      +export type ImageToolbarState<
      +  B extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema = StyleSchema
      +> = BaseUiElementState & {
      +  block: SpecificBlock;
       };
       
      -export class ImageToolbarView {
      -  private imageToolbarState?: ImageToolbarState;
      +export class ImageToolbarView<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> {
      +  private imageToolbarState?: ImageToolbarState;
         public updateImageToolbar: () => void;
       
         public prevWasEditable: boolean | null = null;
      @@ -24,7 +33,9 @@ export class ImageToolbarView {
         constructor(
           private readonly pluginKey: PluginKey,
           private readonly pmView: EditorView,
      -    updateImageToolbar: (imageToolbarState: ImageToolbarState) => void
      +    updateImageToolbar: (
      +      imageToolbarState: ImageToolbarState
      +    ) => void
         ) {
           this.updateImageToolbar = () => {
             if (!this.imageToolbarState) {
      @@ -94,7 +105,7 @@ export class ImageToolbarView {
       
         update(view: EditorView, prevState: EditorState) {
           const pluginState: {
      -      block: Block<(typeof Image)["config"]>;
      +      block: SpecificBlock;
           } = this.pluginKey.getState(view.state);
       
           if (!this.imageToolbarState?.show && pluginState.block) {
      @@ -139,15 +150,17 @@ export class ImageToolbarView {
       export const imageToolbarPluginKey = new PluginKey("ImageToolbarPlugin");
       
       export class ImageToolbarProsemirrorPlugin<
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > extends EventEmitter {
      -  private view: ImageToolbarView | undefined;
      +  private view: ImageToolbarView | undefined;
         public readonly plugin: Plugin;
       
      -  constructor(_editor: BlockNoteEditor) {
      +  constructor(_editor: BlockNoteEditor) {
           super();
           this.plugin = new Plugin<{
      -      block: Block<(typeof Image)["config"]> | undefined;
      +      block: SpecificBlock | undefined;
           }>({
             key: imageToolbarPluginKey,
             view: (editorView) => {
      @@ -168,7 +181,7 @@ export class ImageToolbarProsemirrorPlugin<
                 };
               },
               apply: (transaction) => {
      -          const block: Block<(typeof Image)["config"]> | undefined =
      +          const block: SpecificBlock | undefined =
                   transaction.getMeta(imageToolbarPluginKey)?.block;
       
                 return {
      @@ -179,7 +192,7 @@ export class ImageToolbarProsemirrorPlugin<
           });
         }
       
      -  public onUpdate(callback: (state: ImageToolbarState) => void) {
      +  public onUpdate(callback: (state: ImageToolbarState) => void) {
           return this.on("update", callback);
         }
       }
      diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
      index c4e5823426..ed87b5df07 100644
      --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
      +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
      @@ -3,21 +3,27 @@ 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/blockTypes";
      +import { Block, BlockSchema } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos";
       import { slashMenuPluginKey } from "../SlashMenu/SlashMenuPlugin";
       import { MultipleNodeSelection } from "./MultipleNodeSelection";
       
       let dragImageElement: Element | undefined;
       
      -export type SideMenuState = BaseUiElementState & {
      +export type SideMenuState<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = BaseUiElementState & {
         // The block that the side menu is attached to.
      -  block: Block;
      +  block: Block;
       };
       
       export function getDraggableBlockFromCoords(
      @@ -170,9 +176,13 @@ function unsetDragImage() {
         }
       }
       
      -function dragStart(
      +function dragStart<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
         e: { dataTransfer: DataTransfer | null; clientY: number },
      -  editor: BlockNoteEditor
      +  editor: BlockNoteEditor
       ) {
         if (!e.dataTransfer) {
           return;
      @@ -224,7 +234,7 @@ function dragStart(
             selectedSlice.content
           );
       
      -    const plainText = markdown(externalHTML);
      +    const plainText = cleanHTMLToMarkdown(externalHTML);
       
           e.dataTransfer.clearData();
           e.dataTransfer.setData("blocknote/html", internalHTML);
      @@ -236,8 +246,13 @@ function dragStart(
         }
       }
       
      -export class SideMenuView implements PluginView {
      -  private sideMenuState?: SideMenuState;
      +export class SideMenuView<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> implements PluginView
      +{
      +  private sideMenuState?: SideMenuState;
       
         // When true, the drag handle with be anchored at the same level as root elements
         // When false, the drag handle with be just to the left of the element
      @@ -253,10 +268,10 @@ export class SideMenuView implements PluginView {
         public menuFrozen = false;
       
         constructor(
      -    private readonly editor: BlockNoteEditor,
      +    private readonly editor: BlockNoteEditor,
           private readonly pmView: EditorView,
           private readonly updateSideMenu: (
      -      sideMenuState: SideMenuState
      +      sideMenuState: SideMenuState
           ) => void
         ) {
           this.horizontalPosAnchoredAtRoot = true;
      @@ -561,12 +576,14 @@ export class SideMenuView implements PluginView {
       export const sideMenuPluginKey = new PluginKey("SideMenuPlugin");
       
       export class SideMenuProsemirrorPlugin<
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > extends EventEmitter {
      -  private sideMenuView: SideMenuView | undefined;
      +  private sideMenuView: SideMenuView | undefined;
         public readonly plugin: Plugin;
       
      -  constructor(private readonly editor: BlockNoteEditor) {
      +  constructor(private readonly editor: BlockNoteEditor) {
           super();
           this.plugin = new Plugin({
             key: sideMenuPluginKey,
      @@ -583,7 +600,7 @@ export class SideMenuProsemirrorPlugin<
           });
         }
       
      -  public onUpdate(callback: (state: SideMenuState) => void) {
      +  public onUpdate(callback: (state: SideMenuState) => void) {
           return this.on("update", callback);
         }
       
      diff --git a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts
      index 41fc78917c..6bcfd8c361 100644
      --- a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts
      +++ b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts
      @@ -1,11 +1,14 @@
      -import { SuggestionItem } from "../../shared/plugins/suggestion/SuggestionItem";
       import { BlockNoteEditor } from "../../BlockNoteEditor";
      -import { BlockSchema } from "../Blocks/api/blockTypes";
      -import { DefaultBlockSchema } from "../Blocks/api/defaultBlocks";
      +import { SuggestionItem } from "../../shared/plugins/suggestion/SuggestionItem";
      +import { BlockSchema } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       
       export type BaseSlashMenuItem<
      -  BSchema extends BlockSchema = DefaultBlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > = SuggestionItem & {
      -  execute: (editor: BlockNoteEditor) => void;
      +  execute: (editor: BlockNoteEditor) => void;
         aliases?: string[];
       };
      diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts
      index a190cc3209..67aec3cdb0 100644
      --- a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts
      +++ b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts
      @@ -6,21 +6,25 @@ import {
         SuggestionsMenuState,
         setupSuggestionsMenu,
       } from "../../shared/plugins/suggestion/SuggestionPlugin";
      -import { BlockSchema } from "../Blocks/api/blockTypes";
      +import { BlockSchema } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       import { BaseSlashMenuItem } from "./BaseSlashMenuItem";
       
       export const slashMenuPluginKey = new PluginKey("SlashMenuPlugin");
       
       export class SlashMenuProsemirrorPlugin<
         BSchema extends BlockSchema,
      -  SlashMenuItem extends BaseSlashMenuItem
      +  I extends InlineContentSchema,
      +  S extends StyleSchema,
      +  SlashMenuItem extends BaseSlashMenuItem
       > extends EventEmitter {
         public readonly plugin: Plugin;
         public readonly itemCallback: (item: SlashMenuItem) => void;
       
      -  constructor(editor: BlockNoteEditor, items: SlashMenuItem[]) {
      +  constructor(editor: BlockNoteEditor, items: SlashMenuItem[]) {
           super();
      -    const suggestions = setupSuggestionsMenu(
      +    const suggestions = setupSuggestionsMenu(
             editor,
             (state) => {
               this.emit("update", state);
      diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts
      index d4f1c14824..52a9bc9337 100644
      --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts
      +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts
      @@ -1,6 +1,11 @@
       import { BlockNoteEditor } from "../../BlockNoteEditor";
      -import { Block, BlockSchema, PartialBlock } from "../Blocks/api/blockTypes";
      +import { Block, BlockSchema, PartialBlock } from "../Blocks/api/blocks/types";
       import { defaultBlockSchema } from "../Blocks/api/defaultBlocks";
      +import {
      +  InlineContentSchema,
      +  isStyledTextInlineContent,
      +} from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin";
       import { BaseSlashMenuItem } from "./BaseSlashMenuItem";
       
      @@ -8,11 +13,13 @@ import { BaseSlashMenuItem } from "./BaseSlashMenuItem";
       // so either a block with inline content or a table. The last block is always a
       // paragraph, so this function won't try to set the cursor position past the
       // last block.
      -function setSelectionToNextContentEditableBlock(
      -  editor: BlockNoteEditor
      -) {
      +function setSelectionToNextContentEditableBlock<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(editor: BlockNoteEditor) {
         let block = editor.getTextCursorPosition().block;
      -  let contentType = editor.schema[block.type].config.content as
      +  let contentType = editor.blockSchema[block.type].content as
           | "inline"
           | "table"
           | "none";
      @@ -20,7 +27,7 @@ function setSelectionToNextContentEditableBlock(
         while (contentType === "none") {
           editor.setTextCursorPosition(block, "end");
           block = editor.getTextCursorPosition().nextBlock!;
      -    contentType = editor.schema[block.type].config.content as
      +    contentType = editor.blockSchema[block.type].content as
             | "inline"
             | "table"
             | "none";
      @@ -31,10 +38,14 @@ function setSelectionToNextContentEditableBlock(
       // updates the current block instead of inserting a new one below. If the new
       // block doesn't contain editable content, the cursor is moved to the next block
       // that does.
      -function insertOrUpdateBlock(
      -  editor: BlockNoteEditor,
      -  block: PartialBlock
      -): Block {
      +function insertOrUpdateBlock<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  editor: BlockNoteEditor,
      +  block: PartialBlock
      +): Block {
         const currentBlock = editor.getTextCursorPosition().block;
       
         if (currentBlock.content === undefined) {
      @@ -44,6 +55,7 @@ function insertOrUpdateBlock(
         if (
           Array.isArray(currentBlock.content) &&
           ((currentBlock.content.length === 1 &&
      +      isStyledTextInlineContent(currentBlock.content[0]) &&
             currentBlock.content[0].type === "text" &&
             currentBlock.content[0].text === "/") ||
             currentBlock.content.length === 0)
      @@ -63,18 +75,18 @@ function insertOrUpdateBlock(
         return insertedBlock;
       }
       
      -export const getDefaultSlashMenuItems = (
      -  // This type casting is weird, but it's the best way of doing it, as it allows
      -  // the schema type to be automatically inferred if it is defined, or be
      -  // inferred as any if it is not defined. I don't think it's possible to make it
      -  // infer to DefaultBlockSchema if it is not defined.
      +export const getDefaultSlashMenuItems = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
         schema: BSchema = defaultBlockSchema as unknown as BSchema
       ) => {
      -  const slashMenuItems: BaseSlashMenuItem[] = [];
      +  const slashMenuItems: BaseSlashMenuItem[] = [];
       
      -  if ("heading" in schema && "level" in schema.heading.config.propSchema) {
      +  if ("heading" in schema && "level" in schema.heading.propSchema) {
           // Command for creating a level 1 heading
      -    if (schema.heading.config.propSchema.level.values?.includes(1)) {
      +    if (schema.heading.propSchema.level.values?.includes(1)) {
             slashMenuItems.push({
               name: "Heading",
               aliases: ["h", "heading1", "h1"],
      @@ -82,12 +94,12 @@ export const getDefaultSlashMenuItems = (
                 insertOrUpdateBlock(editor, {
                   type: "heading",
                   props: { level: 1 },
      -          } as PartialBlock),
      +          } as PartialBlock),
             });
           }
       
           // Command for creating a level 2 heading
      -    if (schema.heading.config.propSchema.level.values?.includes(2)) {
      +    if (schema.heading.propSchema.level.values?.includes(2)) {
             slashMenuItems.push({
               name: "Heading 2",
               aliases: ["h2", "heading2", "subheading"],
      @@ -95,12 +107,12 @@ export const getDefaultSlashMenuItems = (
                 insertOrUpdateBlock(editor, {
                   type: "heading",
                   props: { level: 2 },
      -          } as PartialBlock),
      +          } as PartialBlock),
             });
           }
       
           // Command for creating a level 3 heading
      -    if (schema.heading.config.propSchema.level.values?.includes(3)) {
      +    if (schema.heading.propSchema.level.values?.includes(3)) {
             slashMenuItems.push({
               name: "Heading 3",
               aliases: ["h3", "heading3", "subheading"],
      @@ -108,7 +120,7 @@ export const getDefaultSlashMenuItems = (
                 insertOrUpdateBlock(editor, {
                   type: "heading",
                   props: { level: 3 },
      -          } as PartialBlock),
      +          } as PartialBlock),
             });
           }
         }
      @@ -120,7 +132,7 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) =>
               insertOrUpdateBlock(editor, {
                 type: "bulletListItem",
      -        } as PartialBlock),
      +        }),
           });
         }
       
      @@ -131,7 +143,7 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) =>
               insertOrUpdateBlock(editor, {
                 type: "numberedListItem",
      -        } as PartialBlock),
      +        }),
           });
         }
       
      @@ -142,7 +154,7 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) =>
               insertOrUpdateBlock(editor, {
                 type: "paragraph",
      -        } as PartialBlock),
      +        }),
           });
         }
       
      @@ -169,7 +181,7 @@ export const getDefaultSlashMenuItems = (
                     },
                   ],
                 },
      -        } as PartialBlock);
      +        } as PartialBlock);
             },
           });
         }
      @@ -191,7 +203,7 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) => {
               const insertedBlock = insertOrUpdateBlock(editor, {
                 type: "image",
      -        } as PartialBlock);
      +        });
       
               // Immediately open the image toolbar
               editor._tiptapEditor.view.dispatch(
      diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
      index 18e5bd42f5..f600bbf4d4 100644
      --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
      +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
      @@ -1,16 +1,19 @@
      -import { Plugin, PluginKey } from "prosemirror-state";
      +import { Plugin, PluginKey, PluginView } from "prosemirror-state";
       import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
       import {
      +  Block,
      +  BlockFromConfigNoChildren,
         BlockNoteEditor,
      -  BlockSchema,
      -  getDraggableBlockFromCoords,
      +  BlockSchemaWithBlock,
      +  DefaultBlockSchema,
      +  InlineContentSchema,
         PartialBlock,
      +  SpecificBlock,
      +  StyleSchema,
      +  getDraggableBlockFromCoords,
      +  nodeToBlock,
       } from "../..";
       import { EventEmitter } from "../../shared/EventEmitter";
      -import { Block } from "../Blocks/api/blockTypes";
      -import { Table } from "../Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent";
      -import { nodeToBlock } from "../../api/nodeConversions/nodeConversions";
      -import { PluginView } from "@tiptap/pm/state";
       
       let dragImageElement: HTMLElement | undefined;
       
      @@ -32,12 +35,15 @@ function unsetHiddenDragImage() {
         }
       }
       
      -export type TableHandlesState = {
      +export type TableHandlesState<
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
         show: boolean;
         referencePosCell: DOMRect;
         referencePosTable: DOMRect;
       
      -  block: Block<(typeof Table)["config"]>;
      +  block: BlockFromConfigNoChildren;
         colIndex: number;
         rowIndex: number;
       
      @@ -76,10 +82,13 @@ function hideElementsWithClassNames(classNames: string[]) {
         });
       }
       
      -export class TableHandlesView
      -  implements PluginView
      +export class TableHandlesView<
      +  BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> implements PluginView
       {
      -  public state?: TableHandlesState;
      +  public state?: TableHandlesState;
         public updateState: () => void;
       
         public tableId: string | undefined;
      @@ -90,9 +99,9 @@ export class TableHandlesView
         public prevWasEditable: boolean | null = null;
       
         constructor(
      -    private readonly editor: BlockNoteEditor,
      +    private readonly editor: BlockNoteEditor,
           private readonly pmView: EditorView,
      -    updateState: (state: TableHandlesState) => void
      +    updateState: (state: TableHandlesState) => void
         ) {
           this.updateState = () => {
             if (!this.state) {
      @@ -149,7 +158,7 @@ export class TableHandlesView
             return;
           }
       
      -    let block: Block | undefined = undefined;
      +    let block: Block | undefined = undefined;
       
           // Copied from `getBlock`. We don't use `getBlock` since we also need the PM
           // node for the table, so we would effectively be doing the same work twice.
      @@ -162,7 +171,13 @@ export class TableHandlesView
               return true;
             }
       
      -      block = nodeToBlock(node, this.editor.schema, this.editor.blockCache);
      +      block = nodeToBlock(
      +        node,
      +        this.editor.blockSchema,
      +        this.editor.inlineContentSchema,
      +        this.editor.styleSchema,
      +        this.editor.blockCache
      +      );
             this.tablePos = pos + 1;
       
             return false;
      @@ -173,7 +188,7 @@ export class TableHandlesView
             referencePosCell: cellRect,
             referencePosTable: tableRect,
       
      -      block: block! as Block<(typeof Table)["config"]>,
      +      block: block! as SpecificBlock,
             colIndex: colIndex,
             rowIndex: rowIndex,
       
      @@ -310,7 +325,7 @@ export class TableHandlesView
               type: "tableContent",
               rows: rows,
             },
      -    } as PartialBlock);
      +    } as PartialBlock);
         };
       
         scrollHandler = () => {
      @@ -343,12 +358,14 @@ export class TableHandlesView
       export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin");
       
       export class TableHandlesProsemirrorPlugin<
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > extends EventEmitter {
      -  private view: TableHandlesView | undefined;
      +  private view: TableHandlesView | undefined;
         public readonly plugin: Plugin;
       
      -  constructor(private readonly editor: BlockNoteEditor) {
      +  constructor(private readonly editor: BlockNoteEditor) {
           super();
           this.plugin = new Plugin({
             key: tableHandlesPluginKey,
      @@ -489,7 +506,7 @@ export class TableHandlesProsemirrorPlugin<
           });
         }
       
      -  public onUpdate(callback: (state: TableHandlesState) => void) {
      +  public onUpdate(callback: (state: TableHandlesState) => void) {
           return this.on("update", callback);
         }
       
      diff --git a/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts b/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts
      index 6a99548918..7f9fb505ea 100644
      --- a/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts
      +++ b/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts
      @@ -1,15 +1,4 @@
       import { Extension } from "@tiptap/core";
      -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos";
      -
      -declare module "@tiptap/core" {
      -  interface Commands {
      -    textAlignment: {
      -      setTextAlignment: (
      -        textAlignment: "left" | "center" | "right" | "justify"
      -      ) => ReturnType;
      -    };
      -  }
      -}
       
       export const TextAlignmentExtension = Extension.create({
         name: "textAlignment",
      @@ -23,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,
      @@ -33,43 +24,4 @@ export const TextAlignmentExtension = Extension.create({
             },
           ];
         },
      -
      -  addCommands() {
      -    return {
      -      setTextAlignment:
      -        (textAlignment) =>
      -        ({ state }) => {
      -          const positionsBeforeSelectedContent = [];
      -
      -          const blockInfo = getBlockInfoFromPos(
      -            state.doc,
      -            state.selection.from
      -          );
      -          if (blockInfo === undefined) {
      -            return false;
      -          }
      -
      -          // Finds all blockContent nodes that the current selection is in.
      -          let pos = blockInfo.startPos;
      -          while (pos < state.selection.to) {
      -            if (
      -              state.doc.resolve(pos).node().type.spec.group === "blockContent"
      -            ) {
      -              positionsBeforeSelectedContent.push(pos - 1);
      -
      -              pos += state.doc.resolve(pos).node().nodeSize - 1;
      -            } else {
      -              pos += 1;
      -            }
      -          }
      -
      -          // Sets text alignment for all blockContent nodes that the current selection is in.
      -          for (const pos of positionsBeforeSelectedContent) {
      -            state.tr.setNodeAttribute(pos, "textAlignment", textAlignment);
      -          }
      -
      -          return true;
      -        },
      -    };
      -  },
       });
      diff --git a/packages/core/src/extensions/TextColor/TextColorExtension.ts b/packages/core/src/extensions/TextColor/TextColorExtension.ts
      index a3ab7b8db8..09a5d894f4 100644
      --- a/packages/core/src/extensions/TextColor/TextColorExtension.ts
      +++ b/packages/core/src/extensions/TextColor/TextColorExtension.ts
      @@ -1,15 +1,6 @@
       import { Extension } from "@tiptap/core";
      -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos";
       import { defaultProps } from "../Blocks/api/defaultProps";
       
      -declare module "@tiptap/core" {
      -  interface Commands {
      -    blockTextColor: {
      -      setBlockTextColor: (posInBlock: number, color: string) => ReturnType;
      -    };
      -  }
      -}
      -
       export const TextColorExtension = Extension.create({
         name: "blockTextColor",
       
      @@ -33,23 +24,4 @@ export const TextColorExtension = Extension.create({
             },
           ];
         },
      -
      -  addCommands() {
      -    return {
      -      setBlockTextColor:
      -        (posInBlock, color) =>
      -        ({ state, view }) => {
      -          const blockInfo = getBlockInfoFromPos(state.doc, posInBlock);
      -          if (blockInfo === undefined) {
      -            return false;
      -          }
      -
      -          state.tr.setNodeAttribute(blockInfo.startPos - 1, "textColor", color);
      -
      -          view.focus();
      -
      -          return true;
      -        },
      -    };
      -  },
       });
      diff --git a/packages/core/src/extensions/TextColor/TextColorMark.ts b/packages/core/src/extensions/TextColor/TextColorMark.ts
      index ce8a0cb4ca..c18ab0b374 100644
      --- a/packages/core/src/extensions/TextColor/TextColorMark.ts
      +++ b/packages/core/src/extensions/TextColor/TextColorMark.ts
      @@ -1,24 +1,16 @@
       import { Mark } from "@tiptap/core";
      -import { defaultProps } from "../Blocks/api/defaultProps";
      +import { createStyleSpecFromTipTapMark } from "../Blocks/api/styles/internal";
       
      -declare module "@tiptap/core" {
      -  interface Commands {
      -    textColor: {
      -      setTextColor: (color: string) => ReturnType;
      -    };
      -  }
      -}
      -
      -export const TextColorMark = Mark.create({
      +const TextColorMark = Mark.create({
         name: "textColor",
       
         addAttributes() {
           return {
      -      color: {
      +      stringValue: {
               default: undefined,
               parseHTML: (element) => element.getAttribute("data-text-color"),
               renderHTML: (attributes) => ({
      -          "data-text-color": attributes.color,
      +          "data-text-color": attributes.stringValue,
               }),
             },
           };
      @@ -34,7 +26,7 @@ export const TextColorMark = Mark.create({
                 }
       
                 if (element.hasAttribute("data-text-color")) {
      -            return { color: element.getAttribute("data-text-color") };
      +            return { stringValue: element.getAttribute("data-text-color") };
                 }
       
                 return false;
      @@ -46,18 +38,6 @@ export const TextColorMark = Mark.create({
         renderHTML({ HTMLAttributes }) {
           return ["span", HTMLAttributes, 0];
         },
      -
      -  addCommands() {
      -    return {
      -      setTextColor:
      -        (color) =>
      -        ({ commands }) => {
      -          if (color !== defaultProps.textColor.default) {
      -            return commands.setMark(this.name, { color: color });
      -          }
      -
      -          return commands.unsetMark(this.name);
      -        },
      -    };
      -  },
       });
      +
      +export const TextColor = createStyleSpecFromTipTapMark(TextColorMark, "string");
      diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
      index e7849ebd06..41637442bb 100644
      --- a/packages/core/src/index.ts
      +++ b/packages/core/src/index.ts
      @@ -1,14 +1,20 @@
       export * from "./BlockNoteEditor";
       export * from "./BlockNoteExtensions";
      -export * from "./api/serialization/html/externalHTMLExporter";
      -export * from "./api/serialization/html/internalHTMLSerializer";
      -export * from "./extensions/Blocks/api/block";
      -export * from "./extensions/Blocks/api/blockTypes";
      -export * from "./extensions/Blocks/api/customBlocks";
      +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";
      +export * from "./extensions/Blocks/api/blocks/types";
       export * from "./extensions/Blocks/api/defaultBlocks";
       export * from "./extensions/Blocks/api/defaultProps";
      -export * from "./extensions/Blocks/api/inlineContentTypes";
      +export * from "./extensions/Blocks/api/inlineContent/createSpec";
      +export * from "./extensions/Blocks/api/inlineContent/internal";
      +export * from "./extensions/Blocks/api/inlineContent/types";
       export * from "./extensions/Blocks/api/selectionTypes";
      +export * from "./extensions/Blocks/api/styles/createSpec";
      +export * from "./extensions/Blocks/api/styles/internal";
      +export * from "./extensions/Blocks/api/styles/types";
       export * as blockStyles from "./extensions/Blocks/nodes/Block.css";
       export * from "./extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
       export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin";
      @@ -23,3 +29,7 @@ export * from "./shared/BaseUiElementTypes";
       export type { SuggestionItem } from "./shared/plugins/suggestion/SuggestionItem";
       export * from "./shared/plugins/suggestion/SuggestionPlugin";
       export * from "./shared/utils";
      +// for testing from react (TODO: move):
      +export * from "./api/nodeConversions/nodeConversions";
      +export * from "./api/nodeConversions/testUtil";
      +export * from "./extensions/UniqueID/UniqueID";
      diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts
      index 8cfdbfc841..480d935db3 100644
      --- a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts
      +++ b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts
      @@ -1,7 +1,9 @@
       import { EditorState, Plugin, PluginKey } from "prosemirror-state";
       import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
       import { BlockNoteEditor } from "../../../BlockNoteEditor";
      -import { BlockSchema } from "../../../extensions/Blocks/api/blockTypes";
      +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 { findBlock } from "../../../extensions/Blocks/helpers/findBlock";
       import { BaseUiElementState } from "../../BaseUiElementTypes";
       import { SuggestionItem } from "./SuggestionItem";
      @@ -16,7 +18,9 @@ export type SuggestionsMenuState =
       
       class SuggestionsMenuView<
         T extends SuggestionItem,
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > {
         private suggestionsMenuState?: SuggestionsMenuState;
         public updateSuggestionsMenu: () => void;
      @@ -24,7 +28,7 @@ class SuggestionsMenuView<
         pluginState: SuggestionPluginState;
       
         constructor(
      -    private readonly editor: BlockNoteEditor,
      +    private readonly editor: BlockNoteEditor,
           private readonly pluginKey: PluginKey,
           updateSuggestionsMenu: (
             suggestionsMenuState: SuggestionsMenuState
      @@ -147,9 +151,11 @@ function getDefaultPluginState<
        */
       export const setupSuggestionsMenu = <
         T extends SuggestionItem,
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       >(
      -  editor: BlockNoteEditor,
      +  editor: BlockNoteEditor,
         updateSuggestionsMenu: (
           suggestionsMenuState: SuggestionsMenuState
         ) => void,
      @@ -159,7 +165,7 @@ export const setupSuggestionsMenu = <
         items: (query: string) => T[] = () => [],
         onSelectItem: (props: {
           item: T;
      -    editor: BlockNoteEditor;
      +    editor: BlockNoteEditor;
         }) => void = () => {
           // noop
         }
      @@ -169,7 +175,7 @@ export const setupSuggestionsMenu = <
           throw new Error("'char' should be a single character");
         }
       
      -  let suggestionsPluginView: SuggestionsMenuView;
      +  let suggestionsPluginView: SuggestionsMenuView;
       
         const deactivate = (view: EditorView) => {
           view.dispatch(view.state.tr.setMeta(pluginKey, { deactivate: true }));
      @@ -180,7 +186,7 @@ export const setupSuggestionsMenu = <
             key: pluginKey,
       
             view: () => {
      -        suggestionsPluginView = new SuggestionsMenuView(
      +        suggestionsPluginView = new SuggestionsMenuView(
                 editor,
                 pluginKey,
       
      diff --git a/packages/react/package.json b/packages/react/package.json
      index 2405e5568c..c2a2ee4fca 100644
      --- a/packages/react/package.json
      +++ b/packages/react/package.json
      @@ -45,7 +45,8 @@
           "build-bundled": "tsc && vite build --config vite.config.bundled.ts && git checkout tmp-releases && rm -rf ../../release && mv ../../release-tmp ../../release",
           "preview": "vite preview",
           "lint": "eslint src --max-warnings 0",
      -    "test": "vitest --run"
      +    "test": "vitest --run",
      +    "test:watch": "vitest --watch"
         },
         "dependencies": {
           "@blocknote/core": "^0.9.6",
      diff --git a/packages/react/src/BlockNoteView.tsx b/packages/react/src/BlockNoteView.tsx
      index 1dddab9f30..c65cbc9b26 100644
      --- a/packages/react/src/BlockNoteView.tsx
      +++ b/packages/react/src/BlockNoteView.tsx
      @@ -1,4 +1,10 @@
      -import { BlockNoteEditor, BlockSchema, mergeCSSClasses } from "@blocknote/core";
      +import {
      +  BlockNoteEditor,
      +  BlockSchema,
      +  InlineContentSchema,
      +  StyleSchema,
      +  mergeCSSClasses,
      +} from "@blocknote/core";
       import { MantineProvider, createStyles } from "@mantine/core";
       import { EditorContent } from "@tiptap/react";
       import { HTMLAttributes, ReactNode, useMemo } from "react";
      @@ -13,9 +19,13 @@ import { TableHandlesPositioner } from "./TableHandles/components/TableHandlePos
       import { darkDefaultTheme, lightDefaultTheme } from "./defaultThemes";
       
       // Renders the editor as well as all menus & toolbars using default styles.
      -function BaseBlockNoteView(
      +function BaseBlockNoteView<
      +  BSchema extends BlockSchema,
      +  ISchema extends InlineContentSchema,
      +  SSchema extends StyleSchema
      +>(
         props: {
      -    editor: BlockNoteEditor;
      +    editor: BlockNoteEditor;
           children?: ReactNode;
         } & HTMLAttributes
       ) {
      @@ -37,16 +47,22 @@ function BaseBlockNoteView(
                 
                 
                 
      -          
      +          {props.editor.blockSchema.table && (
      +            
      +          )}
               
             )}
           
         );
       }
       
      -export function BlockNoteView(
      +export function BlockNoteView<
      +  BSchema extends BlockSchema,
      +  ISchema extends InlineContentSchema,
      +  SSchema extends StyleSchema
      +>(
         props: {
      -    editor: BlockNoteEditor;
      +    editor: BlockNoteEditor;
           theme?:
             | "light"
             | "dark"
      diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx
      index 1af9e4de01..65c5c068a8 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx
      @@ -1,16 +1,25 @@
      -import { useCallback, useMemo, useState } from "react";
      +import {
      +  BlockNoteEditor,
      +  BlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +} from "@blocknote/core";
       import { Menu } from "@mantine/core";
      -import { BlockNoteEditor, BlockSchema } from "@blocknote/core";
      +import { useCallback, useMemo, useState } from "react";
       
      -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton";
       import { ColorIcon } from "../../../SharedComponents/ColorPicker/components/ColorIcon";
       import { ColorPicker } from "../../../SharedComponents/ColorPicker/components/ColorPicker";
      -import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks";
      +import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton";
       import { useEditorChange } from "../../../hooks/useEditorChange";
       import { usePreventMenuOverflow } from "../../../hooks/usePreventMenuOverflow";
      +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks";
       
       export const ColorStyleButton = (props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor<
      +    BSchema,
      +    DefaultInlineContentSchema,
      +    DefaultStyleSchema
      +  >;
       }) => {
         const selectedBlocks = useSelectedBlocks(props.editor);
       
      diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx
      index aabe94e609..cf2dd29e2b 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx
      @@ -16,7 +16,7 @@ import { ToolbarInputDropdownItem } from "../../../SharedComponents/Toolbar/comp
       import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks";
       
       export const ImageCaptionButton = (props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
       }) => {
         const selectedBlocks = useSelectedBlocks(props.editor);
       
      @@ -28,19 +28,19 @@ export const ImageCaptionButton = (props: {
             selectedBlocks[0].type === "image" &&
             // Checks if the block has a `caption` prop which can take any string
             // value.
      -      "caption" in props.editor.schema["image"].config.propSchema &&
      -      typeof props.editor.schema["image"].config.propSchema.caption.default ===
      +      "caption" in props.editor.blockSchema["image"].propSchema &&
      +      typeof props.editor.blockSchema["image"].propSchema.caption.default ===
               "string" &&
      -      props.editor.schema["image"].config.propSchema.caption.values ===
      +      props.editor.blockSchema["image"].propSchema.caption.values ===
               undefined &&
             // Checks if the block has a `url` prop which can take any string value.
      -      "url" in props.editor.schema["image"].config.propSchema &&
      -      typeof props.editor.schema["image"].config.propSchema.url.default ===
      +      "url" in props.editor.blockSchema["image"].propSchema &&
      +      typeof props.editor.blockSchema["image"].propSchema.url.default ===
               "string" &&
      -      props.editor.schema["image"].config.propSchema.url.values === undefined &&
      +      props.editor.blockSchema["image"].propSchema.url.values === undefined &&
             // Checks if the `url` prop is not set to an empty string.
             selectedBlocks[0].props.url !== "",
      -    [props.editor.schema, selectedBlocks]
      +    [props.editor.blockSchema, selectedBlocks]
         );
       
         const [currentCaption, setCurrentCaption] = useState(
      @@ -64,7 +64,7 @@ export const ImageCaptionButton = (props: {
                 props: {
                   caption: currentCaption,
                 },
      -        } as PartialBlock);
      +        } as PartialBlock);
             }
           },
           [currentCaption, props.editor, selectedBlocks]
      diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx
      index cf89ecef97..c56c8ecc30 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx
      @@ -26,7 +26,7 @@ const icons: Record = {
       };
       
       export const TextAlignButton = (props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
         textAlignment: TextAlignment;
       }) => {
         const selectedBlocks = useSelectedBlocks(props.editor);
      @@ -48,7 +48,7 @@ export const TextAlignButton = (props: {
             for (const block of selectedBlocks) {
               props.editor.updateBlock(block, {
                 props: { textAlignment: textAlignment },
      -        } as PartialBlock);
      +        } as PartialBlock);
             }
           },
           [props.editor, selectedBlocks]
      diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx
      index c71824e873..95895d32db 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx
      @@ -1,6 +1,9 @@
      -import { BlockNoteEditor, BlockSchema, ToggledStyle } from "@blocknote/core";
      +import {
      +  BlockNoteEditor,
      +  BlockSchema,
      +  InlineContentSchema,
      +} from "@blocknote/core";
       import { useMemo, useState } from "react";
      -import { IconType } from "react-icons";
       import {
         RiBold,
         RiCodeFill,
      @@ -9,12 +12,13 @@ import {
         RiUnderline,
       } from "react-icons/ri";
       
      +import { StyleSchema } from "@blocknote/core";
       import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton";
       import { useEditorChange } from "../../../hooks/useEditorChange";
       import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks";
       import { formatKeyboardShortcut } from "../../../utils";
       
      -const shortcuts: Record = {
      +const shortcuts = {
         bold: "Mod+B",
         italic: "Mod+I",
         underline: "Mod+U",
      @@ -22,7 +26,7 @@ const shortcuts: Record = {
         code: "",
       };
       
      -const icons: Record = {
      +const icons = {
         bold: RiBold,
         italic: RiItalic,
         underline: RiUnderline,
      @@ -30,9 +34,13 @@ const icons: Record = {
         code: RiCodeFill,
       };
       
      -export const ToggledStyleButton = (props: {
      -  editor: BlockNoteEditor;
      -  toggledStyle: ToggledStyle;
      +export const ToggledStyleButton = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(props: {
      +  editor: BlockNoteEditor;
      +  toggledStyle: keyof typeof shortcuts;
       }) => {
         const selectedBlocks = useSelectedBlocks(props.editor);
       
      @@ -44,9 +52,12 @@ export const ToggledStyleButton = (props: {
           setActive(props.toggledStyle in props.editor.getActiveStyles());
         });
       
      -  const toggleStyle = (style: ToggledStyle) => {
      +  const toggleStyle = (style: typeof props.toggledStyle) => {
           props.editor.focus();
      -    props.editor.toggleStyles({ [style]: true });
      +    if (props.editor.styleSchema[style].propSchema !== "boolean") {
      +      throw new Error("can only toggle boolean styles");
      +    }
      +    props.editor.toggleStyles({ [style]: true } as any);
         };
       
         const show = useMemo(() => {
      diff --git a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx
      index b3f1f621f6..4d15de5be6 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx
      @@ -20,7 +20,7 @@ export type BlockTypeDropdownItem = {
         type: string;
         props?: Record;
         icon: IconType;
      -  isSelected: (block: Block) => boolean;
      +  isSelected: (block: Block) => boolean;
       };
       
       export const defaultBlockTypeDropdownItems: BlockTypeDropdownItem[] = [
      @@ -87,13 +87,13 @@ export const BlockTypeDropdown = (props: {
         const filteredItems: BlockTypeDropdownItem[] = useMemo(() => {
           return (props.items || defaultBlockTypeDropdownItems).filter((item) => {
             // Checks if block type exists in the schema
      -      if (!(item.type in props.editor.schema)) {
      +      if (!(item.type in props.editor.blockSchema)) {
               return false;
             }
       
             // Checks if props for the block type are valid
             for (const [prop, value] of Object.entries(item.props || {})) {
      -        const propSchema = props.editor.schema[item.type].config.propSchema;
      +        const propSchema = props.editor.blockSchema[item.type].propSchema;
       
               // Checks if the prop exists for the block type
               if (!(prop in propSchema)) {
      @@ -134,7 +134,7 @@ export const BlockTypeDropdown = (props: {
             text: item.name,
             icon: item.icon,
             onClick: () => onClick(item),
      -      isSelected: item.isSelected(block as Block),
      +      isSelected: item.isSelected(block as Block),
           }));
         }, [block, filteredItems, props.editor, selectedBlocks]);
       
      diff --git a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx
      index 891c5fc0d5..9441f5e5fa 100644
      --- a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx
      +++ b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx
      @@ -8,8 +8,8 @@ import Tippy, { tippy } from "@tippyjs/react";
       import { FC, useEffect, useMemo, useRef, useState } from "react";
       import { sticky } from "tippy.js";
       
      -import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar";
       import { useEditorChange } from "../../hooks/useEditorChange";
      +import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar";
       
       const textAlignmentToPlacement = (
         textAlignment: DefaultProps["textAlignment"]
      @@ -29,13 +29,13 @@ const textAlignmentToPlacement = (
       export type FormattingToolbarProps<
         BSchema extends BlockSchema = DefaultBlockSchema
       > = {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
       };
       
       export const FormattingToolbarPositioner = <
         BSchema extends BlockSchema = DefaultBlockSchema
       >(props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
         formattingToolbar?: FC>;
       }) => {
         const [show, setShow] = useState(false);
      diff --git a/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx b/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx
      index dc4a1a5f5f..269de59419 100644
      --- a/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx
      +++ b/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx
      @@ -1,14 +1,19 @@
      +import { BlockSchema, InlineContentSchema } from "@blocknote/core";
       import { useRef, useState } from "react";
       import { RiExternalLinkFill, RiLinkUnlink } from "react-icons/ri";
      -import { BlockSchema } from "@blocknote/core";
       
      -import { HyperlinkToolbarProps } from "./HyperlinkToolbarPositioner";
      +import { StyleSchema } from "@blocknote/core";
       import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar";
       import { ToolbarButton } from "../../SharedComponents/Toolbar/components/ToolbarButton";
       import { EditHyperlinkMenu } from "./EditHyperlinkMenu/components/EditHyperlinkMenu";
      +import { HyperlinkToolbarProps } from "./HyperlinkToolbarPositioner";
       
      -export const DefaultHyperlinkToolbar = (
      -  props: HyperlinkToolbarProps
      +export const DefaultHyperlinkToolbar = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  props: HyperlinkToolbarProps
       ) => {
         const [isEditing, setIsEditing] = useState(false);
         const editMenuRef = useRef(null);
      diff --git a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx
      index 66b76706ce..6890e5df61 100644
      --- a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx
      +++ b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx
      @@ -3,25 +3,35 @@ import {
         BlockNoteEditor,
         BlockSchema,
         DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
         HyperlinkToolbarProsemirrorPlugin,
         HyperlinkToolbarState,
      +  InlineContentSchema,
       } from "@blocknote/core";
       import Tippy from "@tippyjs/react";
       import { FC, useEffect, useMemo, useRef, useState } from "react";
       
      +import { StyleSchema } from "@blocknote/core";
       import { DefaultHyperlinkToolbar } from "./DefaultHyperlinkToolbar";
       
      -export type HyperlinkToolbarProps = Pick<
      -  HyperlinkToolbarProsemirrorPlugin,
      +export type HyperlinkToolbarProps<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = Pick<
      +  HyperlinkToolbarProsemirrorPlugin,
         "editHyperlink" | "deleteHyperlink" | "startHideTimer" | "stopHideTimer"
       > &
         Omit;
       
       export const HyperlinkToolbarPositioner = <
      -  BSchema extends BlockSchema = DefaultBlockSchema
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema,
      +  S extends StyleSchema = DefaultStyleSchema
       >(props: {
      -  editor: BlockNoteEditor;
      -  hyperlinkToolbar?: FC>;
      +  editor: BlockNoteEditor;
      +  hyperlinkToolbar?: FC>;
       }) => {
         const [show, setShow] = useState(false);
         const [url, setUrl] = useState();
      diff --git a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx b/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx
      index d04226f116..49260e289a 100644
      --- a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx
      +++ b/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx
      @@ -1,14 +1,5 @@
       import { BlockSchema, PartialBlock } from "@blocknote/core";
       
      -import { ImageToolbarProps } from "./ImageToolbarPositioner";
      -import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar";
      -import {
      -  ChangeEvent,
      -  KeyboardEvent,
      -  useCallback,
      -  useEffect,
      -  useState,
      -} from "react";
       import {
         Button,
         FileInput,
      @@ -17,9 +8,18 @@ import {
         Text,
         TextInput,
       } from "@mantine/core";
      +import {
      +  ChangeEvent,
      +  KeyboardEvent,
      +  useCallback,
      +  useEffect,
      +  useState,
      +} from "react";
      +import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar";
      +import { ImageToolbarProps } from "./ImageToolbarPositioner";
       
       export const DefaultImageToolbar = (
      -  props: ImageToolbarProps
      +  props: ImageToolbarProps
       ) => {
         const [openTab, setOpenTab] = useState<"upload" | "embed">(
           props.editor.uploadFile !== undefined ? "upload" : "embed"
      @@ -46,7 +46,7 @@ export const DefaultImageToolbar = (
                   props: {
                     url: uploaded,
                   },
      -          } as PartialBlock);
      +          } as PartialBlock);
               } catch (e) {
                 setUploadFailed(true);
               } finally {
      @@ -75,7 +75,7 @@ export const DefaultImageToolbar = (
                 props: {
                   url: currentURL,
                 },
      -        } as PartialBlock);
      +        } as PartialBlock);
             }
           },
           [currentURL, props.block, props.editor]
      @@ -87,7 +87,7 @@ export const DefaultImageToolbar = (
             props: {
               url: currentURL,
             },
      -    } as PartialBlock);
      +    } as PartialBlock);
         }, [currentURL, props.block, props.editor]);
       
         return (
      diff --git a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx
      index ccc677759f..7bcfdd8615 100644
      --- a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx
      +++ b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx
      @@ -1,10 +1,12 @@
       import {
         BaseUiElementState,
      -  Block,
         BlockNoteEditor,
         BlockSchema,
         DefaultBlockSchema,
      +  DefaultInlineContentSchema,
         ImageToolbarState,
      +  InlineContentSchema,
      +  SpecificBlock,
       } from "@blocknote/core";
       import Tippy, { tippy } from "@tippyjs/react";
       import { FC, useEffect, useMemo, useRef, useState } from "react";
      @@ -12,20 +14,21 @@ import { FC, useEffect, useMemo, useRef, useState } from "react";
       import { DefaultImageToolbar } from "./DefaultImageToolbar";
       
       export type ImageToolbarProps<
      -  BSchema extends BlockSchema = DefaultBlockSchema
      -> = Omit & {
      -  editor: BlockNoteEditor;
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema
      +> = Omit, keyof BaseUiElementState> & {
      +  editor: BlockNoteEditor;
       };
       
       export const ImageToolbarPositioner = <
      -  BSchema extends BlockSchema = DefaultBlockSchema
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema
       >(props: {
      -  editor: BlockNoteEditor;
      -  imageToolbar?: FC>;
      +  editor: BlockNoteEditor;
      +  imageToolbar?: FC>;
       }) => {
         const [show, setShow] = useState(false);
      -  const [block, setBlock] =
      -    useState>();
      +  const [block, setBlock] = useState>();
       
         const referencePos = useRef();
       
      diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx
      index ea992828af..b5f48dc2df 100644
      --- a/packages/react/src/ReactBlockSpec.tsx
      +++ b/packages/react/src/ReactBlockSpec.tsx
      @@ -1,6 +1,5 @@
       import {
      -  Block,
      -  BlockNoteDOMAttributes,
      +  BlockFromConfig,
         BlockNoteEditor,
         BlockSchemaWithBlock,
         camelToDataKebab,
      @@ -8,12 +7,15 @@ import {
         createStronglyTypedTiptapNode,
         CustomBlockConfig,
         getBlockFromPos,
      +  getParseRules,
         inheritedProps,
      +  InlineContentSchema,
         mergeCSSClasses,
      -  parse,
      +  PartialBlockFromConfig,
         Props,
         PropSchema,
         propsToAttributes,
      +  StyleSchema,
       } from "@blocknote/core";
       import {
         NodeViewContent,
      @@ -21,48 +23,28 @@ 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
       
       // extend BlockConfig but use a React render function
      -export type ReactCustomBlockImplementation = {
      +export type ReactCustomBlockImplementation<
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
         render: FC<{
      -    block: Block;
      -    editor: BlockNoteEditor>;
      +    block: BlockFromConfig;
      +    editor: BlockNoteEditor, I, S>;
      +    contentRef: (node: HTMLElement | null) => void;
         }>;
         toExternalHTML?: FC<{
      -    block: Block;
      -    editor: BlockNoteEditor>;
      +    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
      @@ -114,9 +96,13 @@ export function reactWrapInBlockStructure<
       
       // 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 createReactBlockSpec(
      +export function createReactBlockSpec<
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
         blockConfig: T,
      -  blockImplementation: ReactCustomBlockImplementation
      +  blockImplementation: ReactCustomBlockImplementation
       ) {
         const node = createStronglyTypedTiptapNode({
           name: blockConfig.type as T["type"],
      @@ -127,11 +113,11 @@ export function createReactBlockSpec(
           selectable: true,
       
           addAttributes() {
      -      return propsToAttributes(blockConfig);
      +      return propsToAttributes(blockConfig.propSchema);
           },
       
           parseHTML() {
      -      return parse(blockConfig);
      +      return getParseRules(blockConfig, blockImplementation.parse);
           },
       
           addNodeView() {
      @@ -151,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,
      @@ -176,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
      new file mode 100644
      index 0000000000..6d598990e0
      --- /dev/null
      +++ b/packages/react/src/ReactInlineContentSpec.tsx
      @@ -0,0 +1,171 @@
      +import {
      +  CustomInlineContentConfig,
      +  InlineContentConfig,
      +  InlineContentFromConfig,
      +  PropSchema,
      +  Props,
      +  StyleSchema,
      +  addInlineContentAttributes,
      +  camelToDataKebab,
      +  createInternalInlineContentSpec,
      +  createStronglyTypedTiptapNode,
      +  getInlineContentParseRules,
      +  nodeToCustomInlineContent,
      +  propsToAttributes,
      +} from "@blocknote/core";
      +import {
      +  NodeViewContent,
      +  NodeViewProps,
      +  NodeViewWrapper,
      +  ReactNodeViewRenderer,
      +} from "@tiptap/react";
      +// import { useReactNodeView } from "@tiptap/react/dist/packages/react/src/useReactNodeView";
      +import { FC } from "react";
      +import { renderToDOMSpec } from "./ReactRenderUtil";
      +
      +// this file is mostly analogoues to `customBlocks.ts`, but for React blocks
      +
      +// extend BlockConfig but use a React render function
      +export type ReactInlineContentImplementation<
      +  T extends InlineContentConfig,
      +  // I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  render: FC<{
      +    inlineContent: InlineContentFromConfig;
      +    contentRef: (node: HTMLElement | null) => void;
      +  }>;
      +  // TODO?
      +  // toExternalHTML?: FC<{
      +  //   block: BlockFromConfig;
      +  //   editor: BlockNoteEditor, I, S>;
      +  // }>;
      +};
      +
      +// 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<
      +  T extends CustomInlineContentConfig,
      +  // I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  inlineContentConfig: T,
      +  inlineContentImplementation: ReactInlineContentImplementation
      +) {
      +  const node = createStronglyTypedTiptapNode({
      +    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*" : "",
      +
      +    addAttributes() {
      +      return propsToAttributes(inlineContentConfig.propSchema);
      +    },
      +
      +    parseHTML() {
      +      return getInlineContentParseRules(inlineContentConfig);
      +    },
      +
      +    renderHTML({ node }) {
      +      const editor = this.options.editor;
      +
      +      const ic = nodeToCustomInlineContent(
      +        node,
      +        editor.inlineContentSchema,
      +        editor.styleSchema
      +      ) as any as InlineContentFromConfig; // TODO: fix cast
      +      const Content = inlineContentImplementation.render;
      +      const output = renderToDOMSpec((refCB) => (
      +        
      +      ));
      +
      +      return {
      +        dom: addInlineContentAttributes(
      +          output.dom,
      +          inlineContentConfig.type,
      +          node.attrs as Props,
      +          inlineContentConfig.propSchema
      +        ),
      +        contentDOM: output.contentDOM,
      +      };
      +    },
      +
      +    // TODO: needed?
      +    addNodeView() {
      +      const editor = this.options.editor;
      +
      +      return (props) =>
      +        ReactNodeViewRenderer(
      +          (props: NodeViewProps) => {
      +            // hacky, should export `useReactNodeView` from tiptap to get access to ref
      +            const ref = (NodeViewContent({}) as any).ref;
      +
      +            const Content = inlineContentImplementation.render;
      +            const FullContent = reactWrapInInlineContentStructure(
      +               // TODO: fix cast
      +                }
      +              />,
      +              inlineContentConfig.type,
      +              props.node.attrs as Props,
      +              inlineContentConfig.propSchema
      +            );
      +            return ;
      +          },
      +          {
      +            className: "bn-ic-react-node-view-renderer",
      +            as: "span",
      +            // contentDOMElementTag: "span", (requires tt upgrade)
      +          }
      +        )(props);
      +    },
      +  });
      +
      +  return createInternalInlineContentSpec(inlineContentConfig, {
      +    node: node,
      +  } as any);
      +}
      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
      new file mode 100644
      index 0000000000..cb401850b7
      --- /dev/null
      +++ b/packages/react/src/ReactStyleSpec.tsx
      @@ -0,0 +1,65 @@
      +import {
      +  addStyleAttributes,
      +  createInternalStyleSpec,
      +  getStyleParseRules,
      +  StyleConfig,
      +  stylePropsToAttributes,
      +} from "@blocknote/core";
      +import { Mark } from "@tiptap/react";
      +import { FC } from "react";
      +import { renderToDOMSpec } from "./ReactRenderUtil";
      +
      +// this file is mostly analogoues to `customBlocks.ts`, but for React blocks
      +
      +// extend BlockConfig but use a React render function
      +export type ReactCustomStyleImplementation = {
      +  render: T["propSchema"] extends "boolean"
      +    ? FC<{ contentRef: (el: HTMLElement | null) => void }>
      +    : FC<{ contentRef: (el: HTMLElement | null) => void; value: string }>;
      +};
      +
      +// 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 createReactStyleSpec(
      +  styleConfig: T,
      +  styleImplementation: ReactCustomStyleImplementation
      +) {
      +  const mark = Mark.create({
      +    name: styleConfig.type,
      +
      +    addAttributes() {
      +      return stylePropsToAttributes(styleConfig.propSchema);
      +    },
      +
      +    parseHTML() {
      +      return getStyleParseRules(styleConfig);
      +    },
      +
      +    renderHTML({ mark }) {
      +      const props: any = {};
      +
      +      if (styleConfig.propSchema === "string") {
      +        props.value = mark.attrs.stringValue;
      +      }
      +
      +      const Content = styleImplementation.render;
      +      const renderResult = renderToDOMSpec((refCB) => (
      +        
      +      ));
      +
      +      return {
      +        dom: addStyleAttributes(
      +          renderResult.dom,
      +          styleConfig.type,
      +          mark.attrs.stringValue,
      +          styleConfig.propSchema
      +        ),
      +        contentDOM: renderResult.contentDOM,
      +      };
      +    },
      +  });
      +
      +  return createInternalStyleSpec(styleConfig, {
      +    mark,
      +  });
      +}
      diff --git a/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx b/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx
      index aa4985d700..0dadb3aac2 100644
      --- a/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx
      +++ b/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx
      @@ -1,10 +1,10 @@
      +import { BlockSchema } from "@blocknote/core";
       import { AiOutlinePlus } from "react-icons/ai";
       import { SideMenuButton } from "../SideMenuButton";
       import { SideMenuProps } from "../SideMenuPositioner";
      -import { BlockSchema } from "@blocknote/core";
       
       export const AddBlockButton = (
      -  props: SideMenuProps
      +  props: SideMenuProps
       ) => (
         
           (
      -  props: SideMenuProps
      +  props: SideMenuProps
       ) => {
         const DragHandleMenu = props.dragHandleMenu || DefaultDragHandleMenu;
       
      diff --git a/packages/react/src/SideMenu/components/DefaultSideMenu.tsx b/packages/react/src/SideMenu/components/DefaultSideMenu.tsx
      index 46623a9c12..1ae3fcb99e 100644
      --- a/packages/react/src/SideMenu/components/DefaultSideMenu.tsx
      +++ b/packages/react/src/SideMenu/components/DefaultSideMenu.tsx
      @@ -1,12 +1,17 @@
      -import { BlockSchema } from "@blocknote/core";
      +import { BlockSchema, InlineContentSchema } from "@blocknote/core";
       
      -import { SideMenuProps } from "./SideMenuPositioner";
      -import { SideMenu } from "./SideMenu";
      +import { StyleSchema } from "@blocknote/core";
       import { AddBlockButton } from "./DefaultButtons/AddBlockButton";
       import { DragHandle } from "./DefaultButtons/DragHandle";
      +import { SideMenu } from "./SideMenu";
      +import { SideMenuProps } from "./SideMenuPositioner";
       
      -export const DefaultSideMenu = (
      -  props: SideMenuProps
      +export const DefaultSideMenu = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  props: SideMenuProps
       ) => (
         
           
      diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx
      index f8ea41fa56..e198ebf46e 100644
      --- a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx
      +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx
      @@ -1,21 +1,21 @@
      -import { ReactNode, useCallback, useRef, useState } from "react";
      +import { BlockSchema, PartialBlock } from "@blocknote/core";
       import { Box, Menu } from "@mantine/core";
      +import { ReactNode, useCallback, useRef, useState } from "react";
       import { HiChevronRight } from "react-icons/hi";
      -import { BlockSchema, PartialBlock } from "@blocknote/core";
       
      -import { DragHandleMenuProps } from "../DragHandleMenu";
      -import { DragHandleMenuItem } from "../DragHandleMenuItem";
       import { ColorPicker } from "../../../../SharedComponents/ColorPicker/components/ColorPicker";
       import { usePreventMenuOverflow } from "../../../../hooks/usePreventMenuOverflow";
      +import { DragHandleMenuProps } from "../DragHandleMenu";
      +import { DragHandleMenuItem } from "../DragHandleMenuItem";
       
       export const BlockColorsButton = (
      -  props: DragHandleMenuProps & { children: ReactNode }
      +  props: DragHandleMenuProps & { children: ReactNode }
       ) => {
         const [opened, setOpened] = useState(false);
       
         const { ref, updateMaxHeight } = usePreventMenuOverflow();
       
      -  const menuCloseTimer = useRef();
      +  const menuCloseTimer = useRef | undefined>();
       
         const startMenuCloseTimer = useCallback(() => {
           if (menuCloseTimer.current) {
      @@ -73,7 +73,7 @@ export const BlockColorsButton = (
                             setColor: (color) =>
                               props.editor.updateBlock(props.block, {
                                 props: { textColor: color },
      -                        } as PartialBlock),
      +                        } as PartialBlock),
                           }
                         : undefined
                     }
      @@ -85,7 +85,7 @@ export const BlockColorsButton = (
                             setColor: (color) =>
                               props.editor.updateBlock(props.block, {
                                 props: { backgroundColor: color },
      -                        } as PartialBlock),
      +                        } as PartialBlock),
                           }
                         : undefined
                     }
      diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx
      index bbd5e2331c..1b05fff510 100644
      --- a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx
      +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx
      @@ -1,11 +1,11 @@
      -import { ReactNode } from "react";
       import { BlockSchema } from "@blocknote/core";
      +import { ReactNode } from "react";
       
       import { DragHandleMenuProps } from "../DragHandleMenu";
       import { DragHandleMenuItem } from "../DragHandleMenuItem";
       
       export const RemoveBlockButton = (
      -  props: DragHandleMenuProps & { children: ReactNode }
      +  props: DragHandleMenuProps & { children: ReactNode }
       ) => {
         return (
           (
      -  props: DragHandleMenuProps
      +  props: DragHandleMenuProps
       ) => (
         
           Delete
      diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx
      index b67dd98836..be806fd5a1 100644
      --- a/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx
      +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx
      @@ -1,10 +1,20 @@
      +import {
      +  Block,
      +  BlockNoteEditor,
      +  BlockSchema,
      +  InlineContentSchema,
      +  StyleSchema,
      +} from "@blocknote/core";
      +import { Menu, createStyles } from "@mantine/core";
       import { ReactNode } from "react";
      -import { createStyles, Menu } from "@mantine/core";
      -import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core";
       
      -export type DragHandleMenuProps = {
      -  editor: BlockNoteEditor;
      -  block: Block;
      +export type DragHandleMenuProps<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  editor: BlockNoteEditor;
      +  block: Block;
       };
       
       export const DragHandleMenu = (props: { children: ReactNode }) => {
      diff --git a/packages/react/src/SideMenu/components/SideMenuPositioner.tsx b/packages/react/src/SideMenu/components/SideMenuPositioner.tsx
      index c77caa6cea..b0c94d7b71 100644
      --- a/packages/react/src/SideMenu/components/SideMenuPositioner.tsx
      +++ b/packages/react/src/SideMenu/components/SideMenuPositioner.tsx
      @@ -3,36 +3,41 @@ import {
         BlockNoteEditor,
         BlockSchema,
         DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +  InlineContentSchema,
         SideMenuProsemirrorPlugin,
       } from "@blocknote/core";
       import Tippy from "@tippyjs/react";
       import { FC, useEffect, useMemo, useRef, useState } from "react";
       
      +import { StyleSchema } from "@blocknote/core";
       import { DefaultSideMenu } from "./DefaultSideMenu";
       import { DragHandleMenuProps } from "./DragHandleMenu/DragHandleMenu";
       
      -export type SideMenuProps =
      -  Pick<
      -    SideMenuProsemirrorPlugin,
      -    | "blockDragStart"
      -    | "blockDragEnd"
      -    | "addBlock"
      -    | "freezeMenu"
      -    | "unfreezeMenu"
      -  > & {
      -    block: Block;
      -    editor: BlockNoteEditor;
      -    dragHandleMenu?: FC>;
      -  };
      +export type SideMenuProps<
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema,
      +  S extends StyleSchema = DefaultStyleSchema
      +> = Pick<
      +  SideMenuProsemirrorPlugin,
      +  "blockDragStart" | "blockDragEnd" | "addBlock" | "freezeMenu" | "unfreezeMenu"
      +> & {
      +  block: Block;
      +  editor: BlockNoteEditor;
      +  dragHandleMenu?: FC>;
      +};
       
       export const SideMenuPositioner = <
      -  BSchema extends BlockSchema = DefaultBlockSchema
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema,
      +  S extends StyleSchema = DefaultStyleSchema
       >(props: {
      -  editor: BlockNoteEditor;
      -  sideMenu?: FC>;
      +  editor: BlockNoteEditor;
      +  sideMenu?: FC>;
       }) => {
         const [show, setShow] = useState(false);
      -  const [block, setBlock] = useState>();
      +  const [block, setBlock] = useState>();
       
         const referencePos = useRef();
       
      diff --git a/packages/react/src/SlashMenu/ReactSlashMenuItem.ts b/packages/react/src/SlashMenu/ReactSlashMenuItem.ts
      index b5e6f24091..65ceb044f7 100644
      --- a/packages/react/src/SlashMenu/ReactSlashMenuItem.ts
      +++ b/packages/react/src/SlashMenu/ReactSlashMenuItem.ts
      @@ -2,11 +2,17 @@ import {
         BaseSlashMenuItem,
         BlockSchema,
         DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +  InlineContentSchema,
      +  StyleSchema,
       } from "@blocknote/core";
       
       export type ReactSlashMenuItem<
      -  BSchema extends BlockSchema = DefaultBlockSchema
      -> = BaseSlashMenuItem & {
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema,
      +  S extends StyleSchema = DefaultStyleSchema
      +> = BaseSlashMenuItem & {
         group: string;
         icon: JSX.Element;
         hint?: string;
      diff --git a/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx b/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx
      index e3e005b68e..6c084ad245 100644
      --- a/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx
      +++ b/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx
      @@ -8,12 +8,12 @@ import {
       import Tippy from "@tippyjs/react";
       import { FC, useEffect, useMemo, useRef, useState } from "react";
       
      +import { usePreventMenuOverflow } from "../../hooks/usePreventMenuOverflow";
       import { ReactSlashMenuItem } from "../ReactSlashMenuItem";
       import { DefaultSlashMenu } from "./DefaultSlashMenu";
      -import { usePreventMenuOverflow } from "../../hooks/usePreventMenuOverflow";
       
       export type SlashMenuProps =
      -  Pick, "itemCallback"> &
      +  Pick, "itemCallback"> &
           Pick<
             SuggestionsMenuState>,
             "filteredItems" | "keyboardHoveredItemIndex"
      @@ -22,7 +22,7 @@ export type SlashMenuProps =
       export const SlashMenuPositioner = <
         BSchema extends BlockSchema = DefaultBlockSchema
       >(props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
         slashMenu?: FC>;
       }) => {
         const [show, setShow] = useState(false);
      diff --git a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx b/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx
      index 73b9628607..66411dda62 100644
      --- a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx
      +++ b/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx
      @@ -4,6 +4,8 @@ import {
         defaultBlockSchema,
         DefaultBlockSchema,
         getDefaultSlashMenuItems,
      +  InlineContentSchema,
      +  StyleSchema,
       } from "@blocknote/core";
       import {
         RiH1,
      @@ -22,7 +24,7 @@ const extraFields: Record<
         string,
         Omit<
           ReactSlashMenuItem,
      -    keyof BaseSlashMenuItem
      +    keyof BaseSlashMenuItem
         >
       > = {
         Heading: {
      @@ -74,14 +76,18 @@ const extraFields: Record<
         },
       };
       
      -export function getDefaultReactSlashMenuItems(
      +export function getDefaultReactSlashMenuItems<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
         // This type casting is weird, but it's the best way of doing it, as it allows
         // the schema type to be automatically inferred if it is defined, or be
         // inferred as any if it is not defined. I don't think it's possible to make it
         // infer to DefaultBlockSchema if it is not defined.
      -  schema: BSchema = defaultBlockSchema as unknown as BSchema
      -): ReactSlashMenuItem[] {
      -  const slashMenuItems: BaseSlashMenuItem[] =
      +  schema: BSchema = defaultBlockSchema as any as BSchema
      +): ReactSlashMenuItem[] {
      +  const slashMenuItems: BaseSlashMenuItem[] =
           getDefaultSlashMenuItems(schema);
       
         return slashMenuItems.map((item) => ({
      diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx
      index ea62c15cd7..e7e98d4f1b 100644
      --- a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx
      +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx
      @@ -1,10 +1,12 @@
      -import { BlockSchema } from "@blocknote/core";
      +import { BlockSchemaWithBlock, DefaultBlockSchema } from "@blocknote/core";
       import { MdDragIndicator } from "react-icons/md";
      -import { TableHandleProps } from "./TableHandlePositioner";
       import { TableHandle } from "./TableHandle";
      +import { TableHandleProps } from "./TableHandlePositioner";
       
      -export const DefaultTableHandle = (
      -  props: TableHandleProps
      +export const DefaultTableHandle = <
      +  BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>
      +>(
      +  props: TableHandleProps
       ) => (
         
           
      ( - props: TableHandleProps & { children: ReactNode } +export const TableHandle = < + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> +>( + props: TableHandleProps & { children: ReactNode } ) => { const { classes } = createStyles({ root: {} })(undefined, { name: "TableHandle", diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx index c24350d73e..46d587e0cb 100644 --- a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx @@ -3,8 +3,8 @@ import { PartialBlock, TableContent, } from "@blocknote/core"; -import { TableHandleMenuItem } from "../TableHandleMenuItem"; import { TableHandleMenuProps } from "../TableHandleMenu"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; export const AddRowButton = < BSchema extends { table: DefaultBlockSchema["table"] } @@ -26,7 +26,7 @@ export const AddRowButton = < content: { rows, }, - } as PartialBlock); + } as PartialBlock); }}> {`Add row ${props.side}`} @@ -39,7 +39,7 @@ export const AddColumnButton = < ) => ( { - const content: TableContent = { + const content: TableContent = { type: "tableContent", rows: props.block.content.rows.map((row) => { const cells = [...row.cells]; @@ -51,7 +51,7 @@ export const AddColumnButton = < props.editor.updateBlock(props.block, { type: "table", content: content, - } as PartialBlock); + } as PartialBlock); }}> {`Add column ${props.side}`} diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx index e314a617b3..2a8af93c37 100644 --- a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx @@ -3,8 +3,8 @@ import { PartialBlock, TableContent, } from "@blocknote/core"; -import { TableHandleMenuItem } from "../TableHandleMenuItem"; import { TableHandleMenuProps } from "../TableHandleMenu"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; export const DeleteRowButton = < BSchema extends { table: DefaultBlockSchema["table"] } @@ -13,7 +13,7 @@ export const DeleteRowButton = < ) => ( { - const content: TableContent = { + const content: TableContent = { type: "tableContent", rows: props.block.content.rows.filter( (_, index) => index !== props.index @@ -23,7 +23,7 @@ export const DeleteRowButton = < props.editor.updateBlock(props.block, { type: "table", content, - } as PartialBlock); + } as PartialBlock); }}> Delete row @@ -36,7 +36,7 @@ export const DeleteColumnButton = < ) => ( { - const content: TableContent = { + const content: TableContent = { type: "tableContent", rows: props.block.content.rows.map((row) => ({ cells: row.cells.filter((_, index) => index !== props.index), @@ -46,7 +46,7 @@ export const DeleteColumnButton = < props.editor.updateBlock(props.block, { type: "table", content, - } as PartialBlock); + } as PartialBlock); }}> Delete column diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx index 682ad515b2..24e649aa5c 100644 --- a/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx +++ b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx @@ -1,13 +1,22 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + SpecificBlock, +} from "@blocknote/core"; +import { Menu, createStyles } from "@mantine/core"; import { ReactNode } from "react"; -import { Block, BlockNoteEditor, DefaultBlockSchema } from "@blocknote/core"; -import { createStyles, Menu } from "@mantine/core"; export type TableHandleMenuProps< BSchema extends { table: DefaultBlockSchema["table"] } > = { orientation: "row" | "column"; - editor: BlockNoteEditor; - block: Block; + editor: BlockNoteEditor; + block: SpecificBlock< + { table: DefaultBlockSchema["table"] }, + "table", + any, + any + >; index: number; }; diff --git a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx index ab8c2e2136..78f6102a36 100644 --- a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx +++ b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx @@ -1,56 +1,60 @@ -import { DragEvent, FC, useEffect, useMemo, useRef, useState } from "react"; import { - Block, + BlockFromConfigNoChildren, BlockNoteEditor, - BlockSchema, BlockSchemaWithBlock, DefaultBlockSchema, + InlineContentSchema, + StyleSchema, TableHandlesProsemirrorPlugin, TableHandlesState, } from "@blocknote/core"; import Tippy, { tippy } from "@tippyjs/react"; +import { DragEvent, FC, useEffect, useMemo, useRef, useState } from "react"; import { DragHandleMenuProps } from "../../SideMenu/components/DragHandleMenu/DragHandleMenu"; import { DefaultTableHandle } from "./DefaultTableHandle"; -export type TableHandleProps = - Pick< - TableHandlesProsemirrorPlugin, - "dragEnd" | "freezeHandles" | "unfreezeHandles" - > & - Omit< - TableHandlesState, - | "rowIndex" - | "colIndex" - | "referencePosCell" - | "referencePosTable" - | "show" - | "draggingState" - > & { - orientation: "row" | "column"; - editor: BlockNoteEditor< - BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]["config"]> - >; - tableHandleMenu?: FC>; - dragStart: (e: DragEvent) => void; - index: number; - showOtherSide: () => void; - hideOtherSide: () => void; - }; +export type TableHandleProps< + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema +> = Pick< + TableHandlesProsemirrorPlugin, + "dragEnd" | "freezeHandles" | "unfreezeHandles" +> & + Omit< + TableHandlesState, + | "rowIndex" + | "colIndex" + | "referencePosCell" + | "referencePosTable" + | "show" + | "draggingState" + > & { + orientation: "row" | "column"; + editor: BlockNoteEditor< + BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> + >; + tableHandleMenu?: FC>; + dragStart: (e: DragEvent) => void; + index: number; + showOtherSide: () => void; + hideOtherSide: () => void; + }; export const TableHandlesPositioner = < - BSchema extends BlockSchemaWithBlock< - "table", - DefaultBlockSchema["table"]["config"] - > + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema >(props: { - editor: BlockNoteEditor; - tableHandle?: FC>; + editor: BlockNoteEditor; + tableHandle?: FC>; }) => { const [show, setShow] = useState(false); const [hideRow, setHideRow] = useState(false); const [hideCol, setHideCol] = useState(false); const [block, setBlock] = - useState>(); + useState>(); + const [rowIndex, setRowIndex] = useState(); const [colIndex, setColIndex] = useState(); @@ -67,7 +71,7 @@ export const TableHandlesPositioner = < useEffect(() => { tippy.setDefaultProps({ maxWidth: "" }); - return props.editor.tableHandles.onUpdate((state) => { + return props.editor.tableHandles!.onUpdate((state) => { // console.log("update", state.draggingState); setShow(state.show); setBlock(state.block); @@ -153,10 +157,10 @@ export const TableHandlesPositioner = < editor={props.editor as any} index={colIndex!} block={block!} - dragStart={props.editor.tableHandles.colDragStart} - dragEnd={props.editor.tableHandles.dragEnd} - freezeHandles={props.editor.tableHandles.freezeHandles} - unfreezeHandles={props.editor.tableHandles.unfreezeHandles} + dragStart={props.editor.tableHandles!.colDragStart} + dragEnd={props.editor.tableHandles!.dragEnd} + freezeHandles={props.editor.tableHandles!.freezeHandles} + unfreezeHandles={props.editor.tableHandles!.unfreezeHandles} showOtherSide={() => setHideRow(false)} hideOtherSide={() => setHideRow(true)} /> @@ -172,10 +176,10 @@ export const TableHandlesPositioner = < editor={props.editor as any} index={rowIndex!} block={block!} - dragStart={props.editor.tableHandles.rowDragStart} - dragEnd={props.editor.tableHandles.dragEnd} - freezeHandles={props.editor.tableHandles.freezeHandles} - unfreezeHandles={props.editor.tableHandles.unfreezeHandles} + dragStart={props.editor.tableHandles!.rowDragStart} + dragEnd={props.editor.tableHandles!.dragEnd} + freezeHandles={props.editor.tableHandles!.freezeHandles} + unfreezeHandles={props.editor.tableHandles!.unfreezeHandles} showOtherSide={() => setHideCol(false)} hideOtherSide={() => setHideCol(true)} /> diff --git a/packages/react/src/__snapshots__/reactCustomParagraph/nested/internal.html b/packages/react/src/__snapshots__/reactCustomParagraph/nested/internal.html deleted file mode 100644 index 22dd233fa1..0000000000 --- a/packages/react/src/__snapshots__/reactCustomParagraph/nested/internal.html +++ /dev/null @@ -1 +0,0 @@ -

      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/__snapshots__/reactCustomParagraph/styled/internal.html b/packages/react/src/__snapshots__/reactCustomParagraph/styled/internal.html deleted file mode 100644 index ec4f7f99a2..0000000000 --- a/packages/react/src/__snapshots__/reactCustomParagraph/styled/internal.html +++ /dev/null @@ -1 +0,0 @@ -

      Plain Red Text Blue Background Mixed Colors

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

      React Custom Paragraph

      \ No newline at end of file diff --git a/packages/react/src/__snapshots__/simpleReactCustomParagraph/nested/external.html b/packages/react/src/__snapshots__/simpleReactCustomParagraph/nested/external.html deleted file mode 100644 index a61e824d02..0000000000 --- a/packages/react/src/__snapshots__/simpleReactCustomParagraph/nested/external.html +++ /dev/null @@ -1 +0,0 @@ -

      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/__snapshots__/simpleReactCustomParagraph/nested/internal.html b/packages/react/src/__snapshots__/simpleReactCustomParagraph/nested/internal.html deleted file mode 100644 index 5ce1aa3e93..0000000000 --- a/packages/react/src/__snapshots__/simpleReactCustomParagraph/nested/internal.html +++ /dev/null @@ -1 +0,0 @@ -

      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/__snapshots__/simpleReactCustomParagraph/styled/external.html b/packages/react/src/__snapshots__/simpleReactCustomParagraph/styled/external.html deleted file mode 100644 index 816f2ca547..0000000000 --- a/packages/react/src/__snapshots__/simpleReactCustomParagraph/styled/external.html +++ /dev/null @@ -1 +0,0 @@ -

      Plain Red Text Blue Background Mixed Colors

      \ No newline at end of file diff --git a/packages/react/src/hooks/useActiveStyles.ts b/packages/react/src/hooks/useActiveStyles.ts new file mode 100644 index 0000000000..93e4ad41ac --- /dev/null +++ b/packages/react/src/hooks/useActiveStyles.ts @@ -0,0 +1,22 @@ +import { BlockNoteEditor, StyleSchema } from "@blocknote/core"; +import { useState } from "react"; +import { useEditorContentChange } from "./useEditorContentChange"; +import { useEditorSelectionChange } from "./useEditorSelectionChange"; + +export function useActiveStyles( + editor: BlockNoteEditor +) { + const [styles, setStyles] = useState(() => editor.getActiveStyles()); + + // Updates state on editor content change. + useEditorContentChange(editor, () => { + setStyles(editor.getActiveStyles()); + }); + + // Updates state on selection change. + useEditorSelectionChange(editor, () => { + setStyles(editor.getActiveStyles()); + }); + + return styles; +} diff --git a/packages/react/src/hooks/useBlockNote.ts b/packages/react/src/hooks/useBlockNote.ts index 39a8dc238f..d9282a654c 100644 --- a/packages/react/src/hooks/useBlockNote.ts +++ b/packages/react/src/hooks/useBlockNote.ts @@ -1,31 +1,53 @@ import { BlockNoteEditor, BlockNoteEditorOptions, - BlockSchema, - defaultBlockSchema, - DefaultBlockSchema, + BlockSchemaFromSpecs, + BlockSpecs, + InlineContentSchemaFromSpecs, + InlineContentSpecs, + StyleSchemaFromSpecs, + StyleSpecs, + defaultBlockSpecs, + defaultInlineContentSpecs, + defaultStyleSpecs, + getBlockSchemaFromSpecs, } from "@blocknote/core"; import { DependencyList, useMemo, useRef } from "react"; import { getDefaultReactSlashMenuItems } from "../SlashMenu/defaultReactSlashMenuItems"; -const initEditor = ( - options: Partial> +const initEditor = < + BSpecs extends BlockSpecs, + ISpecs extends InlineContentSpecs, + SSpecs extends StyleSpecs +>( + options: Partial> ) => - new BlockNoteEditor({ - slashMenuItems: getDefaultReactSlashMenuItems( - options.blockSchema || defaultBlockSchema - ), + BlockNoteEditor.create({ + slashMenuItems: getDefaultReactSlashMenuItems( + getBlockSchemaFromSpecs(options.blockSpecs || defaultBlockSpecs) + ) as any, ...options, }); /** * Main hook for importing a BlockNote editor into a React project */ -export const useBlockNote = ( - options: Partial> = {}, +export const useBlockNote = < + BSpecs extends BlockSpecs = typeof defaultBlockSpecs, + ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs, + SSpecs extends StyleSpecs = typeof defaultStyleSpecs +>( + options: Partial> = {}, deps: DependencyList = [] -): BlockNoteEditor => { - const editorRef = useRef>(); +) => { + const editorRef = + useRef< + BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + >(); return useMemo(() => { if (editorRef.current) { @@ -33,6 +55,6 @@ export const useBlockNote = ( } editorRef.current = initEditor(options); - return editorRef.current; + return editorRef.current!; }, deps); //eslint-disable-line react-hooks/exhaustive-deps }; diff --git a/packages/react/src/hooks/useEditorChange.ts b/packages/react/src/hooks/useEditorChange.ts index f9408af9ba..207c8fcd83 100644 --- a/packages/react/src/hooks/useEditorChange.ts +++ b/packages/react/src/hooks/useEditorChange.ts @@ -1,9 +1,9 @@ -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { BlockNoteEditor } from "@blocknote/core"; import { useEditorContentChange } from "./useEditorContentChange"; import { useEditorSelectionChange } from "./useEditorSelectionChange"; -export function useEditorChange( - editor: BlockNoteEditor, +export function useEditorChange( + editor: BlockNoteEditor, callback: () => void ) { useEditorContentChange(editor, callback); diff --git a/packages/react/src/hooks/useEditorContentChange.ts b/packages/react/src/hooks/useEditorContentChange.ts index 64882bcf29..ab98072142 100644 --- a/packages/react/src/hooks/useEditorContentChange.ts +++ b/packages/react/src/hooks/useEditorContentChange.ts @@ -1,8 +1,8 @@ -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { BlockNoteEditor } from "@blocknote/core"; import { useEffect } from "react"; -export function useEditorContentChange( - editor: BlockNoteEditor, +export function useEditorContentChange( + editor: BlockNoteEditor, callback: () => void ) { useEffect(() => { diff --git a/packages/react/src/hooks/useEditorSelectionChange.ts b/packages/react/src/hooks/useEditorSelectionChange.ts index 1072b31973..000f12b060 100644 --- a/packages/react/src/hooks/useEditorSelectionChange.ts +++ b/packages/react/src/hooks/useEditorSelectionChange.ts @@ -1,8 +1,8 @@ -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { BlockNoteEditor } from "@blocknote/core"; import { useEffect } from "react"; -export function useEditorSelectionChange( - editor: BlockNoteEditor, +export function useEditorSelectionChange( + editor: BlockNoteEditor, callback: () => void ) { useEffect(() => { diff --git a/packages/react/src/hooks/useSelectedBlocks.ts b/packages/react/src/hooks/useSelectedBlocks.ts index 1a64543948..63ab136ed7 100644 --- a/packages/react/src/hooks/useSelectedBlocks.ts +++ b/packages/react/src/hooks/useSelectedBlocks.ts @@ -1,11 +1,21 @@ -import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { + Block, + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; import { useState } from "react"; import { useEditorChange } from "./useEditorChange"; -export function useSelectedBlocks( - editor: BlockNoteEditor -) { - const [selectedBlocks, setSelectedBlocks] = useState[]>( +export function useSelectedBlocks< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema +>(editor: BlockNoteEditor) { + const [selectedBlocks, setSelectedBlocks] = useState< + Block[] + >( () => editor.getSelection()?.blocks || [editor.getTextCursorPosition().block] ); diff --git a/packages/react/src/htmlConversion.test.tsx b/packages/react/src/htmlConversion.test.tsx deleted file mode 100644 index fa5bd43161..0000000000 --- a/packages/react/src/htmlConversion.test.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { - BlockNoteEditor, - BlockSchema, - PartialBlock, - createExternalHTMLExporter, - createInternalHTMLSerializer, - defaultBlockSchema, - defaultProps, - uploadToTmpFilesDotOrg_DEV_ONLY, -} from "@blocknote/core"; -import { Editor } from "@tiptap/core"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { InlineContent, createReactBlockSpec } from "./ReactBlockSpec"; - -const ReactCustomParagraph = createReactBlockSpec( - { - type: "reactCustomParagraph" as const, - propSchema: defaultProps, - content: "inline", - }, - { - render: () => ( - - ), - toExternalHTML: () => ( -

      Hello World

      - ), - } -); - -const SimpleReactCustomParagraph = createReactBlockSpec( - { - type: "simpleReactCustomParagraph" as const, - propSchema: defaultProps, - content: "inline", - }, - { - render: () => ( - - ), - } -); - -const customSchema = { - ...defaultBlockSchema, - reactCustomParagraph: ReactCustomParagraph, - simpleReactCustomParagraph: SimpleReactCustomParagraph, -} satisfies BlockSchema; - -let editor: BlockNoteEditor; -let tt: Editor; - -beforeEach(() => { - editor = new BlockNoteEditor({ - blockSchema: customSchema, - uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, - }); - tt = editor._tiptapEditor; -}); - -afterEach(() => { - tt.destroy(); - editor = undefined as any; - tt = undefined as any; - - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; -}); - -function convertToHTMLAndCompareSnapshots( - blocks: PartialBlock[], - snapshotDirectory: string, - snapshotName: string -) { - const serializer = createInternalHTMLSerializer(tt.schema, editor); - const internalHTML = serializer.serializeBlocks(blocks); - const internalHTMLSnapshotPath = - "./__snapshots__/" + - snapshotDirectory + - "/" + - snapshotName + - "/internal.html"; - expect(internalHTML).toMatchFileSnapshot(internalHTMLSnapshotPath); - - const exporter = createExternalHTMLExporter(tt.schema, editor); - const externalHTML = exporter.exportBlocks(blocks); - const externalHTMLSnapshotPath = - "./__snapshots__/" + - snapshotDirectory + - "/" + - snapshotName + - "/external.html"; - expect(externalHTML).toMatchFileSnapshot(externalHTMLSnapshotPath); -} - -describe("Convert custom blocks with inline content to HTML", () => { - it("Convert custom block with inline content to HTML", async () => { - const blocks: PartialBlock[] = [ - { - type: "reactCustomParagraph", - content: "React Custom Paragraph", - }, - ]; - - convertToHTMLAndCompareSnapshots(blocks, "reactCustomParagraph", "basic"); - }); - - it("Convert styled custom block with inline content to HTML", async () => { - const blocks: PartialBlock[] = [ - { - type: "reactCustomParagraph", - props: { - textAlignment: "center", - textColor: "orange", - backgroundColor: "pink", - }, - content: [ - { - type: "text", - styles: {}, - text: "Plain ", - }, - { - type: "text", - styles: { - textColor: "red", - }, - text: "Red Text ", - }, - { - type: "text", - styles: { - backgroundColor: "blue", - }, - text: "Blue Background ", - }, - { - type: "text", - styles: { - textColor: "red", - backgroundColor: "blue", - }, - text: "Mixed Colors", - }, - ], - }, - ]; - - convertToHTMLAndCompareSnapshots(blocks, "reactCustomParagraph", "styled"); - }); - - it("Convert nested block with inline content to HTML", async () => { - const blocks: PartialBlock[] = [ - { - type: "reactCustomParagraph", - content: "React Custom Paragraph", - children: [ - { - type: "reactCustomParagraph", - content: "Nested React Custom Paragraph 1", - }, - { - type: "reactCustomParagraph", - content: "Nested React Custom Paragraph 2", - }, - ], - }, - ]; - - convertToHTMLAndCompareSnapshots(blocks, "reactCustomParagraph", "nested"); - }); -}); - -describe("Convert custom blocks with non-exported inline content to HTML", () => { - it("Convert custom block with non-exported inline content to HTML", async () => { - const blocks: PartialBlock[] = [ - { - type: "simpleReactCustomParagraph", - content: "React Custom Paragraph", - }, - ]; - - convertToHTMLAndCompareSnapshots( - blocks, - "simpleReactCustomParagraph", - "basic" - ); - }); - - it("Convert styled custom block with non-exported inline content to HTML", async () => { - const blocks: PartialBlock[] = [ - { - type: "simpleReactCustomParagraph", - props: { - textAlignment: "center", - textColor: "orange", - backgroundColor: "pink", - }, - content: [ - { - type: "text", - styles: {}, - text: "Plain ", - }, - { - type: "text", - styles: { - textColor: "red", - }, - text: "Red Text ", - }, - { - type: "text", - styles: { - backgroundColor: "blue", - }, - text: "Blue Background ", - }, - { - type: "text", - styles: { - textColor: "red", - backgroundColor: "blue", - }, - text: "Mixed Colors", - }, - ], - }, - ]; - - convertToHTMLAndCompareSnapshots( - blocks, - "simpleReactCustomParagraph", - "styled" - ); - }); - - it("Convert nested block with non-exported inline content to HTML", async () => { - const blocks: PartialBlock[] = [ - { - type: "simpleReactCustomParagraph", - content: "Custom React Paragraph", - children: [ - { - type: "simpleReactCustomParagraph", - content: "Nested React Custom Paragraph 1", - }, - { - type: "simpleReactCustomParagraph", - content: "Nested React Custom Paragraph 2", - }, - ], - }, - ]; - - convertToHTMLAndCompareSnapshots( - blocks, - "simpleReactCustomParagraph", - "nested" - ); - }); -}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0f0c8c6977..2748d2cd74 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,50 +1,53 @@ // TODO: review directories -export * from "./BlockNoteView"; export * from "./BlockNoteTheme"; +export * from "./BlockNoteView"; export * from "./defaultThemes"; -export * from "./FormattingToolbar/components/FormattingToolbarPositioner"; -export * from "./FormattingToolbar/components/DefaultFormattingToolbar"; -export * from "./FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown"; export * from "./FormattingToolbar/components/DefaultButtons/ColorStyleButton"; export * from "./FormattingToolbar/components/DefaultButtons/CreateLinkButton"; export * from "./FormattingToolbar/components/DefaultButtons/NestBlockButtons"; export * from "./FormattingToolbar/components/DefaultButtons/TextAlignButton"; export * from "./FormattingToolbar/components/DefaultButtons/ToggledStyleButton"; +export * from "./FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown"; +export * from "./FormattingToolbar/components/DefaultFormattingToolbar"; +export * from "./FormattingToolbar/components/FormattingToolbarPositioner"; export * from "./HyperlinkToolbar/components/HyperlinkToolbarPositioner"; -export * from "./SideMenu/components/SideMenuPositioner"; -export * from "./SideMenu/components/SideMenu"; -export * from "./SideMenu/components/SideMenuButton"; -export * from "./SideMenu/components/DefaultSideMenu"; export * from "./SideMenu/components/DefaultButtons/AddBlockButton"; export * from "./SideMenu/components/DefaultButtons/DragHandle"; +export * from "./SideMenu/components/DefaultSideMenu"; +export * from "./SideMenu/components/SideMenu"; +export * from "./SideMenu/components/SideMenuButton"; +export * from "./SideMenu/components/SideMenuPositioner"; -export * from "./SideMenu/components/DragHandleMenu/DragHandleMenu"; -export * from "./SideMenu/components/DragHandleMenu/DragHandleMenuItem"; -export * from "./SideMenu/components/DragHandleMenu/DefaultDragHandleMenu"; export * from "./SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton"; export * from "./SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton"; +export * from "./SideMenu/components/DragHandleMenu/DefaultDragHandleMenu"; +export * from "./SideMenu/components/DragHandleMenu/DragHandleMenu"; +export * from "./SideMenu/components/DragHandleMenu/DragHandleMenuItem"; -export * from "./SlashMenu/components/SlashMenuPositioner"; -export * from "./SlashMenu/components/SlashMenuItem"; -export * from "./SlashMenu/components/DefaultSlashMenu"; export * from "./SlashMenu/ReactSlashMenuItem"; +export * from "./SlashMenu/components/DefaultSlashMenu"; +export * from "./SlashMenu/components/SlashMenuItem"; +export * from "./SlashMenu/components/SlashMenuPositioner"; export * from "./SlashMenu/defaultReactSlashMenuItems"; -export * from "./ImageToolbar/components/ImageToolbarPositioner"; export * from "./ImageToolbar/components/DefaultImageToolbar"; +export * from "./ImageToolbar/components/ImageToolbarPositioner"; export * from "./SharedComponents/Toolbar/components/Toolbar"; export * from "./SharedComponents/Toolbar/components/ToolbarButton"; export * from "./SharedComponents/Toolbar/components/ToolbarDropdown"; +export * from "./hooks/useActiveStyles"; export * from "./hooks/useBlockNote"; -export * from "./hooks/useEditorForceUpdate"; +export * from "./hooks/useEditorChange"; export * from "./hooks/useEditorContentChange"; +export * from "./hooks/useEditorForceUpdate"; export * from "./hooks/useEditorSelectionChange"; -export * from "./hooks/useEditorChange"; export * from "./hooks/useSelectedBlocks"; export * from "./ReactBlockSpec"; +export * from "./ReactInlineContentSpec"; +export * from "./ReactStyleSpec"; diff --git a/packages/react/src/test/__snapshots__/fontSize/basic/external.html b/packages/react/src/test/__snapshots__/fontSize/basic/external.html new file mode 100644 index 0000000000..6c8910692f --- /dev/null +++ b/packages/react/src/test/__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/react/src/test/__snapshots__/fontSize/basic/internal.html b/packages/react/src/test/__snapshots__/fontSize/basic/internal.html new file mode 100644 index 0000000000..998d9bcf8b --- /dev/null +++ b/packages/react/src/test/__snapshots__/fontSize/basic/internal.html @@ -0,0 +1 @@ +

      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 new file mode 100644 index 0000000000..2e6f533ca1 --- /dev/null +++ b/packages/react/src/test/__snapshots__/mention/basic/external.html @@ -0,0 +1 @@ +

      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 new file mode 100644 index 0000000000..6ca7d81c2c --- /dev/null +++ b/packages/react/src/test/__snapshots__/mention/basic/internal.html @@ -0,0 +1 @@ +

      I enjoy working with@Matthew

      \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap b/packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap new file mode 100644 index 0000000000..d61a928c5a --- /dev/null +++ b/packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap @@ -0,0 +1,461 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert reactCustomParagraph/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "React Custom Paragraph", + "type": "text", + }, + ], + "type": "reactCustomParagraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert reactCustomParagraph/nested to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "React Custom Paragraph", + "type": "text", + }, + ], + "type": "reactCustomParagraph", + }, + { + "content": [ + { + "attrs": { + "backgroundColor": "default", + "id": "2", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "Nested React Custom Paragraph 1", + "type": "text", + }, + ], + "type": "reactCustomParagraph", + }, + ], + "type": "blockContainer", + }, + { + "attrs": { + "backgroundColor": "default", + "id": "3", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "Nested React Custom Paragraph 2", + "type": "text", + }, + ], + "type": "reactCustomParagraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert reactCustomParagraph/styled to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "pink", + "id": "1", + "textColor": "orange", + }, + "content": [ + { + "attrs": { + "textAlignment": "center", + }, + "content": [ + { + "text": "Plain ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "red", + }, + "type": "textColor", + }, + ], + "text": "Red Text ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "blue", + }, + "type": "backgroundColor", + }, + ], + "text": "Blue Background ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "red", + }, + "type": "textColor", + }, + { + "attrs": { + "stringValue": "blue", + }, + "type": "backgroundColor", + }, + ], + "text": "Mixed Colors", + "type": "text", + }, + ], + "type": "reactCustomParagraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert simpleReactCustomParagraph/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "React Custom Paragraph", + "type": "text", + }, + ], + "type": "simpleReactCustomParagraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert simpleReactCustomParagraph/nested to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "Custom React Paragraph", + "type": "text", + }, + ], + "type": "simpleReactCustomParagraph", + }, + { + "content": [ + { + "attrs": { + "backgroundColor": "default", + "id": "2", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "Nested React Custom Paragraph 1", + "type": "text", + }, + ], + "type": "simpleReactCustomParagraph", + }, + ], + "type": "blockContainer", + }, + { + "attrs": { + "backgroundColor": "default", + "id": "3", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "Nested React Custom Paragraph 2", + "type": "text", + }, + ], + "type": "simpleReactCustomParagraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert simpleReactCustomParagraph/styled to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "pink", + "id": "1", + "textColor": "orange", + }, + "content": [ + { + "attrs": { + "textAlignment": "center", + }, + "content": [ + { + "text": "Plain ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "red", + }, + "type": "textColor", + }, + ], + "text": "Red Text ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "blue", + }, + "type": "backgroundColor", + }, + ], + "text": "Blue Background ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "red", + }, + "type": "textColor", + }, + { + "attrs": { + "stringValue": "blue", + }, + "type": "backgroundColor", + }, + ], + "text": "Mixed Colors", + "type": "text", + }, + ], + "type": "simpleReactCustomParagraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react inline content schema > Convert mention/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "I enjoy working with", + "type": "text", + }, + { + "attrs": { + "user": "Matthew", + }, + "type": "mention", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react inline content schema > Convert tag/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "I love ", + "type": "text", + }, + { + "content": [ + { + "text": "BlockNote", + "type": "text", + }, + ], + "type": "tag", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react style schema > Convert fontSize/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "marks": [ + { + "attrs": { + "stringValue": "18px", + }, + "type": "fontSize", + }, + ], + "text": "This is text with a custom fontSize", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react style schema > Convert small/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "marks": [ + { + "type": "small", + }, + ], + "text": "This is a small text", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", +} +`; diff --git a/packages/react/src/__snapshots__/reactCustomParagraph/basic/external.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/external.html similarity index 100% rename from packages/react/src/__snapshots__/reactCustomParagraph/basic/external.html rename to packages/react/src/test/__snapshots__/reactCustomParagraph/basic/external.html diff --git a/packages/react/src/__snapshots__/reactCustomParagraph/basic/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html similarity index 53% rename from packages/react/src/__snapshots__/reactCustomParagraph/basic/internal.html rename to packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html index 91eec85769..edde3826ef 100644 --- a/packages/react/src/__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/__snapshots__/reactCustomParagraph/nested/external.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/external.html similarity index 100% rename from packages/react/src/__snapshots__/reactCustomParagraph/nested/external.html rename to packages/react/src/test/__snapshots__/reactCustomParagraph/nested/external.html diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html new file mode 100644 index 0000000000..faec73f053 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html @@ -0,0 +1 @@ +

      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/__snapshots__/reactCustomParagraph/styled/external.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/external.html similarity index 100% rename from packages/react/src/__snapshots__/reactCustomParagraph/styled/external.html rename to packages/react/src/test/__snapshots__/reactCustomParagraph/styled/external.html diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html new file mode 100644 index 0000000000..dd2e249332 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html @@ -0,0 +1 @@ +

      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 new file mode 100644 index 0000000000..a12e18e1e3 --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html @@ -0,0 +1 @@ +

      React Custom Paragraph

      \ No newline at end of file diff --git a/packages/react/src/__snapshots__/simpleReactCustomParagraph/basic/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html similarity index 52% rename from packages/react/src/__snapshots__/simpleReactCustomParagraph/basic/internal.html rename to packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html index 08534f9e77..ef4a1496c0 100644 --- a/packages/react/src/__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 new file mode 100644 index 0000000000..f34364cb2a --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html @@ -0,0 +1 @@ +

      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 new file mode 100644 index 0000000000..b036c67a6d --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html @@ -0,0 +1 @@ +

      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 new file mode 100644 index 0000000000..df6c3a0e11 --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html @@ -0,0 +1 @@ +

      Plain Red Text Blue Background Mixed Colors

      \ No newline at end of file diff --git a/packages/react/src/__snapshots__/simpleReactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html similarity index 55% rename from packages/react/src/__snapshots__/simpleReactCustomParagraph/styled/internal.html rename to packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html index fefa7e8680..fdc04d2f52 100644 --- a/packages/react/src/__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 new file mode 100644 index 0000000000..35c3d5c232 --- /dev/null +++ b/packages/react/src/test/__snapshots__/small/basic/external.html @@ -0,0 +1 @@ +

      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 new file mode 100644 index 0000000000..73836f647d --- /dev/null +++ b/packages/react/src/test/__snapshots__/small/basic/internal.html @@ -0,0 +1 @@ +

      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 new file mode 100644 index 0000000000..b8387e9a55 --- /dev/null +++ b/packages/react/src/test/__snapshots__/tag/basic/external.html @@ -0,0 +1 @@ +

      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 new file mode 100644 index 0000000000..bac28633b0 --- /dev/null +++ b/packages/react/src/test/__snapshots__/tag/basic/internal.html @@ -0,0 +1 @@ +

      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 new file mode 100644 index 0000000000..08c01088db --- /dev/null +++ b/packages/react/src/test/htmlConversion.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom + +import { + BlockNoteEditor, + BlockSchema, + 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"; + +// TODO: code same from @blocknote/core, maybe create separate test util package +async function convertToHTMLAndCompareSnapshots< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + blocks: PartialBlock[], + snapshotDirectory: string, + snapshotName: string +) { + addIdsToBlocks(blocks); + const serializer = createInternalHTMLSerializer( + editor._tiptapEditor.schema, + editor + ); + const internalHTML = serializer.serializeBlocks(blocks); + const internalHTMLSnapshotPath = + "./__snapshots__/" + + snapshotDirectory + + "/" + + snapshotName + + "/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 + ); + const externalHTML = exporter.exportBlocks(blocks); + const externalHTMLSnapshotPath = + "./__snapshots__/" + + snapshotDirectory + + "/" + + snapshotName + + "/external.html"; + expect(externalHTML).toMatchFileSnapshot(externalHTMLSnapshotPath); +} + +const testCases = [ + customReactBlockSchemaTestCases, + customReactStylesTestCases, + customReactInlineContentTestCases, +]; + +describe("Test React HTML conversion", () => { + for (const testCase of testCases) { + describe("Case: " + testCase.name, () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = testCase.createEditor(); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + }); + + for (const document of testCase.documents) { + // eslint-disable-next-line no-loop-func + it("Convert " + document.name + " to HTML", async () => { + const nameSplit = document.name.split("/"); + await convertToHTMLAndCompareSnapshots( + editor, + document.blocks, + nameSplit[0], + nameSplit[1] + ); + }); + } + }); + } +}); diff --git a/packages/react/src/test/nodeConversion.test.tsx b/packages/react/src/test/nodeConversion.test.tsx new file mode 100644 index 0000000000..6c48f557a4 --- /dev/null +++ b/packages/react/src/test/nodeConversion.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + BlockNoteEditor, + PartialBlock, + UniqueID, + blockToNode, + nodeToBlock, + partialBlockToBlockForTesting, +} from "@blocknote/core"; +import { customReactBlockSchemaTestCases } from "./testCases/customReactBlocks"; +import { customReactInlineContentTestCases } from "./testCases/customReactInlineContent"; +import { customReactStylesTestCases } from "./testCases/customReactStyles"; + +function addIdsToBlock(block: PartialBlock) { + if (!block.id) { + block.id = UniqueID.options.generateID(); + } + for (const child of block.children || []) { + addIdsToBlock(child); + } +} + +function validateConversion( + block: PartialBlock, + editor: BlockNoteEditor +) { + addIdsToBlock(block); + const node = blockToNode( + block, + editor._tiptapEditor.schema, + editor.styleSchema + ); + + expect(node).toMatchSnapshot(); + + const outputBlock = nodeToBlock( + node, + editor.blockSchema, + editor.inlineContentSchema, + editor.styleSchema + ); + + const fullOriginalBlock = partialBlockToBlockForTesting( + editor.blockSchema, + block + ); + + expect(outputBlock).toStrictEqual(fullOriginalBlock); +} + +const testCases = [ + customReactBlockSchemaTestCases, + customReactStylesTestCases, + customReactInlineContentTestCases, +]; + +describe("Test React BlockNote-Prosemirror conversion", () => { + for (const testCase of testCases) { + describe("Case: " + testCase.name, () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = testCase.createEditor(); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + }); + + for (const document of testCase.documents) { + // eslint-disable-next-line no-loop-func + it("Convert " + document.name + " to/from prosemirror", () => { + // NOTE: only converts first block + validateConversion(document.blocks[0], editor); + }); + } + }); + } +}); diff --git a/packages/react/src/test/testCases/customReactBlocks.tsx b/packages/react/src/test/testCases/customReactBlocks.tsx new file mode 100644 index 0000000000..8dd528f74d --- /dev/null +++ b/packages/react/src/test/testCases/customReactBlocks.tsx @@ -0,0 +1,203 @@ +import { + BlockNoteEditor, + BlockSchemaFromSpecs, + BlockSpecs, + DefaultInlineContentSchema, + DefaultStyleSchema, + EditorTestCases, + defaultBlockSpecs, + defaultProps, + uploadToTmpFilesDotOrg_DEV_ONLY, +} from "@blocknote/core"; +import { createReactBlockSpec } from "../../ReactBlockSpec"; + +const ReactCustomParagraph = createReactBlockSpec( + { + type: "reactCustomParagraph" as const, + propSchema: defaultProps, + content: "inline", + }, + { + render: (props) => ( +

      + ), + toExternalHTML: () => ( +

      Hello World

      + ), + } +); + +const SimpleReactCustomParagraph = createReactBlockSpec( + { + type: "simpleReactCustomParagraph" as const, + propSchema: defaultProps, + content: "inline", + }, + { + render: (props) => ( +

      + ), + } +); + +const customSpecs = { + ...defaultBlockSpecs, + reactCustomParagraph: ReactCustomParagraph, + simpleReactCustomParagraph: SimpleReactCustomParagraph, +} satisfies BlockSpecs; + +export const customReactBlockSchemaTestCases: EditorTestCases< + BlockSchemaFromSpecs, + DefaultInlineContentSchema, + DefaultStyleSchema +> = { + name: "custom react block schema", + createEditor: () => { + return BlockNoteEditor.create({ + blockSpecs: customSpecs, + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + }); + }, + documents: [ + { + name: "reactCustomParagraph/basic", + blocks: [ + { + type: "reactCustomParagraph", + content: "React Custom Paragraph", + }, + ], + }, + { + name: "reactCustomParagraph/styled", + blocks: [ + { + type: "reactCustomParagraph", + props: { + textAlignment: "center", + textColor: "orange", + backgroundColor: "pink", + }, + content: [ + { + type: "text", + styles: {}, + text: "Plain ", + }, + { + type: "text", + styles: { + textColor: "red", + }, + text: "Red Text ", + }, + { + type: "text", + styles: { + backgroundColor: "blue", + }, + text: "Blue Background ", + }, + { + type: "text", + styles: { + textColor: "red", + backgroundColor: "blue", + }, + text: "Mixed Colors", + }, + ], + }, + ], + }, + { + name: "reactCustomParagraph/nested", + blocks: [ + { + type: "reactCustomParagraph", + content: "React Custom Paragraph", + children: [ + { + type: "reactCustomParagraph", + content: "Nested React Custom Paragraph 1", + }, + { + type: "reactCustomParagraph", + content: "Nested React Custom Paragraph 2", + }, + ], + }, + ], + }, + { + name: "simpleReactCustomParagraph/basic", + blocks: [ + { + type: "simpleReactCustomParagraph", + content: "React Custom Paragraph", + }, + ], + }, + { + name: "simpleReactCustomParagraph/styled", + blocks: [ + { + type: "simpleReactCustomParagraph", + props: { + textAlignment: "center", + textColor: "orange", + backgroundColor: "pink", + }, + content: [ + { + type: "text", + styles: {}, + text: "Plain ", + }, + { + type: "text", + styles: { + textColor: "red", + }, + text: "Red Text ", + }, + { + type: "text", + styles: { + backgroundColor: "blue", + }, + text: "Blue Background ", + }, + { + type: "text", + styles: { + textColor: "red", + backgroundColor: "blue", + }, + text: "Mixed Colors", + }, + ], + }, + ], + }, + { + name: "simpleReactCustomParagraph/nested", + blocks: [ + { + type: "simpleReactCustomParagraph", + content: "Custom React Paragraph", + children: [ + { + type: "simpleReactCustomParagraph", + content: "Nested React Custom Paragraph 1", + }, + { + type: "simpleReactCustomParagraph", + content: "Nested React Custom Paragraph 2", + }, + ], + }, + ], + }, + ], +}; diff --git a/packages/react/src/test/testCases/customReactInlineContent.tsx b/packages/react/src/test/testCases/customReactInlineContent.tsx new file mode 100644 index 0000000000..4b6db0e07e --- /dev/null +++ b/packages/react/src/test/testCases/customReactInlineContent.tsx @@ -0,0 +1,101 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + DefaultStyleSchema, + EditorTestCases, + InlineContentSchemaFromSpecs, + InlineContentSpecs, + defaultInlineContentSpecs, + uploadToTmpFilesDotOrg_DEV_ONLY, +} from "@blocknote/core"; +import { createReactInlineContentSpec } from "../../ReactInlineContentSpec"; + +const mention = createReactInlineContentSpec( + { + type: "mention", + propSchema: { + user: { + default: "", + }, + }, + content: "none", + }, + { + render: (props) => { + return @{props.inlineContent.props.user}; + }, + } +); + +const tag = createReactInlineContentSpec( + { + type: "tag", + propSchema: {}, + content: "styled", + }, + { + render: (props) => { + return ( + + # + + ); + }, + } +); + +const customReactInlineContent = { + ...defaultInlineContentSpecs, + tag, + mention, +} satisfies InlineContentSpecs; + +export const customReactInlineContentTestCases: EditorTestCases< + DefaultBlockSchema, + InlineContentSchemaFromSpecs, + DefaultStyleSchema +> = { + name: "custom react inline content schema", + createEditor: () => { + return BlockNoteEditor.create({ + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + inlineContentSpecs: customReactInlineContent, + }); + }, + documents: [ + { + name: "mention/basic", + blocks: [ + { + type: "paragraph", + content: [ + "I enjoy working with", + { + type: "mention", + props: { + user: "Matthew", + }, + content: undefined, + } as any, + ], + }, + ], + }, + { + name: "tag/basic", + blocks: [ + { + type: "paragraph", + content: [ + "I love ", + { + type: "tag", + // props: {}, + content: "BlockNote", + } as any, + ], + }, + ], + }, + ], +}; diff --git a/packages/react/src/test/testCases/customReactStyles.tsx b/packages/react/src/test/testCases/customReactStyles.tsx new file mode 100644 index 0000000000..ea7126d6ce --- /dev/null +++ b/packages/react/src/test/testCases/customReactStyles.tsx @@ -0,0 +1,93 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + DefaultInlineContentSchema, + EditorTestCases, + StyleSchemaFromSpecs, + StyleSpecs, + defaultStyleSpecs, + uploadToTmpFilesDotOrg_DEV_ONLY, +} from "@blocknote/core"; +import { createReactStyleSpec } from "../../ReactStyleSpec"; + +const small = createReactStyleSpec( + { + type: "small", + propSchema: "boolean", + }, + { + render: (props) => { + return ; + }, + } +); + +const fontSize = createReactStyleSpec( + { + type: "fontSize", + propSchema: "string", + }, + { + render: (props) => { + return ( + + ); + }, + } +); + +const customReactStyles = { + ...defaultStyleSpecs, + small, + fontSize, +} satisfies StyleSpecs; + +export const customReactStylesTestCases: EditorTestCases< + DefaultBlockSchema, + DefaultInlineContentSchema, + StyleSchemaFromSpecs +> = { + name: "custom react style schema", + createEditor: () => { + return BlockNoteEditor.create({ + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + styleSpecs: customReactStyles, + }); + }, + documents: [ + { + name: "small/basic", + blocks: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "This is a small text", + styles: { + small: true, + }, + }, + ], + }, + ], + }, + { + name: "fontSize/basic", + blocks: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "This is text with a custom fontSize", + styles: { + fontSize: "18px", + }, + }, + ], + }, + ], + }, + ], +}; diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 83603cc619..41e980486e 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -5,12 +5,22 @@ import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; // https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig((conf) => ({ test: { environment: "jsdom", 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: { @@ -37,4 +47,4 @@ export default defineConfig({ }, }, }, -}); +})); diff --git a/packages/website/docs/docs/vanilla-js.md b/packages/website/docs/docs/vanilla-js.md index 6d2b9ce4f4..e290034274 100644 --- a/packages/website/docs/docs/vanilla-js.md +++ b/packages/website/docs/docs/vanilla-js.md @@ -25,7 +25,7 @@ This is how to create a new BlockNote editor: ``` import { BlockNoteEditor } from "@blocknote/core"; -const editor = new BlockNoteEditor({ +const editor = BlockNoteEditor.create({ element: document.getElementById("root")!, // element to append the editor to onUpdate: ({ editor }) => { console.log(editor.getJSON()); @@ -47,7 +47,7 @@ Because we can't use the built-in React elements, you'll need to create and regi You can do this by passing custom component factories as `uiFactories`, e.g.: ``` -const editor = new BlockNoteEditor({ +const editor = BlockNoteEditor.create({ element: document.getElementById("root")!, uiFactories: { formattingToolbarFactory: customFormattingToolbarFactory,