diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 256ce38cd5..f73c4bdf5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,9 +5,6 @@ on: - main pull_request: types: [opened, synchronize, reopened, edited] - branches: - - main - - "project/**" jobs: build: 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 b4acf6ca17..6c3213b0dd 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/examples/Basic.tsx @@ -1,16 +1,12 @@ -// import logo from './logo.svg' 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 d2ce297101..a14117e164 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": { @@ -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", @@ -6424,6 +6454,42 @@ "@tiptap/core": "^2.0.0" } }, + "node_modules/@tiptap/extension-table-cell": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.1.12.tgz", + "integrity": "sha512-hextcfVTdwX8G7s8Q/V6LW2aUhGvPgu1dfV+kVVO42AFHxG+6PIkDOUuHphGajG3Nrs129bjMDWb8jphj38dUg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.1.12.tgz", + "integrity": "sha512-a4WZ5Z7gqQ/QlK8cK2d1ONYdma/J5+yH/0SNtQhkfELoS45GsLJh89OyKO0W0FnY6Mg0RoH1FsoBD+cqm0yazA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.1.12.tgz", + "integrity": "sha512-0kPr+zngQC1YQRcU6+Fl3CpIW/SdJhVJ5qOLpQleXrLPdjmZQd3Z1DXvOSDphYjXCowGPCxeUa++6bo7IoEMJw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, "node_modules/@tiptap/extension-text": { "version": "2.0.3", "license": "MIT", @@ -7691,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": { @@ -7717,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" @@ -7736,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": { @@ -7753,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": { @@ -7781,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, @@ -7812,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, @@ -8225,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" @@ -9098,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, @@ -9107,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" }, @@ -9367,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", @@ -9392,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" @@ -9432,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, @@ -10017,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": { @@ -10076,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" }, @@ -10179,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", @@ -10196,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": { @@ -10505,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", @@ -10771,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" @@ -10841,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" @@ -11183,6 +11337,7 @@ }, "node_modules/has": { "version": "1.0.3", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.1" @@ -11258,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", @@ -11544,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, @@ -11954,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, @@ -12035,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" @@ -12100,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, @@ -12108,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, @@ -12303,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" @@ -12416,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, @@ -12797,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" }, @@ -15346,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" } @@ -15407,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" @@ -15423,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" } }, @@ -15467,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" @@ -16371,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": [ { @@ -16387,7 +16621,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -16806,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", @@ -17147,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, @@ -17176,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" @@ -17225,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", @@ -17354,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" }, @@ -17529,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, @@ -17738,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, @@ -18028,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" @@ -18044,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" @@ -18504,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, @@ -18531,15 +19066,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": { @@ -19340,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, @@ -19355,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" @@ -19645,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", @@ -19664,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": { @@ -19719,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", @@ -19778,6 +20361,9 @@ "@tiptap/extension-link": "^2.0.3", "@tiptap/extension-paragraph": "^2.0.3", "@tiptap/extension-strike": "^2.0.3", + "@tiptap/extension-table-cell": "^2.0.3", + "@tiptap/extension-table-header": "^2.0.3", + "@tiptap/extension-table-row": "^2.0.3", "@tiptap/extension-text": "^2.0.3", "@tiptap/extension-underline": "^2.0.3", "@tiptap/pm": "^2.0.3", @@ -19785,8 +20371,10 @@ "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-format": "^5.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", @@ -19894,6 +20482,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", @@ -19979,7 +20579,7 @@ "@tiptap/core": "^2.0.3", "@tiptap/react": "^2.0.3", "lodash": "^4.17.21", - "react": "^18.2.0", + "react": "^18", "react-dom": "^18.2.0", "react-icons": "^4.3.1", "tippy.js": "^6.3.7", @@ -19994,10 +20594,12 @@ "typescript": "^5.0.4", "vite": "^4.4.8", "vite-plugin-eslint": "^1.8.1", - "vite-plugin-externalize-deps": "^0.7.0" + "vite-plugin-externalize-deps": "^0.7.0", + "vitest": "^0.34.1" }, "peerDependencies": { - "react": "^18" + "react": "^18", + "react-dom": "^18" } }, "packages/react/node_modules/@babel/code-frame": { 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/package.json b/packages/core/package.json index dfceb58816..2984e7b9d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -66,6 +66,9 @@ "@tiptap/extension-link": "^2.0.3", "@tiptap/extension-paragraph": "^2.0.3", "@tiptap/extension-strike": "^2.0.3", + "@tiptap/extension-table-cell": "^2.0.3", + "@tiptap/extension-table-header": "^2.0.3", + "@tiptap/extension-table-row": "^2.0.3", "@tiptap/extension-text": "^2.0.3", "@tiptap/extension-underline": "^2.0.3", "@tiptap/pm": "^2.0.3", @@ -75,9 +78,11 @@ "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", + "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 7683c5431c..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,21 +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"; @@ -42,11 +66,15 @@ 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"; -import "./editor.css"; +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; /** @@ -55,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. @@ -72,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`. */ @@ -88,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. * @@ -99,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. @@ -143,52 +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 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); - 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, }); @@ -202,13 +323,12 @@ export class BlockNoteEditor { this.slashMenu.plugin, this.hyperlinkToolbar.plugin, this.imageToolbar.plugin, + ...(this.tableHandles ? [this.tableHandles.plugin] : []), ]; }, }); extensions.push(blockNoteUIExtension); - this.schema = newOptions.blockSchema; - this.uploadFile = newOptions.uploadFile; const initialContent = @@ -221,6 +341,7 @@ export class BlockNoteEditor { id: UniqueID.options.generateID(), }, ]); + const styleSchema = this.styleSchema; const tiptapOptions: Partial = { ...blockNoteTipTapOptions, @@ -240,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(); @@ -251,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); @@ -299,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); + }, }, }; @@ -331,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; }); @@ -350,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") { @@ -366,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; }); @@ -380,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(); @@ -389,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; @@ -430,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 @@ -458,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 + ), }; } @@ -488,25 +695,42 @@ export class BlockNoteEditor { posBeforeNode + 2 )!; - // For blocks without inline content - if (contentNode.type.spec.content === "") { + const contentType: "none" | "inline" | "table" = + this.blockSchema[contentNode.type.name]!.content; + + if (contentType === "none") { this._tiptapEditor.commands.setNodeSelection(startPos); return; } - if (placement === "start") { - this._tiptapEditor.commands.setTextSelection(startPos + 1); + if (contentType === "inline") { + if (placement === "start") { + this._tiptapEditor.commands.setTextSelection(startPos + 1); + } else { + this._tiptapEditor.commands.setTextSelection( + startPos + contentNode.nodeSize - 1 + ); + } + } else if (contentType === "table") { + if (placement === "start") { + // 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 { + this._tiptapEditor.commands.setTextSelection( + startPos + contentNode.nodeSize - 4 + ); + } } else { - this._tiptapEditor.commands.setTextSelection( - startPos + contentNode.nodeSize - 1 - ); + throw new UnreachableCaseError(contentType); } } /** * 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 ( @@ -517,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. @@ -536,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 ) ); @@ -572,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); } /** @@ -588,7 +814,7 @@ export class BlockNoteEditor { */ public updateBlock( blockToUpdate: BlockIdentifier, - update: PartialBlock + update: PartialBlock ) { updateBlock(blockToUpdate, update, this._tiptapEditor); } @@ -610,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; } } @@ -646,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); } } } @@ -671,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)) { @@ -683,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); } } } @@ -785,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 39da5cef7a..e7c52358fd 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -2,42 +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 { 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"; + 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"; -import { createClipboardHandlerExtension } from "./api/serialization/clipboardHandlerExtension"; /** * 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: { @@ -73,33 +80,51 @@ 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, }), - ...Object.values(opts.blockSchema).map((blockSpec) => - blockSpec.node.configure({ - editor: opts.editor, - domAttributes: opts.domAttributes, - }) - ), - createClipboardHandlerExtension(opts.editor), + ...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.requiredExtensions || []).map((ext) => + ext.configure({ + editor: opts.editor, + domAttributes: opts.domAttributes, + }) + ), + // the actual node itself + blockSpec.implementation.node.configure({ + editor: opts.editor, + domAttributes: opts.domAttributes, + }), + ]; + }), + 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 4185f4b6b4..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", @@ -76,6 +96,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(); 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 76% rename from packages/core/src/api/serialization/html/sharedHTMLConversion.ts rename to packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts index 43b95377e0..03f45db17f 100644 --- a/packages/core/src/api/serialization/html/sharedHTMLConversion.ts +++ b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts @@ -1,10 +1,10 @@ -import { nodeToBlock } from "../../nodeConversions/nodeConversions"; import { DOMSerializer, Fragment, Node } from "prosemirror-model"; -import { - BlockSchema, - SpecificBlock, -} from "../../../extensions/Blocks/api/blockTypes"; -import { BlockNoteEditor } from "../../../BlockNoteEditor"; + +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; @@ -16,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) @@ -52,17 +59,21 @@ export const serializeNodeInner = ( // conform to the schema. This is unintuitive but important as it occurs // when copying only nested blocks. if (blockContentNode !== undefined) { - const blockSpec = - editor.schema[blockContentNode.type.name as keyof BSchema]; + const impl = + editor.blockImplementations[blockContentNode.type.name] + .implementation; const toHTML = toExternalHTML - ? blockSpec.toExternalHTML - : blockSpec.toInternalHTML; + ? impl.toExternalHTML + : impl.toInternalHTML; const blockContent = toHTML( - nodeToBlock(node, editor.schema, editor.blockCache) as SpecificBlock< - BlockSchema, - keyof BlockSchema - >, - editor as BlockNoteEditor + nodeToBlock( + node, + editor.blockSchema, + editor.inlineContentSchema, + editor.styleSchema, + editor.blockCache + ), + editor as any ); // Converts inline nodes in the `blockContent` node's content to HTML 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 24d2fb3a09..391fbe1e5d 100644
    --- a/packages/core/src/api/nodeConversions/nodeConversions.ts
    +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts
    @@ -4,41 +4,51 @@ import {
       Block,
       BlockSchema,
       PartialBlock,
    -} from "../../extensions/Blocks/api/blockTypes";
    +  PartialTableContent,
    +  TableContent,
    +} 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);
         }
       }
     
    @@ -64,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;
     }
    @@ -107,44 +128,85 @@ 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;
     }
     
     /**
    - * Converts a BlockNote block to a TipTap node.
    + * converts an array of inline content elements to prosemirror nodes
      */
    -export function blockToNode(
    -  block: PartialBlock,
    -  schema: Schema
    -) {
    -  let id = block.id;
    +export function tableContentToNodes<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  tableContent: PartialTableContent,
    +  schema: Schema,
    +  styleSchema: StyleSchema
    +): Node[] {
    +  const rowNodes: Node[] = [];
    +
    +  for (const row of tableContent.rows) {
    +    const columnNodes: Node[] = [];
    +    for (const cell of row.cells) {
    +      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, styleSchema);
    +        pNode = schema.nodes["tableParagraph"].create({}, textNodes);
    +      }
     
    -  if (id === undefined) {
    -    id = UniqueID.options.generateID();
    +      const cellNode = schema.nodes["tableCell"].create({}, pNode);
    +      columnNodes.push(cellNode);
    +    }
    +    const rowNode = schema.nodes["tableRow"].create({}, columnNodes);
    +    rowNodes.push(rowNode);
       }
    +  return rowNodes;
    +}
     
    +function blockOrInlineContentToContentNode(
    +  block:
    +    | PartialBlock
    +    | PartialCustomInlineContentFromConfig,
    +  schema: Schema,
    +  styleSchema: StyleSchema
    +) {
    +  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);
    @@ -153,16 +215,42 @@ export function blockToNode(
           block.props,
           schema.text(block.content)
         );
    -  } else {
    -    const nodes = inlineContentToNodes(block.content, schema);
    +  } else if (Array.isArray(block.content)) {
    +    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, 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));
         }
       }
     
    @@ -177,12 +265,48 @@ export function blockToNode(
       );
     }
     
    +/**
    + * Converts an internal (prosemirror) table node contentto a BlockNote 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] = {
    +      cells: [],
    +    };
    +
    +    rowNode.content.forEach((cellNode) => {
    +      row.cells.push(
    +        contentNodeToInlineContent(
    +          cellNode.firstChild!,
    +          inlineContentSchema,
    +          styleSchema
    +        )
    +      );
    +    });
    +
    +    ret.rows.push(row);
    +  });
    +
    +  return ret;
    +}
    +
     /**
      * 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
    @@ -192,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.
    @@ -212,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);
    +        }
           }
         }
     
    @@ -231,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 (
    @@ -263,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).
    @@ -309,6 +458,8 @@ function contentNodeToInlineContent(contentNode: Node) {
                 styles,
               };
             }
    +      } else {
    +        // TODO
           }
         }
         // Current content does not exist.
    @@ -342,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" +
    @@ -382,6 +582,7 @@ export function nodeToBlock(
         ...blockInfo.contentNode.attrs,
       })) {
         const blockSpec = blockSchema[blockInfo.contentType.name];
    +
         if (!blockSpec) {
           throw Error(
             "Block is of an unrecognized type: " + blockInfo.contentType.name
    @@ -395,25 +596,48 @@ export function nodeToBlock(
         }
       }
     
    -  const blockSpec = blockSchema[blockInfo.contentType.name];
    +  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"];
    +
    +  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(blockConfig.content);
    +  }
    +
       const block = {
         id,
    -    type: blockSpec.node.name,
    +    type: blockConfig.type,
         props,
    -    content:
    -      blockSpec.node.config.content === "inline*"
    -        ? contentNodeToInlineContent(blockInfo.contentNode)
    -        : undefined,
    +    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 c1740b0120..3398e19d2d 100644
    --- a/packages/core/src/api/nodeConversions/testUtil.ts
    +++ b/packages/core/src/api/nodeConversions/testUtil.ts
    @@ -2,16 +2,22 @@ import {
       Block,
       BlockSchema,
       PartialBlock,
    -} from "../../extensions/Blocks/api/blockTypes";
    +  TableContent,
    +} 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 [
           {
    @@ -25,41 +31,97 @@ function textShorthandToStyledText(
     }
     
     function partialContentToInlineContent(
    -  content: string | PartialInlineContent[] = ""
    -): InlineContent[] {
    +  content: PartialInlineContent | TableContent | undefined
    +): InlineContent[] | TableContent | undefined {
       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.flatMap((partialContent) => {
    +      if (typeof partialContent === "string") {
    +        return textShorthandToStyledText(partialContent);
    +      } else if (isPartialLinkInlineContent(partialContent)) {
    +        return {
    +          ...partialContent,
    +          content: textShorthandToStyledText(partialContent.content),
    +        };
    +      } else if (isStyledTextInlineContent(partialContent)) {
    +        return partialContent;
    +      } else {
    +        // custom inline content
    +
    +        return {
    +          props: {},
    +          ...partialContent,
    +          content: partialContentToInlineContent(partialContent.content),
    +        } as any;
    +      }
    +    });
    +  }
    +
    +  return content;
     }
     
    -export function partialBlockToBlockForTesting(
    -  partialBlock: PartialBlock
    -): Block {
    -  const withDefaults = {
    +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,
    -    children: [],
    +    content:
    +      schema[partialBlock.type!].content === "inline" ? [] : (undefined as any),
    +    children: [] as any,
         ...partialBlock,
    -  } satisfies 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 52a0833fa3..0000000000
      --- a/packages/core/src/api/serialization/html/htmlConversion.test.ts
      +++ /dev/null
      @@ -1,456 +0,0 @@
      -import { afterEach, beforeEach, describe, expect, it } from "vitest";
      -import { createBlockSpec } from "../../../extensions/Blocks/api/block";
      -import {
      -  imagePropSchema,
      -  renderImage,
      -} from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent";
      -import { defaultBlockSchema } from "../../../extensions/Blocks/api/defaultBlocks";
      -import {
      -  BlockSchema,
      -  PartialBlock,
      -} from "../../../extensions/Blocks/api/blockTypes";
      -import { BlockNoteEditor } from "../../../BlockNoteEditor";
      -import { Editor } from "@tiptap/core";
      -import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      -import { createInternalHTMLSerializer } from "./internalHTMLSerializer";
      -import { createExternalHTMLExporter } from "./externalHTMLExporter";
      -import { defaultProps } from "../../../extensions/Blocks/api/defaultProps";
      -
      -// 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",
      -  propSchema: imagePropSchema,
      -  containsInlineContent: false,
      -  render: renderImage as any,
      -});
      -
      -const CustomParagraph = createBlockSpec({
      -  type: "customParagraph",
      -  propSchema: defaultProps,
      -  containsInlineContent: true,
      -  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",
      -  propSchema: defaultProps,
      -  containsInlineContent: true,
      -  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/editor.css b/packages/core/src/editor.css
      index e47157e409..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;
      @@ -85,3 +92,23 @@ Tippy popups that are appended to document.body directly
         user-select: none;
         white-space: nowrap;
       }
      +
      +/* table related: */
      +.bn-editor table {
      +  width: auto !important;
      +}
      +.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/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/block.ts b/packages/core/src/extensions/Blocks/api/block.ts
      deleted file mode 100644
      index f576928b0a..0000000000
      --- a/packages/core/src/extensions/Blocks/api/block.ts
      +++ /dev/null
      @@ -1,364 +0,0 @@
      -import { Attribute, Attributes, Editor, Node } from "@tiptap/core";
      -import { ParseRule } from "prosemirror-model";
      -import {
      -  BlockConfig,
      -  BlockNoteDOMAttributes,
      -  BlockSchemaWithBlock,
      -  BlockSpec,
      -  Props,
      -  PropSchema,
      -  SpecificBlock,
      -  TipTapNode,
      -  TipTapNodeConfig,
      -} from "./blockTypes";
      -import { mergeCSSClasses } from "../../../shared/utils";
      -import { BlockNoteEditor } from "../../../BlockNoteEditor";
      -import { inheritedProps } from "./defaultProps";
      -
      -export function camelToDataKebab(str: string): string {
      -  return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
      -}
      -
      -// Function that uses the 'propSchema' of a blockConfig to create a TipTap
      -// node's `addAttributes` property.
      -export function propsToAttributes<
      -  BType extends string,
      -  PSchema extends PropSchema,
      -  ContainsInlineContent extends boolean,
      -  BSchema extends BlockSchemaWithBlock
      ->(
      -  blockConfig: Omit<
      -    BlockConfig,
      -    "render" | "toExternalHTML"
      -  >
      -): Attributes {
      -  const tiptapAttributes: Record = {};
      -
      -  Object.entries(blockConfig.propSchema)
      -    .filter(([name, _spec]) => !inheritedProps.includes(name))
      -    .forEach(([name, spec]) => {
      -      tiptapAttributes[name] = {
      -        default: spec.default,
      -        keepOnSplit: true,
      -        // Props are displayed in kebab-case as HTML attributes. If a prop's
      -        // value is the same as its default, we don't display an HTML
      -        // attribute for it.
      -        parseHTML: (element) => {
      -          const value = element.getAttribute(camelToDataKebab(name));
      -
      -          if (value === null) {
      -            return null;
      -          }
      -
      -          if (typeof spec.default === "boolean") {
      -            if (value === "true") {
      -              return true;
      -            }
      -
      -            if (value === "false") {
      -              return false;
      -            }
      -
      -            return null;
      -          }
      -
      -          if (typeof spec.default === "number") {
      -            const asNumber = parseFloat(value);
      -            const isNumeric =
      -              !Number.isNaN(asNumber) && Number.isFinite(asNumber);
      -
      -            if (isNumeric) {
      -              return asNumber;
      -            }
      -
      -            return null;
      -          }
      -
      -          return value;
      -        },
      -        renderHTML: (attributes) =>
      -          attributes[name] !== spec.default
      -            ? {
      -                [camelToDataKebab(name)]: attributes[name],
      -              }
      -            : {},
      -      };
      -    });
      -
      -  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<
      -  BType extends string,
      -  PSchema extends PropSchema,
      -  ContainsInlineContent extends boolean,
      -  BSchema extends BlockSchemaWithBlock
      ->(
      -  blockConfig: Omit<
      -    BlockConfig,
      -    "render" | "toExternalHTML"
      -  >
      -) {
      -  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,
      -  PSchema extends PropSchema,
      -  ContainsInlineContent extends boolean,
      -  BSchema extends BlockSchemaWithBlock
      ->(
      -  getPos: (() => number) | boolean,
      -  editor: BlockNoteEditor,
      -  tipTapEditor: Editor,
      -  type: BType
      -) {
      -  // Gets position of the node
      -  if (typeof getPos === "boolean") {
      -    throw new Error(
      -      "Cannot find node position as getPos is a boolean, not a function."
      -    );
      -  }
      -  const pos = getPos();
      -  // Gets parent blockContainer node
      -  const blockContainer = tipTapEditor.state.doc.resolve(pos!).node();
      -  // Gets block identifier
      -  const blockIdentifier = blockContainer.attrs.id;
      -  // Gets the block
      -  const block = editor.getBlock(blockIdentifier)! as SpecificBlock<
      -    BSchema,
      -    BType
      -  >;
      -  if (block.type !== type) {
      -    throw new Error("Block type does not match");
      -  }
      -
      -  return block;
      -}
      -
      -// Function that wraps the `dom` element returned from 'blockConfig.render' in a
      -// `blockContent` div, which contains the block type and props as HTML
      -// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds
      -// an `inlineContent` class to it.
      -export function wrapInBlockStructure<
      -  BType extends string,
      -  PSchema extends PropSchema
      ->(
      -  element: {
      -    dom: HTMLElement;
      -    contentDOM?: HTMLElement;
      -    destroy?: () => void;
      -  },
      -  blockType: BType,
      -  blockProps: Props,
      -  propSchema: PSchema,
      -  domAttributes?: Record
      -): {
      -  dom: HTMLElement;
      -  contentDOM?: HTMLElement;
      -  destroy?: () => void;
      -} {
      -  // Creates `blockContent` element
      -  const blockContent = document.createElement("div");
      -
      -  // Adds custom HTML attributes
      -  if (domAttributes !== undefined) {
      -    for (const [attr, value] of Object.entries(domAttributes)) {
      -      if (attr !== "class") {
      -        blockContent.setAttribute(attr, value);
      -      }
      -    }
      -  }
      -  // Sets blockContent class
      -  blockContent.className = mergeCSSClasses(
      -    "bn-block-content",
      -    domAttributes?.class || ""
      -  );
      -  // Sets content type attribute
      -  blockContent.setAttribute("data-content-type", blockType);
      -  // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props
      -  // which are already added as HTML attributes to the parent `blockContent`
      -  // element (inheritedProps) and props set to their default values.
      -  for (const [prop, value] of Object.entries(blockProps)) {
      -    if (!inheritedProps.includes(prop) && value !== propSchema[prop].default) {
      -      blockContent.setAttribute(camelToDataKebab(prop), value);
      -    }
      -  }
      -
      -  blockContent.appendChild(element.dom);
      -
      -  if (element.contentDOM !== undefined) {
      -    element.contentDOM.className = mergeCSSClasses(
      -      "bn-inline-content",
      -      element.contentDOM.className
      -    );
      -  }
      -
      -  return {
      -    ...element,
      -    dom: blockContent,
      -  };
      -}
      -
      -// 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<
      -  BType extends string,
      -  PSchema extends PropSchema,
      -  ContainsInlineContent extends boolean,
      -  BSchema extends BlockSchemaWithBlock
      ->(
      -  blockConfig: BlockConfig
      -): BlockSpec {
      -  const node = createTipTapBlock<
      -    BType,
      -    ContainsInlineContent,
      -    {
      -      editor: BlockNoteEditor;
      -      domAttributes?: BlockNoteDOMAttributes;
      -    }
      -  >({
      -    name: blockConfig.type,
      -    content: (blockConfig.containsInlineContent
      -      ? "inline*"
      -      : "") as ContainsInlineContent extends true ? "inline*" : "",
      -    selectable: true,
      -
      -    addAttributes() {
      -      return propsToAttributes(blockConfig);
      -    },
      -
      -    parseHTML() {
      -      return parse(blockConfig);
      -    },
      -
      -    addNodeView() {
      -      return ({ getPos }) => {
      -        // Gets the BlockNote editor instance
      -        const editor = this.options.editor;
      -        // Gets the block
      -        const block = getBlockFromPos<
      -          BType,
      -          PSchema,
      -          ContainsInlineContent,
      -          BSchema
      -        >(getPos, editor, this.editor, blockConfig.type);
      -        // Gets the custom HTML attributes for `blockContent` nodes
      -        const blockContentDOMAttributes =
      -          this.options.domAttributes?.blockContent || {};
      -
      -        const output = blockConfig.render(block, editor);
      -
      -        return wrapInBlockStructure(
      -          output,
      -          block.type,
      -          block.props,
      -          blockConfig.propSchema,
      -          blockContentDOMAttributes
      -        );
      -      };
      -    },
      -  });
      -
      -  return {
      -    node: node as TipTapNode,
      -    propSchema: blockConfig.propSchema,
      -    toInternalHTML: (block, editor) => {
      -      const blockContentDOMAttributes =
      -        node.options.domAttributes?.blockContent || {};
      -
      -      const output = blockConfig.render(block as any, editor as any);
      -
      -      return wrapInBlockStructure(
      -        output,
      -        block.type as BType,
      -        block.props as Props,
      -        blockConfig.propSchema,
      -        blockContentDOMAttributes
      -      );
      -    },
      -    toExternalHTML: (block, editor) => {
      -      const blockContentDOMAttributes =
      -        node.options.domAttributes?.blockContent || {};
      -
      -      let output = blockConfig.toExternalHTML?.(block as any, editor as any);
      -      if (output === undefined) {
      -        output = blockConfig.render(block as any, editor as any);
      -      }
      -
      -      return wrapInBlockStructure(
      -        output,
      -        block.type as BType,
      -        block.props as Props,
      -        blockConfig.propSchema,
      -        blockContentDOMAttributes
      -      );
      -    },
      -  };
      -}
      -
      -export function createTipTapBlock<
      -  Type extends string,
      -  ContainsInlineContent extends boolean,
      -  Options extends {
      -    domAttributes?: BlockNoteDOMAttributes;
      -  } = {
      -    domAttributes?: BlockNoteDOMAttributes;
      -  },
      -  Storage = any
      ->(
      -  config: TipTapNodeConfig
      -): TipTapNode {
      -  // Type cast is needed as Node.name is mutable, though there is basically no
      -  // reason to change it after creation. Alternative is to wrap Node in a new
      -  // class, which I don't think is worth it since we'd only be changing 1
      -  // attribute to be read only.
      -  return Node.create({
      -    ...config,
      -    group: "blockContent",
      -    content: config.content,
      -  }) as TipTapNode;
      -}
      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 ecd0cca0a0..0000000000
      --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts
      +++ /dev/null
      @@ -1,287 +0,0 @@
      -/** Define the main block types **/
      -import { Node, NodeConfig } from "@tiptap/core";
      -import { BlockNoteEditor } from "../../../BlockNoteEditor";
      -import { InlineContent, PartialInlineContent } from "./inlineContentTypes";
      -import { DefaultBlockSchema } from "./defaultBlocks";
      -
      -export type BlockNoteDOMElement =
      -  | "editor"
      -  | "blockContainer"
      -  | "blockGroup"
      -  | "blockContent"
      -  | "inlineContent";
      -
      -export type BlockNoteDOMAttributes = Partial<{
      -  [DOMElement in BlockNoteDOMElement]: Record;
      -}>;
      -
      -// A configuration for a TipTap node, but with stricter type constraints on the
      -// "name" and "content" properties. The "name" property is now always a string
      -// literal type, and the "content" property can only be "inline*" or "". Used as
      -// the parameter in `createTipTapNode`. The "group" is also removed as
      -// `createTipTapNode` always sets it to "blockContent"
      -export type TipTapNodeConfig<
      -  Name extends string,
      -  ContainsInlineContent extends boolean,
      -  Options extends {
      -    domAttributes?: BlockNoteDOMAttributes;
      -  } = {
      -    domAttributes?: BlockNoteDOMAttributes;
      -  },
      -  Storage = any
      -> = {
      -  [K in keyof NodeConfig]: K extends "name"
      -    ? Name
      -    : K extends "content"
      -    ? ContainsInlineContent extends true
      -      ? "inline*"
      -      : ""
      -    : K extends "group"
      -    ? never
      -    : NodeConfig[K];
      -} & {
      -  name: Name;
      -  content: ContainsInlineContent extends true ? "inline*" : "";
      -};
      -
      -// A TipTap node with stricter type constraints on the "name", "group", and
      -// "content properties. The "name" property is now a string literal type, and
      -// the "blockGroup" property is now "blockContent", and the "content" property
      -// can only be "inline*" or "". Returned by `createTipTapNode`.
      -export type TipTapNode<
      -  Name extends string,
      -  ContainsInlineContent extends boolean,
      -  Options extends {
      -    domAttributes?: BlockNoteDOMAttributes;
      -  } = {
      -    domAttributes?: BlockNoteDOMAttributes;
      -  },
      -  Storage = any
      -> = {
      -  [Key in keyof Node]: Key extends "name"
      -    ? Name
      -    : Key extends "config"
      -    ? {
      -        [ConfigKey in keyof Node<
      -          Options,
      -          Storage
      -        >["config"]]: ConfigKey extends "group"
      -          ? "blockContent"
      -          : ConfigKey extends "content"
      -          ? ContainsInlineContent extends true
      -            ? "inline*"
      -            : ""
      -          : NodeConfig["config"][ConfigKey];
      -      } & {
      -        group: "blockContent";
      -        content: ContainsInlineContent extends true ? "inline*" : "";
      -      }
      -    : Node["config"][Key];
      -};
      -
      -// 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;
      -};
      -
      -// Defines the config for a single block. Meant to be used as an argument to
      -// `createBlockSpec`, which will create a new block spec from it. This is the
      -// main way we expect people to create custom blocks as consumers don't need to
      -// know anything about the TipTap API since the associated nodes are created
      -// automatically.
      -export type BlockConfig<
      -  Type extends string,
      -  PSchema extends PropSchema,
      -  ContainsInlineContent extends boolean,
      -  BSchema extends BlockSchemaWithBlock
      -> = {
      -  // Attributes to define block in the API as well as a TipTap node.
      -  type: Type;
      -  readonly propSchema: PSchema;
      -
      -  // Additional attributes to help define block as a TipTap node.
      -  containsInlineContent: ContainsInlineContent;
      -  render: (
      -    /**
      -     * The custom block to render
      -     */
      -    block: SpecificBlock,
      -    /**
      -     * 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;
      -  };
      -  // Exports block to external HTML. If not defined, the output will be the same
      -  // as `render(...).dom`. Used to create clipboard data when pasting outside
      -  // BlockNote.
      -  // TODO: Maybe can return undefined to ignore when serializing?
      -  toExternalHTML?: (
      -    block: SpecificBlock,
      -    editor: BlockNoteEditor
      -  ) => {
      -    dom: HTMLElement;
      -    contentDOM?: HTMLElement;
      -  };
      -  // parse?: (
      -  //   element: HTMLElement
      -  // ) => SpecificPartialBlock | undefined;
      -};
      -
      -// Defines a single block spec, which includes the props that the block has and
      -// the TipTap node used to implement it. Usually created using `createBlockSpec`
      -// though it can also be defined from scratch by providing your own TipTap node,
      -// allowing for more advanced custom blocks.
      -export type BlockSpec<
      -  BType extends string,
      -  PSchema extends PropSchema,
      -  ContainsInlineContent extends boolean
      -> = {
      -  node: TipTapNode;
      -  readonly propSchema: PSchema;
      -  // TODO: Typing
      -  // Serializes block to internal HTML. Used to create clipboard data for
      -  // pasting inside BlockNote. Same implementation as `render(...).dom` in this
      -  // block's `BlockConfig`.
      -  toInternalHTML: (
      -    block: SpecificBlock,
      -    editor: BlockNoteEditor
      -  ) => {
      -    dom: HTMLElement;
      -    contentDOM?: HTMLElement;
      -  };
      -  // Exports block to external HTML. Used to create clipboard data for pasting
      -  // outside BlockNote. Will be the same as `toInternalHTML` if `toExternalHTML`
      -  // is not defined in this block's `BlockConfig`.
      -  toExternalHTML: (
      -    block: SpecificBlock,
      -    editor: BlockNoteEditor
      -  ) => {
      -    dom: HTMLElement;
      -    contentDOM?: HTMLElement;
      -  };
      -};
      -
      -// 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 Record>
      -> = Blocks extends {
      -  [Type in keyof Blocks]: Type extends string
      -    ? Blocks[Type] extends BlockSpec
      -      ? 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<
      -  Record>
      ->;
      -
      -export type BlockSchemaWithBlock<
      -  BType extends string,
      -  PSchema extends PropSchema,
      -  ContainsInlineContent extends boolean
      -> = BlockSchema & {
      -  [k in BType]: BlockSpec;
      -};
      -
      -// 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]: {
      -    id: string;
      -    type: BType;
      -    props: Props;
      -    content: BSchema[BType]["node"]["config"]["content"] extends "inline*"
      -      ? InlineContent[]
      -      : undefined;
      -  };
      -};
      -
      -// Converts each block spec into a Block object without children, merges them
      -// into a union type, and adds a children property
      -export type Block =
      -  BlocksWithoutChildren[keyof BlocksWithoutChildren] & {
      -    children: Block[];
      -  };
      -
      -export type SpecificBlock<
      -  BSchema extends BlockSchema,
      -  BType extends keyof BSchema
      -> = BlocksWithoutChildren[BType] & {
      -  children: Block[];
      -};
      -
      -// 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]: Partial<{
      -    id: string;
      -    type: BType;
      -    props: Partial>;
      -    content: BSchema[BType]["node"]["config"]["content"] extends "inline*"
      -      ? PartialInlineContent[] | string
      -      : undefined;
      -  }>;
      -};
      -
      -// 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 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/blocks/createSpec.ts b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts
      new file mode 100644
      index 0000000000..18b0d780f4
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts
      @@ -0,0 +1,206 @@
      +import { ParseRule } from "@tiptap/pm/model";
      +import { BlockNoteEditor } from "../../../../BlockNoteEditor";
      +import { InlineContentSchema } from "../inlineContent/types";
      +import { StyleSchema } from "../styles/types";
      +import {
      +  createInternalBlockSpec,
      +  createStronglyTypedTiptapNode,
      +  getBlockFromPos,
      +  propsToAttributes,
      +  wrapInBlockStructure,
      +} 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<
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  render: (
      +    /**
      +     * The custom block to render
      +     */
      +    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, 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
      +  ) => {
      +    dom: HTMLElement;
      +    contentDOM?: HTMLElement;
      +    destroy?: () => void;
      +  };
      +  // Exports block to external HTML. If not defined, the output will be the same
      +  // as `render(...).dom`. Used to create clipboard data when pasting outside
      +  // BlockNote.
      +  // TODO: Maybe can return undefined to ignore when serializing?
      +  toExternalHTML?: (
      +    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<
      +  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"
      +      ? "inline*"
      +      : "") as T["content"] extends "inline" ? "inline*" : "",
      +    group: "blockContent",
      +    selectable: true,
      +
      +    addAttributes() {
      +      return propsToAttributes(blockConfig.propSchema);
      +    },
      +
      +    parseHTML() {
      +      return getParseRules(blockConfig, blockImplementation.parse);
      +    },
      +
      +    addNodeView() {
      +      return ({ getPos }) => {
      +        // Gets the BlockNote editor instance
      +        const editor = this.options.editor;
      +        // Gets the block
      +        const block = getBlockFromPos(
      +          getPos,
      +          editor,
      +          this.editor,
      +          blockConfig.type
      +        );
      +        // Gets the custom HTML attributes for `blockContent` nodes
      +        const blockContentDOMAttributes =
      +          this.options.domAttributes?.blockContent || {};
      +
      +        const output = blockImplementation.render(block as any, editor);
      +
      +        return wrapInBlockStructure(
      +          output,
      +          block.type,
      +          block.props,
      +          blockConfig.propSchema,
      +          blockContentDOMAttributes
      +        );
      +      };
      +    },
      +  });
      +
      +  if (node.name !== blockConfig.type) {
      +    throw new Error(
      +      "Node name does not match block type. This is a bug in BlockNote."
      +    );
      +  }
      +
      +  return createInternalBlockSpec(blockConfig, {
      +    node,
      +    toInternalHTML: (block, editor) => {
      +      const blockContentDOMAttributes =
      +        node.options.domAttributes?.blockContent || {};
      +
      +      const output = blockImplementation.render(block as any, editor as any);
      +
      +      return wrapInBlockStructure(
      +        output,
      +        block.type,
      +        block.props,
      +        blockConfig.propSchema,
      +        blockContentDOMAttributes
      +      );
      +    },
      +    toExternalHTML: (block, editor) => {
      +      const blockContentDOMAttributes =
      +        node.options.domAttributes?.blockContent || {};
      +
      +      let output = blockImplementation.toExternalHTML?.(
      +        block as any,
      +        editor as any
      +      );
      +      if (output === undefined) {
      +        output = blockImplementation.render(block as any, editor as any);
      +      }
      +
      +      return wrapInBlockStructure(
      +        output,
      +        block.type,
      +        block.props,
      +        blockConfig.propSchema,
      +        blockContentDOMAttributes
      +      );
      +    },
      +  });
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/blocks/internal.ts b/packages/core/src/extensions/Blocks/api/blocks/internal.ts
      new file mode 100644
      index 0000000000..58f8b84d50
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/blocks/internal.ts
      @@ -0,0 +1,256 @@
      +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 "./types";
      +
      +export function camelToDataKebab(str: string): string {
      +  return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
      +}
      +
      +// Function that uses the 'propSchema' of a blockConfig to create a TipTap
      +// node's `addAttributes` property.
      +// TODO: extract function
      +export function propsToAttributes(propSchema: PropSchema): Attributes {
      +  const tiptapAttributes: Record = {};
      +
      +  Object.entries(propSchema)
      +    .filter(([name, _spec]) => !inheritedProps.includes(name))
      +    .forEach(([name, spec]) => {
      +      tiptapAttributes[name] = {
      +        default: spec.default,
      +        keepOnSplit: true,
      +        // Props are displayed in kebab-case as HTML attributes. If a prop's
      +        // value is the same as its default, we don't display an HTML
      +        // attribute for it.
      +        parseHTML: (element) => {
      +          const value = element.getAttribute(camelToDataKebab(name));
      +
      +          if (value === null) {
      +            return null;
      +          }
      +
      +          if (typeof spec.default === "boolean") {
      +            if (value === "true") {
      +              return true;
      +            }
      +
      +            if (value === "false") {
      +              return false;
      +            }
      +
      +            return null;
      +          }
      +
      +          if (typeof spec.default === "number") {
      +            const asNumber = parseFloat(value);
      +            const isNumeric =
      +              !Number.isNaN(asNumber) && Number.isFinite(asNumber);
      +
      +            if (isNumeric) {
      +              return asNumber;
      +            }
      +
      +            return null;
      +          }
      +
      +          return value;
      +        },
      +        renderHTML: (attributes) =>
      +          attributes[name] !== spec.default
      +            ? {
      +                [camelToDataKebab(name)]: attributes[name],
      +              }
      +            : {},
      +      };
      +    });
      +
      +  return tiptapAttributes;
      +}
      +
      +// 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,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  getPos: (() => number) | boolean,
      +  editor: BlockNoteEditor,
      +  tipTapEditor: Editor,
      +  type: BType
      +) {
      +  // Gets position of the node
      +  if (typeof getPos === "boolean") {
      +    throw new Error(
      +      "Cannot find node position as getPos is a boolean, not a function."
      +    );
      +  }
      +  const pos = getPos();
      +  // Gets parent blockContainer node
      +  const blockContainer = tipTapEditor.state.doc.resolve(pos!).node();
      +  // Gets block identifier
      +  const blockIdentifier = blockContainer.attrs.id;
      +  // Gets the block
      +  const block = editor.getBlock(blockIdentifier)! as SpecificBlock<
      +    BSchema,
      +    BType,
      +    I,
      +    S
      +  >;
      +  if (block.type !== type) {
      +    throw new Error("Block type does not match");
      +  }
      +
      +  return block;
      +}
      +
      +// Function that wraps the `dom` element returned from 'blockConfig.render' in a
      +// `blockContent` div, which contains the block type and props as HTML
      +// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds
      +// an `inlineContent` class to it.
      +export function wrapInBlockStructure<
      +  BType extends string,
      +  PSchema extends PropSchema
      +>(
      +  element: {
      +    dom: HTMLElement;
      +    contentDOM?: HTMLElement;
      +    destroy?: () => void;
      +  },
      +  blockType: BType,
      +  blockProps: Props,
      +  propSchema: PSchema,
      +  domAttributes?: Record
      +): {
      +  dom: HTMLElement;
      +  contentDOM?: HTMLElement;
      +  destroy?: () => void;
      +} {
      +  // Creates `blockContent` element
      +  const blockContent = document.createElement("div");
      +
      +  // Adds custom HTML attributes
      +  if (domAttributes !== undefined) {
      +    for (const [attr, value] of Object.entries(domAttributes)) {
      +      if (attr !== "class") {
      +        blockContent.setAttribute(attr, value);
      +      }
      +    }
      +  }
      +  // Sets blockContent class
      +  blockContent.className = mergeCSSClasses(
      +    "bn-block-content",
      +    domAttributes?.class || ""
      +  );
      +  // Sets content type attribute
      +  blockContent.setAttribute("data-content-type", blockType);
      +  // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props
      +  // which are already added as HTML attributes to the parent `blockContent`
      +  // element (inheritedProps) and props set to their default values.
      +  for (const [prop, value] of Object.entries(blockProps)) {
      +    if (!inheritedProps.includes(prop) && value !== propSchema[prop].default) {
      +      blockContent.setAttribute(camelToDataKebab(prop), value);
      +    }
      +  }
      +
      +  blockContent.appendChild(element.dom);
      +
      +  if (element.contentDOM !== undefined) {
      +    element.contentDOM.className = mergeCSSClasses(
      +      "bn-inline-content",
      +      element.contentDOM.className
      +    );
      +  }
      +
      +  return {
      +    ...element,
      +    dom: blockContent,
      +  };
      +}
      +
      +// Helper type to keep track of the `name` and `content` properties after calling Node.create.
      +type StronglyTypedTipTapNode<
      +  Name extends string,
      +  Content extends "inline*" | "tableRow+" | ""
      +> = Node & { name: Name; config: { content: Content } };
      +
      +export function createStronglyTypedTiptapNode<
      +  Name extends string,
      +  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)
      +}
      +
      +// This helper function helps to instantiate a blockspec with a
      +// config and implementation that conform to the type of Config
      +export function createInternalBlockSpec(
      +  config: T,
      +  implementation: TiptapBlockImplementation<
      +    T,
      +    any,
      +    InlineContentSchema,
      +    StyleSchema
      +  >
      +) {
      +  return {
      +    config,
      +    implementation,
      +  } satisfies BlockSpec;
      +}
      +
      +export function createBlockSpecFromStronglyTypedTiptapNode<
      +  T extends Node,
      +  P extends PropSchema
      +>(node: T, propSchema: P, requiredExtensions?: Array) {
      +  return createInternalBlockSpec(
      +    {
      +      type: node.name as T["name"],
      +      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,
      +    },
      +    {
      +      node,
      +      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 88c7f0f640..dd15f12f74 100644
      --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts
      +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts
      @@ -1,16 +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 { Image } from "../nodes/BlockContent/ImageBlockContent/ImageBlockContent";
      -import { BlockSchema } from "./blockTypes";
      +import { Table } from "../nodes/BlockContent/TableBlockContent/TableBlockContent";
      +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,
      -} as const satisfies BlockSchema;
      +  table: Table,
      +} 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 b89ad416a5..bba83b4308 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts
      @@ -1,21 +1,25 @@
       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,
      +  tableContentToNodes,
       } from "../../../api/nodeConversions/nodeConversions";
      -
      +import { UnreachableCaseError, mergeCSSClasses } from "../../../shared/utils";
      +import { NonEditableBlockPlugin } from "../NonEditableBlockPlugin";
      +import { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin";
       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 { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin";
       import BlockAttributes from "./BlockAttributes";
      -import { mergeCSSClasses } from "../../../shared/utils";
      -import { NonEditableBlockPlugin } from "../NonEditableBlockPlugin";
       
       declare module "@tiptap/core" {
         interface Commands {
      @@ -24,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;
           };
         }
      @@ -41,6 +53,7 @@ declare module "@tiptap/core" {
        */
       export const BlockContainer = Node.create<{
         domAttributes?: BlockNoteDOMAttributes;
      +  editor: BlockNoteEditor;
       }>({
         name: "blockContainer",
         group: "blockContainer",
      @@ -157,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.
      @@ -177,59 +196,63 @@ 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.
      +                content = inlineContentToNodes(
      +                  block.content,
      +                  state.schema,
      +                  this.options.editor.styleSchema
      +                );
      +              } else if (block.content.type === "tableContent") {
      +                content = tableContentToNodes(
      +                  block.content,
      +                  state.schema,
      +                  this.options.editor.styleSchema
      +                );
                     } else {
      -                // Adds a text node with the provided styles converted into marks to the content, for each InlineContent
      -                // object.
      -                content = inlineContentToNodes(block.content, state.schema);
      +                throw new UnreachableCaseError(block.content.type);
      +              }
      +            } 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
                     }
      -
      -              // 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)
      -              );
                   }
       
      -            // 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
      @@ -240,6 +263,35 @@ 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
      +                  )
      +                )
      +                // 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(
      +                  state.schema.nodes[newType].spec.content === ""
      +                    ? new NodeSelection(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))
      +                );
                   }
       
                   // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing
      @@ -431,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) {
      @@ -452,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");
      @@ -470,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;
      @@ -500,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 96edffb2c5..50a0b74197 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts
      @@ -1,27 +1,34 @@
       import { InputRule } from "@tiptap/core";
      -import { defaultProps } from "../../../api/defaultProps";
      -import { createTipTapBlock } from "../../../api/block";
      -import { BlockSpec, PropSchema } from "../../../api/blockTypes";
       import {
      -  createDefaultBlockDOMOutputSpec,
      -  defaultBlockToHTML,
      -} from "../defaultBlockHelpers";
      +  createBlockSpecFromStronglyTypedTiptapNode,
      +  createStronglyTypedTiptapNode,
      +} from "../../../api/blocks/internal";
      +import { PropSchema } from "../../../api/blocks/types";
      +import { defaultProps } from "../../../api/defaultProps";
      +import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
       
       export const headingPropSchema = {
         ...defaultProps,
         level: { default: 1, values: [1, 2, 3] as const },
       } satisfies PropSchema;
       
      -const HeadingBlockContent = createTipTapBlock<"heading", true>({
      +const HeadingBlockContent = createStronglyTypedTiptapNode({
         name: "heading",
         content: "inline*",
      -
      +  group: "blockContent",
         addAttributes() {
           return {
             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 = createTipTapBlock<"heading", true>({
               }),
           };
         },
      -
         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 },
      @@ -112,9 +130,7 @@ const HeadingBlockContent = createTipTapBlock<"heading", true>({
         },
       });
       
      -export const Heading = {
      -  node: HeadingBlockContent,
      -  propSchema: headingPropSchema,
      -  toInternalHTML: defaultBlockToHTML,
      -  toExternalHTML: defaultBlockToHTML,
      -} satisfies BlockSpec<"heading", typeof headingPropSchema, true>;
      +export const Heading = createBlockSpecFromStronglyTypedTiptapNode(
      +  HeadingBlockContent,
      +  headingPropSchema
      +);
      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 66c44c2300..4a373c03e5 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts
      @@ -1,12 +1,18 @@
      -import { createBlockSpec } from "../../../api/block";
      -import { defaultProps } from "../../../api/defaultProps";
      +import { BlockNoteEditor } from "../../../../../BlockNoteEditor";
      +import { imageToolbarPluginKey } from "../../../../ImageToolbar/ImageToolbarPlugin";
      +
       import {
      +  CustomBlockConfig,
      +  createBlockSpec,
      +} from "../../../api/blocks/createSpec";
      +import {
      +  BlockFromConfig,
         BlockSchemaWithBlock,
         PropSchema,
      -  SpecificBlock,
      -} from "../../../api/blockTypes";
      -import { BlockNoteEditor } from "../../../../../BlockNoteEditor";
      -import { imageToolbarPluginKey } from "../../../../ImageToolbar/ImageToolbarPlugin";
      +} 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,
      @@ -44,14 +50,15 @@ const textAlignmentToAlignItems = (
       // Min image width in px.
       const minWidth = 64;
       
      +const blockConfig = {
      +  type: "image" as const,
      +  propSchema: imagePropSchema,
      +  content: "none",
      +} satisfies CustomBlockConfig;
      +
       export const renderImage = (
      -  block: SpecificBlock<
      -    BlockSchemaWithBlock<"image", typeof imagePropSchema, false>,
      -    "image"
      -  >,
      -  editor: BlockNoteEditor<
      -    BlockSchemaWithBlock<"image", typeof imagePropSchema, false>
      -  >
      +  block: BlockFromConfig,
      +  editor: BlockNoteEditor>
       ) => {
         // Wrapper element to set the image alignment, contains both image/image
         // upload dashboard and caption.
      @@ -330,47 +337,63 @@ export const renderImage = (
         };
       };
       
      -export const Image = createBlockSpec({
      -  type: "image",
      -  propSchema: imagePropSchema,
      -  containsInlineContent: false,
      -  render: renderImage,
      -  toExternalHTML: (block) => {
      -    if (block.props.url === "") {
      -      const div = document.createElement("p");
      -      div.innerHTML = "Add Image";
      +export const Image = createBlockSpec(
      +  {
      +    type: "image" as const,
      +    propSchema: imagePropSchema,
      +    content: "none",
      +  },
      +  {
      +    render: renderImage,
      +    toExternalHTML: (block) => {
      +      if (block.props.url === "") {
      +        const div = document.createElement("p");
      +        div.innerHTML = "Add Image";
      +
      +        return {
      +          dom: div,
      +        };
      +      }
       
      -      return {
      -        dom: div,
      -      };
      -    }
      +      const figure = document.createElement("figure");
       
      -    const figure = document.createElement("figure");
      +      const img = document.createElement("img");
      +      img.src = block.props.url;
      +      figure.appendChild(img);
       
      -    const img = document.createElement("img");
      -    img.src = block.props.url;
      -    figure.appendChild(img);
      +      if (block.props.caption !== "") {
      +        const figcaption = document.createElement("figcaption");
      +        figcaption.innerHTML = block.props.caption;
      +        figure.appendChild(figcaption);
      +      }
       
      -    if (block.props.caption !== "") {
      -      const figcaption = document.createElement("figcaption");
      -      figcaption.innerHTML = block.props.caption;
      -      figure.appendChild(figcaption);
      -    }
      +      return {
      +        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 {
      -      dom: figure,
      -    };
      -  },
      -  // parse: (element) => {
      -  //   if (element.tagName === "IMG") {
      -  //     return {
      -  //       type: "image",
      -  //       props: {
      -  //         url: element.getAttribute("src") || "",
      -  //       },
      -  //     };
      -  //   }
      -  //
      -  //   return;
      -  // },
      -});
      +      return undefined;
      +    },
      +  }
      +);
      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 8c618cf8ab..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
      @@ -1,21 +1,21 @@
       import { InputRule } from "@tiptap/core";
      +import {
      +  createBlockSpecFromStronglyTypedTiptapNode,
      +  createStronglyTypedTiptapNode,
      +} from "../../../../api/blocks/internal";
      +import { PropSchema } from "../../../../api/blocks/types";
       import { defaultProps } from "../../../../api/defaultProps";
      -import { createTipTapBlock } from "../../../../api/block";
      -import { BlockSpec, PropSchema } from "../../../../api/blockTypes";
      +import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers";
       import { handleEnter } from "../ListItemKeyboardShortcuts";
      -import {
      -  createDefaultBlockDOMOutputSpec,
      -  defaultBlockToHTML,
      -} from "../../defaultBlockHelpers";
       
       export const bulletListItemPropSchema = {
         ...defaultProps,
       } satisfies PropSchema;
       
      -const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({
      +const BulletListItemBlockContent = createStronglyTypedTiptapNode({
         name: "bulletListItem",
         content: "inline*",
      -
      +  group: "blockContent",
         addInputRules() {
           return [
             // Creates an unordered list when starting with "-", "+", or "*".
      @@ -48,6 +48,9 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({
         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 = createTipTapBlock<"bulletListItem", true>({
                   return false;
                 }
       
      -          if (parent.tagName === "UL") {
      +          if (
      +            parent.tagName === "UL" ||
      +            (parent.tagName === "DIV" && parent.parentElement!.tagName === "UL")
      +          ) {
                   return {};
                 }
       
      @@ -111,9 +117,7 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({
         },
       });
       
      -export const BulletListItem = {
      -  node: BulletListItemBlockContent,
      -  propSchema: bulletListItemPropSchema,
      -  toInternalHTML: defaultBlockToHTML,
      -  toExternalHTML: defaultBlockToHTML,
      -} satisfies BlockSpec<"bulletListItem", typeof bulletListItemPropSchema, true>;
      +export const BulletListItem = createBlockSpecFromStronglyTypedTiptapNode(
      +  BulletListItemBlockContent,
      +  bulletListItemPropSchema
      +);
      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 c0c6c25ec3..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
      @@ -1,25 +1,22 @@
       import { InputRule } from "@tiptap/core";
      +import {
      +  createBlockSpecFromStronglyTypedTiptapNode,
      +  createStronglyTypedTiptapNode,
      +} from "../../../../api/blocks/internal";
      +import { PropSchema } from "../../../../api/blocks/types";
       import { defaultProps } from "../../../../api/defaultProps";
      -import { createTipTapBlock } from "../../../../api/block";
      -import { BlockSpec, PropSchema } from "../../../../api/blockTypes";
      +import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers";
       import { handleEnter } from "../ListItemKeyboardShortcuts";
       import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin";
      -import {
      -  createDefaultBlockDOMOutputSpec,
      -  defaultBlockToHTML,
      -} from "../../defaultBlockHelpers";
       
       export const numberedListItemPropSchema = {
         ...defaultProps,
       } satisfies PropSchema;
       
      -const NumberedListItemBlockContent = createTipTapBlock<
      -  "numberedListItem",
      -  true
      ->({
      +const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
         name: "numberedListItem",
         content: "inline*",
      -
      +  group: "blockContent",
         addAttributes() {
           return {
             index: {
      @@ -69,6 +66,9 @@ const NumberedListItemBlockContent = createTipTapBlock<
       
         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)
             {
      @@ -84,7 +84,10 @@ const NumberedListItemBlockContent = createTipTapBlock<
                   return false;
                 }
       
      -          if (parent.tagName === "OL") {
      +          if (
      +            parent.tagName === "OL" ||
      +            (parent.tagName === "DIV" && parent.parentElement!.tagName === "OL")
      +          ) {
                   return {};
                 }
       
      @@ -135,13 +138,7 @@ const NumberedListItemBlockContent = createTipTapBlock<
         },
       });
       
      -export const NumberedListItem = {
      -  node: NumberedListItemBlockContent,
      -  propSchema: numberedListItemPropSchema,
      -  toInternalHTML: defaultBlockToHTML,
      -  toExternalHTML: defaultBlockToHTML,
      -} satisfies BlockSpec<
      -  "numberedListItem",
      -  typeof numberedListItemPropSchema,
      -  true
      ->;
      +export const NumberedListItem = createBlockSpecFromStronglyTypedTiptapNode(
      +  NumberedListItemBlockContent,
      +  numberedListItemPropSchema
      +);
      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 dbacc61e63..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,21 +1,21 @@
      -import { defaultProps } from "../../../api/defaultProps";
      -import { createTipTapBlock } from "../../../api/block";
      -import { BlockSpec } from "../../../api/blockTypes";
       import {
      -  createDefaultBlockDOMOutputSpec,
      -  defaultBlockToHTML,
      -} from "../defaultBlockHelpers";
      +  createBlockSpecFromStronglyTypedTiptapNode,
      +  createStronglyTypedTiptapNode,
      +} from "../../../api/blocks/internal";
      +import { defaultProps } from "../../../api/defaultProps";
      +import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
       
       export const paragraphPropSchema = {
         ...defaultProps,
       };
       
      -export const ParagraphBlockContent = createTipTapBlock<"paragraph", true>({
      +export const ParagraphBlockContent = createStronglyTypedTiptapNode({
         name: "paragraph",
         content: "inline*",
      -
      +  group: "blockContent",
         parseHTML() {
           return [
      +      { tag: "div[data-content-type=" + this.name + "]" },
             {
               tag: "p",
               priority: 200,
      @@ -37,9 +37,7 @@ export const ParagraphBlockContent = createTipTapBlock<"paragraph", true>({
         },
       });
       
      -export const Paragraph = {
      -  node: ParagraphBlockContent,
      -  propSchema: paragraphPropSchema,
      -  toInternalHTML: defaultBlockToHTML,
      -  toExternalHTML: defaultBlockToHTML,
      -} satisfies BlockSpec<"paragraph", typeof paragraphPropSchema, true>;
      +export const Paragraph = createBlockSpecFromStronglyTypedTiptapNode(
      +  ParagraphBlockContent,
      +  paragraphPropSchema
      +);
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts
      new file mode 100644
      index 0000000000..8807586fdc
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts
      @@ -0,0 +1,73 @@
      +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/blocks/internal";
      +import { defaultProps } from "../../../api/defaultProps";
      +import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
      +import { TableExtension } from "./TableExtension";
      +
      +export const tablePropSchema = {
      +  ...defaultProps,
      +};
      +
      +export const TableBlockContent = createStronglyTypedTiptapNode({
      +  name: "table",
      +  content: "tableRow+",
      +  group: "blockContent",
      +  tableRole: "table",
      +
      +  isolating: true,
      +
      +  parseHTML() {
      +    return [{ tag: "table" }];
      +  },
      +
      +  renderHTML({ HTMLAttributes }) {
      +    return createDefaultBlockDOMOutputSpec(
      +      this.name,
      +      "table",
      +      {
      +        ...(this.options.domAttributes?.blockContent || {}),
      +        ...HTMLAttributes,
      +      },
      +      this.options.domAttributes?.inlineContent || {}
      +    );
      +  },
      +});
      +
      +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",
      +    }),
      +    TableCell.extend({
      +      content: "tableContent",
      +    }),
      +    TableRow,
      +  ]
      +);
      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..14bc48310c
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts
      @@ -0,0 +1,54 @@
      +import { callOrReturn, Extension, getExtensionField } from "@tiptap/core";
      +import { columnResizing, tableEditing } from "prosemirror-tables";
      +
      +export const TableExtension = Extension.create({
      +  name: "BlockNoteTableExtension",
      +
      +  addProseMirrorPlugins: () => {
      +    return [
      +      columnResizing({
      +        cellMinWidth: 100,
      +      }),
      +      tableEditing(),
      +    ];
      +  },
      +
      +  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: () => {
      +        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,
      +      options: extension.options,
      +      storage: extension.storage,
      +    };
      +
      +    return {
      +      tableRole: callOrReturn(
      +        getExtensionField(extension, "tableRole", context)
      +      ),
      +    };
      +  },
      +});
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts
      index feb634a122..b5ade2c570 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts
      @@ -1,11 +1,9 @@
      -import { mergeCSSClasses } from "../../../../shared/utils";
      -import {
      -  BlockSchemaWithBlock,
      -  PropSchema,
      -  SpecificBlock,
      -} from "../../api/blockTypes";
       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
      @@ -56,23 +54,21 @@ export function createDefaultBlockDOMOutputSpec(
       // node's `renderHTML` method to do the conversion by using a default
       // `DOMSerializer`.
       export const defaultBlockToHTML = <
      -  BType extends string,
      -  PSchema extends PropSchema,
      -  ContainsInlineContent extends boolean
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       >(
      -  block: SpecificBlock<
      -    BlockSchemaWithBlock,
      -    BType
      -  >,
      -  editor: BlockNoteEditor<
      -    BlockSchemaWithBlock
      -  >
      +  block: Block,
      +  editor: BlockNoteEditor
       ): {
         dom: HTMLElement;
         contentDOM?: HTMLElement;
       } => {
      -  const node = blockToNode(block as any, 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 40a8cd2d59..9ecb162d3a 100644
      --- a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts
      +++ b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts
      @@ -1,48 +1,41 @@
      -import { Node as PMNode } from "prosemirror-model";
       import { EditorState, Plugin, PluginKey } from "prosemirror-state";
       import { EditorView } from "prosemirror-view";
      +
      +import { BlockNoteEditor } from "../../BlockNoteEditor";
       import {
         BaseUiElementCallbacks,
         BaseUiElementState,
      -  BlockNoteEditor,
      -  BlockSchema,
      -  BlockSpec,
      -  SpecificBlock,
      -} from "../..";
      +} from "../../shared/BaseUiElementTypes";
       import { EventEmitter } from "../../shared/EventEmitter";
      -
      +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: SpecificBlock<
      -    BlockSchema & {
      -      image: BlockSpec<
      -        "image",
      -        {
      -          src: { default: string };
      -        },
      -        false
      -      >;
      -    },
      -    "image"
      -  >;
      +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;
       
      -  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,
      -    updateImageToolbar: (imageToolbarState: ImageToolbarState) => void
      +    updateImageToolbar: (
      +      imageToolbarState: ImageToolbarState
      +    ) => void
         ) {
           this.updateImageToolbar = () => {
             if (!this.imageToolbarState) {
      @@ -112,18 +105,7 @@ export class ImageToolbarView {
       
         update(view: EditorView, prevState: EditorState) {
           const pluginState: {
      -      block: SpecificBlock<
      -        BlockSchema & {
      -          image: BlockSpec<
      -            "image",
      -            {
      -              src: { default: string };
      -            },
      -            false
      -          >;
      -        },
      -        "image"
      -      >;
      +      block: SpecificBlock;
           } = this.pluginKey.getState(view.state);
       
           if (!this.imageToolbarState?.show && pluginState.block) {
      @@ -168,28 +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:
      -        | SpecificBlock<
      -            BlockSchema & {
      -              image: BlockSpec<
      -                "image",
      -                {
      -                  src: { default: string };
      -                },
      -                false
      -              >;
      -            },
      -            "image"
      -          >
      -        | undefined;
      +      block: SpecificBlock | undefined;
           }>({
             key: imageToolbarPluginKey,
             view: (editorView) => {
      @@ -210,20 +181,8 @@ export class ImageToolbarProsemirrorPlugin<
                 };
               },
               apply: (transaction) => {
      -          const block:
      -            | SpecificBlock<
      -                BlockSchema & {
      -                  image: BlockSpec<
      -                    "image",
      -                    {
      -                      src: { default: string };
      -                    },
      -                    false
      -                  >;
      -                },
      -                "image"
      -              >
      -            | undefined = transaction.getMeta(imageToolbarPluginKey)?.block;
      +          const block: SpecificBlock | undefined =
      +            transaction.getMeta(imageToolbarPluginKey)?.block;
       
                 return {
                   block,
      @@ -233,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 f2dba2aa61..ed87b5df07 100644
      --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
      +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
      @@ -3,24 +3,30 @@ import { Node } from "prosemirror-model";
       import { NodeSelection, Plugin, PluginKey, Selection } from "prosemirror-state";
       import { EditorView } from "prosemirror-view";
       import { BlockNoteEditor } from "../../BlockNoteEditor";
      +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";
      -import { createInternalHTMLSerializer } from "../../api/serialization/html/internalHTMLSerializer";
      -import { createExternalHTMLExporter } from "../../api/serialization/html/externalHTMLExporter";
      -import { markdown } from "../../api/formatConversions/formatConversions";
       
       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;
       };
       
      -function getDraggableBlockFromCoords(
      +export function getDraggableBlockFromCoords(
         coords: { left: number; top: number },
         view: EditorView
       ) {
      @@ -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 8646ee1b99..52a9bc9337 100644
      --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts
      +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts
      @@ -1,42 +1,88 @@
       import { BlockNoteEditor } from "../../BlockNoteEditor";
      -import { BlockSchema, PartialBlock } from "../Blocks/api/blockTypes";
      -import { BaseSlashMenuItem } from "./BaseSlashMenuItem";
      +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";
      +
      +// 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<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(editor: BlockNoteEditor) {
      +  let block = editor.getTextCursorPosition().block;
      +  let contentType = editor.blockSchema[block.type].content as
      +    | "inline"
      +    | "table"
      +    | "none";
      +
      +  while (contentType === "none") {
      +    editor.setTextCursorPosition(block, "end");
      +    block = editor.getTextCursorPosition().nextBlock!;
      +    contentType = editor.blockSchema[block.type].content as
      +      | "inline"
      +      | "table"
      +      | "none";
      +  }
      +}
       
      -function insertOrUpdateBlock(
      -  editor: BlockNoteEditor,
      -  block: PartialBlock
      -) {
      +// 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<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  editor: BlockNoteEditor,
      +  block: PartialBlock
      +): Block {
         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 &&
      +      isStyledTextInlineContent(currentBlock.content[0]) &&
             currentBlock.content[0].type === "text" &&
             currentBlock.content[0].text === "/") ||
      -    currentBlock.content.length === 0
      +      currentBlock.content.length === 0)
         ) {
           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;
      +  setSelectionToNextContentEditableBlock(editor);
      +
      +  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.propSchema) {
           // Command for creating a level 1 heading
      @@ -48,7 +94,7 @@ export const getDefaultSlashMenuItems = (
                 insertOrUpdateBlock(editor, {
                   type: "heading",
                   props: { level: 1 },
      -          } as PartialBlock),
      +          } as PartialBlock),
             });
           }
       
      @@ -61,7 +107,7 @@ export const getDefaultSlashMenuItems = (
                 insertOrUpdateBlock(editor, {
                   type: "heading",
                   props: { level: 2 },
      -          } as PartialBlock),
      +          } as PartialBlock),
             });
           }
       
      @@ -74,7 +120,7 @@ export const getDefaultSlashMenuItems = (
                 insertOrUpdateBlock(editor, {
                   type: "heading",
                   props: { level: 3 },
      -          } as PartialBlock),
      +          } as PartialBlock),
             });
           }
         }
      @@ -86,7 +132,7 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) =>
               insertOrUpdateBlock(editor, {
                 type: "bulletListItem",
      -        } as PartialBlock),
      +        }),
           });
         }
       
      @@ -97,7 +143,7 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) =>
               insertOrUpdateBlock(editor, {
                 type: "numberedListItem",
      -        } as PartialBlock),
      +        }),
           });
         }
       
      @@ -108,7 +154,35 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) =>
               insertOrUpdateBlock(editor, {
                 type: "paragraph",
      -        } as PartialBlock),
      +        }),
      +    });
      +  }
      +
      +  if ("table" in schema) {
      +    slashMenuItems.push({
      +      name: "Table",
      +      aliases: ["table"],
      +      execute: (editor) => {
      +        insertOrUpdateBlock(editor, {
      +          type: "table",
      +          content: {
      +            type: "tableContent",
      +            rows: [
      +              // TODO: replace with empty content before merging
      +              {
      +                cells: [
      +                  "ab",
      +                  [{ type: "text", styles: { bold: true }, text: "hello" }],
      +                  "",
      +                ],
      +              },
      +              {
      +                cells: ["", "cd", ""],
      +              },
      +            ],
      +          },
      +        } as PartialBlock);
      +      },
           });
         }
       
      @@ -127,19 +201,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,
                 })
               );
             },
      diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
      new file mode 100644
      index 0000000000..f600bbf4d4
      --- /dev/null
      +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
      @@ -0,0 +1,617 @@
      +import { Plugin, PluginKey, PluginView } from "prosemirror-state";
      +import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
      +import {
      +  Block,
      +  BlockFromConfigNoChildren,
      +  BlockNoteEditor,
      +  BlockSchemaWithBlock,
      +  DefaultBlockSchema,
      +  InlineContentSchema,
      +  PartialBlock,
      +  SpecificBlock,
      +  StyleSchema,
      +  getDraggableBlockFromCoords,
      +  nodeToBlock,
      +} from "../..";
      +import { EventEmitter } from "../../shared/EventEmitter";
      +
      +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<
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  show: boolean;
      +  referencePosCell: DOMRect;
      +  referencePosTable: DOMRect;
      +
      +  block: BlockFromConfigNoChildren;
      +  colIndex: number;
      +  rowIndex: number;
      +
      +  draggingState:
      +    | {
      +        draggedCellOrientation: "row" | "col";
      +        originalIndex: number;
      +        mousePos: number;
      +      }
      +    | undefined;
      +};
      +
      +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: Element | null): Element | null {
      +  while (target && target.nodeName !== "TD" && target.nodeName !== "TH") {
      +    target =
      +      target.classList && target.classList.contains("ProseMirror")
      +        ? null
      +        : (target.parentNode as Element);
      +  }
      +  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<
      +  BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> implements PluginView
      +{
      +  public state?: TableHandlesState;
      +  public updateState: () => void;
      +
      +  public tableId: string | undefined;
      +  public tablePos: number | undefined;
      +
      +  public menuFrozen = false;
      +
      +  public prevWasEditable: boolean | null = null;
      +
      +  constructor(
      +    private readonly editor: BlockNoteEditor,
      +    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);
      +
      +    document.addEventListener("dragover", this.dragOverHandler);
      +    document.addEventListener("drop", this.dropHandler);
      +
      +    document.addEventListener("scroll", this.scrollHandler);
      +  }
      +
      +  mouseMoveHandler = (event: MouseEvent) => {
      +    if (this.menuFrozen) {
      +      return;
      +    }
      +
      +    const target = domCellAround(event.target as HTMLElement);
      +
      +    if (!target || !this.editor.isEditable) {
      +      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);
      +    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.state.show &&
      +      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.blockSchema,
      +        this.editor.inlineContentSchema,
      +        this.editor.styleSchema,
      +        this.editor.blockCache
      +      );
      +      this.tablePos = pos + 1;
      +
      +      return false;
      +    });
      +
      +    this.state = {
      +      show: true,
      +      referencePosCell: cellRect,
      +      referencePosTable: tableRect,
      +
      +      block: block! as SpecificBlock,
      +      colIndex: colIndex,
      +      rowIndex: rowIndex,
      +
      +      draggingState: undefined,
      +    };
      +    this.updateState();
      +
      +    return false;
      +  };
      +
      +  dragOverHandler = (event: DragEvent) => {
      +    if (this.state?.draggingState === undefined) {
      +      return;
      +    }
      +
      +    event.preventDefault();
      +    event.dataTransfer!.dropEffect = "move";
      +
      +    hideElementsWithClassNames([
      +      "column-resize-handle",
      +      "prosemirror-dropcursor-block",
      +      "prosemirror-dropcursor-inline",
      +    ]);
      +
      +    // 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 + 1),
      +        this.state.referencePosTable.right - 1
      +      ),
      +      top: Math.min(
      +        Math.max(event.clientY, this.state.referencePosTable.top + 1),
      +        this.state.referencePosTable.bottom - 1
      +      ),
      +    };
      +
      +    // 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);
      +
      +    // 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
      +        : this.state.colIndex;
      +    const newIndex =
      +      this.state.draggingState.draggedCellOrientation === "row"
      +        ? rowIndex
      +        : colIndex;
      +    const dispatchDecorationsTransaction = newIndex !== oldIndex;
      +
      +    // 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.draggingState.draggedCellOrientation === "row"
      +        ? boundedMouseCoords.top
      +        : boundedMouseCoords.left;
      +    if (this.state.draggingState.mousePos !== mousePos) {
      +      this.state.draggingState.mousePos = mousePos;
      +
      +      emitStateUpdate = true;
      +    }
      +
      +    // Emits a state update if any of the fields have changed.
      +    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)
      +      );
      +    }
      +  };
      +
      +  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(
      +        `[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("drop", this.dropHandler);
      +
      +    document.removeEventListener("scroll", this.scrollHandler);
      +  }
      +}
      +
      +export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin");
      +
      +export class TableHandlesProsemirrorPlugin<
      +  BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> extends EventEmitter {
      +  private view: TableHandlesView | undefined;
      +  public readonly plugin: Plugin;
      +
      +  constructor(private readonly editor: BlockNoteEditor) {
      +    super();
      +    this.plugin = new Plugin({
      +      key: tableHandlesPluginKey,
      +      view: (editorView) => {
      +        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
      +      // or column. The decorations are updated in the `dragOverHandler` method.
      +      props: {
      +        decorations: (state) => {
      +          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 (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(this.view.tablePos + 1);
      +          const tableNode = tableResolvedPos.node();
      +
      +          if (this.view.state.draggingState.draggedCellOrientation === "row") {
      +            // Gets the row at the new index.
      +            const rowResolvedPos = state.doc.resolve(
      +              tableResolvedPos.posAtIndex(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 +
      +                (newIndex > this.view.state.draggingState.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";
      +                  // 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 (
      +                    newIndex > this.view!.state!.draggingState!.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(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 +
      +                (newIndex > this.view.state.draggingState.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";
      +                  // 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 (
      +                    newIndex > this.view!.state!.draggingState!.originalIndex
      +                  ) {
      +                    widget.style.right = "-2px";
      +                  } else {
      +                    widget.style.left = "-3px";
      +                  }
      +                  widget.style.width = "4px";
      +
      +                  return widget;
      +                })
      +              );
      +            }
      +          }
      +
      +          return DecorationSet.create(state.doc, decorations);
      +        },
      +      },
      +    });
      +  }
      +
      +  public onUpdate(callback: (state: TableHandlesState) => void) {
      +    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;
      +  }) => {
      +    if (this.view!.state === undefined) {
      +      throw new Error(
      +        "Attempted to drag table column, but no table block was hovered prior."
      +      );
      +    }
      +
      +    this.view!.state.draggingState = {
      +      draggedCellOrientation: "col",
      +      originalIndex: this.view!.state.colIndex,
      +      mousePos: event.clientX,
      +    };
      +    this.view!.updateState();
      +
      +    this.editor._tiptapEditor.view.dispatch(
      +      this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, {
      +        draggedCellOrientation:
      +          this.view!.state.draggingState.draggedCellOrientation,
      +        originalIndex: this.view!.state.colIndex,
      +        newIndex: this.view!.state.colIndex,
      +        tablePos: this.view!.tablePos,
      +      })
      +    );
      +
      +    setHiddenDragImage();
      +    event.dataTransfer!.setDragImage(dragImageElement!, 0, 0);
      +    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;
      +  }) => {
      +    if (this.view!.state === undefined) {
      +      throw new Error(
      +        "Attempted to drag table row, but no table block was hovered prior."
      +      );
      +    }
      +
      +    this.view!.state.draggingState = {
      +      draggedCellOrientation: "row",
      +      originalIndex: this.view!.state.rowIndex,
      +      mousePos: event.clientY,
      +    };
      +    this.view!.updateState();
      +
      +    this.editor._tiptapEditor.view.dispatch(
      +      this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, {
      +        draggedCellOrientation:
      +          this.view!.state.draggingState.draggedCellOrientation,
      +        originalIndex: this.view!.state.rowIndex,
      +        newIndex: this.view!.state.rowIndex,
      +        tablePos: this.view!.tablePos,
      +      })
      +    );
      +
      +    setHiddenDragImage();
      +    event.dataTransfer!.setDragImage(dragImageElement!, 0, 0);
      +    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(
      +        "Attempted to drag table row, but no table block was hovered prior."
      +      );
      +    }
      +
      +    this.view!.state.draggingState = undefined;
      +    this.view!.updateState();
      +
      +    this.editor._tiptapEditor.view.dispatch(
      +      this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, null)
      +    );
      +
      +    unsetHiddenDragImage();
      +  };
      +
      +  /**
      +   * 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/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 118973d8ca..41637442bb 100644
      --- a/packages/core/src/index.ts
      +++ b/packages/core/src/index.ts
      @@ -1,13 +1,20 @@
       export * from "./BlockNoteEditor";
       export * from "./BlockNoteExtensions";
      -export * from "./extensions/Blocks/api/block";
      -export * from "./extensions/Blocks/api/blockTypes";
      -export * from "./extensions/Blocks/api/defaultProps";
      +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/inlineContentTypes";
      +export * from "./extensions/Blocks/api/defaultProps";
      +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 "./api/serialization/html/internalHTMLSerializer";
      -export * from "./api/serialization/html/externalHTMLExporter";
      +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";
      @@ -17,7 +24,12 @@ 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";
       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/BlockNoteTheme.ts b/packages/react/src/BlockNoteTheme.ts
      index c7302ca71f..156eaced8a 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
      @@ -60,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 = {
      @@ -130,6 +135,40 @@ 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: lightShadow,
      +              color: theme.colors.sideMenu,
      +              ":hover, div.bn-table-handle-dragging": {
      +                backgroundColor: theme.colors.hovered.background,
      +              },
      +              cursor: "pointer",
      +            },
      +            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/BlockNoteView.tsx b/packages/react/src/BlockNoteView.tsx
      index a5f3ee308b..c65cbc9b26 100644
      --- a/packages/react/src/BlockNoteView.tsx
      +++ b/packages/react/src/BlockNoteView.tsx
      @@ -1,20 +1,31 @@
      -import { BlockNoteEditor, BlockSchema, mergeCSSClasses } from "@blocknote/core";
      -import { createStyles, MantineProvider } from "@mantine/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";
       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(
      +function BaseBlockNoteView<
      +  BSchema extends BlockSchema,
      +  ISchema extends InlineContentSchema,
      +  SSchema extends StyleSchema
      +>(
         props: {
      -    editor: BlockNoteEditor;
      +    editor: BlockNoteEditor;
           children?: ReactNode;
         } & HTMLAttributes
       ) {
      @@ -36,15 +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 cbe1b482b0..cf2dd29e2b 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx
      @@ -1,3 +1,4 @@
      +import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core";
       import {
         ChangeEvent,
         KeyboardEvent,
      @@ -6,17 +7,16 @@ import {
         useMemo,
         useState,
       } from "react";
      -import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core";
       import { RiText } from "react-icons/ri";
       
       import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton";
      -import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton";
       import { ToolbarInputDropdown } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdown";
      -import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks";
      +import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton";
       import { ToolbarInputDropdownItem } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownItem";
      +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks";
       
       export const ImageCaptionButton = (props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
       }) => {
         const selectedBlocks = useSelectedBlocks(props.editor);
       
      @@ -28,17 +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"].propSchema &&
      -      typeof props.editor.schema["image"].propSchema.caption.default ===
      +      "caption" in props.editor.blockSchema["image"].propSchema &&
      +      typeof props.editor.blockSchema["image"].propSchema.caption.default ===
               "string" &&
      -      props.editor.schema["image"].propSchema.caption.values === undefined &&
      +      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"].propSchema &&
      -      typeof props.editor.schema["image"].propSchema.url.default === "string" &&
      -      props.editor.schema["image"].propSchema.url.values === undefined &&
      +      "url" in props.editor.blockSchema["image"].propSchema &&
      +      typeof props.editor.blockSchema["image"].propSchema.url.default ===
      +        "string" &&
      +      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(
      @@ -62,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 34868362bc..4d15de5be6 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx
      @@ -1,10 +1,5 @@
      +import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core";
       import { useMemo, useState } from "react";
      -import {
      -  Block,
      -  BlockNoteEditor,
      -  BlockSchema,
      -  PartialBlock,
      -} from "@blocknote/core";
       import { IconType } from "react-icons";
       import {
         RiH1,
      @@ -25,7 +20,7 @@ export type BlockTypeDropdownItem = {
         type: string;
         props?: Record;
         icon: IconType;
      -  isSelected: (block: Block) => boolean;
      +  isSelected: (block: Block) => boolean;
       };
       
       export const defaultBlockTypeDropdownItems: BlockTypeDropdownItem[] = [
      @@ -92,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].propSchema;
      +        const propSchema = props.editor.blockSchema[item.type].propSchema;
       
               // Checks if the prop exists for the block type
               if (!(prop in propSchema)) {
      @@ -129,9 +124,9 @@ export const BlockTypeDropdown = (props: {
       
             for (const block of selectedBlocks) {
               props.editor.updateBlock(block, {
      -          type: item.type,
      -          props: item.props,
      -        } as PartialBlock);
      +          type: item.type as any,
      +          props: item.props as any,
      +        });
             }
           };
       
      @@ -139,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 9400cf75d9..7bcfdd8615 100644
      --- a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx
      +++ b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx
      @@ -2,9 +2,10 @@ import {
         BaseUiElementState,
         BlockNoteEditor,
         BlockSchema,
      -  BlockSpec,
         DefaultBlockSchema,
      +  DefaultInlineContentSchema,
         ImageToolbarState,
      +  InlineContentSchema,
         SpecificBlock,
       } from "@blocknote/core";
       import Tippy, { tippy } from "@tippyjs/react";
      @@ -13,32 +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<
      -    SpecificBlock<
      -      BlockSchema & {
      -        image: BlockSpec<
      -          "image",
      -          {
      -            src: { default: string };
      -          },
      -          false
      -        >;
      -      },
      -      "image"
      -    >
      -  >();
      +  const [block, setBlock] = useState>();
       
         const referencePos = useRef();
       
      diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx
      index af8c941241..b5f48dc2df 100644
      --- a/packages/react/src/ReactBlockSpec.tsx
      +++ b/packages/react/src/ReactBlockSpec.tsx
      @@ -1,18 +1,21 @@
       import {
      -  BlockConfig,
      -  BlockNoteDOMAttributes,
      +  BlockFromConfig,
         BlockNoteEditor,
         BlockSchemaWithBlock,
      -  BlockSpec,
         camelToDataKebab,
      -  createTipTapBlock,
      +  createInternalBlockSpec,
      +  createStronglyTypedTiptapNode,
      +  CustomBlockConfig,
         getBlockFromPos,
      +  getParseRules,
         inheritedProps,
      +  InlineContentSchema,
         mergeCSSClasses,
      -  parse,
      +  PartialBlockFromConfig,
         Props,
         PropSchema,
         propsToAttributes,
      +  StyleSchema,
       } from "@blocknote/core";
       import {
         NodeViewContent,
      @@ -20,62 +23,28 @@ import {
         NodeViewWrapper,
         ReactNodeViewRenderer,
       } from "@tiptap/react";
      -import { renderToString } from "react-dom/server";
      -import { createContext, ElementType, FC, HTMLProps, useContext } from "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 ReactBlockConfig<
      -  BType extends string,
      -  PSchema extends PropSchema,
      -  ContainsInlineContent extends boolean,
      -  BSchema extends BlockSchemaWithBlock
      -> = Omit<
      -  BlockConfig,
      -  "render" | "toExternalHTML"
      -> & {
      +export type ReactCustomBlockImplementation<
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
         render: FC<{
      -    block: Parameters<
      -      BlockConfig["render"]
      -    >[0];
      -    editor: Parameters<
      -      BlockConfig["render"]
      -    >[1];
      +    block: BlockFromConfig;
      +    editor: BlockNoteEditor, I, S>;
      +    contentRef: (node: HTMLElement | null) => void;
         }>;
         toExternalHTML?: FC<{
      -    block: Parameters<
      -      BlockConfig["render"]
      -    >[0];
      -    editor: Parameters<
      -      BlockConfig["render"]
      -    >[1];
      +    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
      @@ -128,33 +97,27 @@ 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<
      -  BType extends string,
      -  PSchema extends PropSchema,
      -  ContainsInlineContent extends boolean,
      -  BSchema extends BlockSchemaWithBlock
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       >(
      -  blockConfig: ReactBlockConfig
      -): BlockSpec {
      -  const node = createTipTapBlock<
      -    BType,
      -    ContainsInlineContent,
      -    {
      -      editor: BlockNoteEditor;
      -      domAttributes?: BlockNoteDOMAttributes;
      -    }
      -  >({
      -    name: blockConfig.type,
      -    content: (blockConfig.containsInlineContent
      +  blockConfig: T,
      +  blockImplementation: ReactCustomBlockImplementation
      +) {
      +  const node = createStronglyTypedTiptapNode({
      +    name: blockConfig.type as T["type"],
      +    content: (blockConfig.content === "inline"
             ? "inline*"
      -      : "") as ContainsInlineContent extends true ? "inline*" : "",
      +      : "") as T["content"] extends "inline" ? "inline*" : "",
      +    group: "blockContent",
           selectable: true,
       
           addAttributes() {
      -      return propsToAttributes(blockConfig);
      +      return propsToAttributes(blockConfig.propSchema);
           },
       
           parseHTML() {
      -      return parse(blockConfig);
      +      return getParseRules(blockConfig, blockImplementation.parse);
           },
       
           addNodeView() {
      @@ -162,25 +125,24 @@ export function createReactBlockSpec<
               ReactNodeViewRenderer(
                 (props: NodeViewProps) => {
                   // Gets the BlockNote editor instance
      -            const editor = this.options.editor! as BlockNoteEditor<
      -              BSchema & {
      -                [k in BType]: BlockSpec;
      -              }
      -            >;
      +            const editor = this.options.editor! as BlockNoteEditor;
                   // Gets the block
      -            const block = getBlockFromPos<
      -              BType,
      -              PSchema,
      -              ContainsInlineContent,
      -              BSchema
      -            >(props.getPos, editor, this.editor, blockConfig.type);
      +            const block = getBlockFromPos(
      +              props.getPos,
      +              editor,
      +              this.editor,
      +              blockConfig.type
      +            ) as any;
                   // Gets the custom HTML attributes for `blockContent` nodes
                   const blockContentDOMAttributes =
                     this.options.domAttributes?.blockContent || {};
       
      -            const Content = blockConfig.render;
      +            // 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,
      @@ -196,55 +158,50 @@ export function createReactBlockSpec<
           },
         });
       
      -  return {
      +  return createInternalBlockSpec(blockConfig, {
           node: node,
      -    propSchema: blockConfig.propSchema,
           toInternalHTML: (block, editor) => {
             const blockContentDOMAttributes =
               node.options.domAttributes?.blockContent || {};
       
      -      const Content = blockConfig.render;
      -      const BlockContent = reactWrapInBlockStructure(
      -        ,
      -        block.type as BType,
      -        block.props as 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.render;
      +
      +      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 = blockConfig.toExternalHTML;
      -      if (Content === undefined) {
      -        Content = blockConfig.render;
      -      }
      -      const BlockContent = reactWrapInBlockStructure(
      -        ,
      -        block.type as BType,
      -        block.props as 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 b8b1fe6a18..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,
      @@ -12,6 +14,7 @@ import {
         RiImage2Fill,
         RiListOrdered,
         RiListUnordered,
      +  RiTable2,
         RiText,
       } from "react-icons/ri";
       import { formatKeyboardShortcut } from "../utils";
      @@ -21,7 +24,7 @@ const extraFields: Record<
         string,
         Omit<
           ReactSlashMenuItem,
      -    keyof BaseSlashMenuItem
      +    keyof BaseSlashMenuItem
         >
       > = {
         Heading: {
      @@ -60,6 +63,12 @@ const extraFields: Record<
           hint: "Used for the body of your document",
           shortcut: formatKeyboardShortcut("Mod-Alt-0"),
         },
      +  Table: {
      +    group: "Advanced",
      +    icon: ,
      +    hint: "Used for for tables",
      +    // shortcut: formatKeyboardShortcut("Mod-Alt-0"),
      +  },
         Image: {
           group: "Media",
           icon: ,
      @@ -67,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
      new file mode 100644
      index 0000000000..e7e98d4f1b
      --- /dev/null
      +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx
      @@ -0,0 +1,23 @@
      +import { BlockSchemaWithBlock, DefaultBlockSchema } from "@blocknote/core";
      +import { MdDragIndicator } from "react-icons/md";
      +import { TableHandle } from "./TableHandle";
      +import { TableHandleProps } from "./TableHandlePositioner";
      +
      +export const DefaultTableHandle = <
      +  BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>
      +>(
      +  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..aca6b9fbe5 --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandle.tsx @@ -0,0 +1,62 @@ +import { BlockSchemaWithBlock, DefaultBlockSchema } from "@blocknote/core"; +import { Menu, createStyles } from "@mantine/core"; +import { ReactNode, useState } from "react"; +import { DefaultTableHandleMenu } from "./TableHandleMenu/DefaultTableHandleMenu"; +import { TableHandleProps } from "./TableHandlePositioner"; + +export const TableHandle = < + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> +>( + 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..46d587e0cb --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx @@ -0,0 +1,73 @@ +import { + DefaultBlockSchema, + PartialBlock, + TableContent, +} from "@blocknote/core"; +import { TableHandleMenuProps } from "../TableHandleMenu"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; + +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..2a8af93c37 --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx @@ -0,0 +1,64 @@ +import { + DefaultBlockSchema, + PartialBlock, + TableContent, +} from "@blocknote/core"; +import { TableHandleMenuProps } from "../TableHandleMenu"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; + +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..24e649aa5c --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx @@ -0,0 +1,33 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + SpecificBlock, +} from "@blocknote/core"; +import { Menu, createStyles } from "@mantine/core"; +import { ReactNode } from "react"; + +export type TableHandleMenuProps< + BSchema extends { table: DefaultBlockSchema["table"] } +> = { + orientation: "row" | "column"; + editor: BlockNoteEditor; + block: SpecificBlock< + { table: DefaultBlockSchema["table"] }, + "table", + any, + any + >; + 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 new file mode 100644 index 0000000000..78f6102a36 --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx @@ -0,0 +1,218 @@ +import { + BlockFromConfigNoChildren, + BlockNoteEditor, + 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< + 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"]>, + I extends InlineContentSchema, + S extends StyleSchema +>(props: { + editor: BlockNoteEditor; + tableHandle?: FC>; +}) => { + const [show, setShow] = useState(false); + const [hideRow, setHideRow] = useState(false); + const [hideCol, setHideCol] = useState(false); + const [block, setBlock] = + useState>(); + + const [rowIndex, setRowIndex] = useState(); + const [colIndex, setColIndex] = useState(); + + const [draggedCellOrientation, setDraggedCellOrientation] = useState< + "row" | "col" | undefined + >(undefined); + const [mousePos, setMousePos] = useState(); + + const [_, setForceUpdate] = useState(0); + + const referencePosCell = useRef(); + const referencePosTable = useRef(); + + useEffect(() => { + tippy.setDefaultProps({ maxWidth: "" }); + + return props.editor.tableHandles!.onUpdate((state) => { + // console.log("update", state.draggingState); + setShow(state.show); + setBlock(state.block); + setRowIndex(state.rowIndex); + setColIndex(state.colIndex); + + if (state.draggingState) { + setDraggedCellOrientation(state.draggingState.draggedCellOrientation); + setMousePos(state.draggingState.mousePos); + } else { + setDraggedCellOrientation(undefined); + setMousePos(undefined); + } + + setForceUpdate(Math.random()); + + referencePosCell.current = state.referencePosCell; + referencePosTable.current = state.referencePosTable; + }); + }, [props.editor]); + + const getReferenceClientRectRow = useMemo( + () => { + if (!referencePosCell.current || !referencePosTable.current) { + return undefined; + } + + if (draggedCellOrientation === "row") { + return () => + new DOMRect( + referencePosTable.current!.x, + mousePos!, + referencePosTable.current!.width, + 0 + ); + } + + return () => + new DOMRect( + referencePosTable.current!.x, + referencePosCell.current!.y, + referencePosTable.current!.width, + referencePosCell.current!.height + ); + }, + [referencePosTable.current, draggedCellOrientation, mousePos] // eslint-disable-line + ); + + const getReferenceClientRectColumn = useMemo( + () => { + if (!referencePosCell.current || !referencePosTable.current) { + return undefined; + } + + if (draggedCellOrientation === "col") { + return () => + new DOMRect( + mousePos!, + referencePosTable.current!.y, + 0, + referencePosTable.current!.height + ); + } + + return () => + new DOMRect( + referencePosCell.current!.x, + referencePosTable.current!.y, + referencePosCell.current!.width, + referencePosTable.current!.height + ); + }, + [referencePosTable.current, draggedCellOrientation, mousePos] // eslint-disable-line + ); + + const columnTableHandle = useMemo(() => { + const TableHandle = props.tableHandle || DefaultTableHandle; + + return ( + setHideRow(false)} + hideOtherSide={() => setHideRow(true)} + /> + ); + }, [block, props.editor, props.tableHandle, colIndex]); + + const rowTableHandle = useMemo(() => { + const TableHandle = props.tableHandle || DefaultTableHandle; + + return ( + setHideCol(false)} + hideOtherSide={() => setHideCol(true)} + /> + ); + }, [block, props.editor, props.tableHandle, rowIndex]); + + return ( + <> + + + + ); +}; + +const rowOffset: [number, number] = [0, -12]; +const columnOffset: [number, number] = [0, -12]; 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 351fab19c8..0000000000 --- a/packages/react/src/htmlConversion.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createReactBlockSpec, InlineContent } from "./ReactBlockSpec"; -import { - BlockNoteEditor, - BlockSchema, - createExternalHTMLExporter, - createInternalHTMLSerializer, - defaultBlockSchema, - defaultProps, - PartialBlock, - uploadToTmpFilesDotOrg_DEV_ONLY, -} from "@blocknote/core"; -import { Editor } from "@tiptap/core"; - -const ReactCustomParagraph = createReactBlockSpec({ - type: "reactCustomParagraph", - propSchema: defaultProps, - containsInlineContent: true, - render: () => , - toExternalHTML: () =>

      Hello World

      , -}); - -const SimpleReactCustomParagraph = createReactBlockSpec({ - type: "simpleReactCustomParagraph", - propSchema: defaultProps, - containsInlineContent: true, - 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,