diff --git a/examples/editor/src/App.tsx b/examples/editor/examples/Basic.tsx similarity index 74% rename from examples/editor/src/App.tsx rename to examples/editor/examples/Basic.tsx index 2ff77e8c34..6c3213b0dd 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/examples/Basic.tsx @@ -1,16 +1,12 @@ -// import logo from './logo.svg' -import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; import "@blocknote/core/style.css"; import { BlockNoteView, useBlockNote } from "@blocknote/react"; -import "./App.css"; + +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; -function App() { +export function App() { const editor = useBlockNote({ - onEditorContentChange: (editor) => { - console.log(editor.topLevelBlocks); - }, domAttributes: { editor: { class: "editor", @@ -23,7 +19,7 @@ function App() { // Give tests a way to get prosemirror instance (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; - return ; + return ; } export default App; diff --git a/examples/editor/examples/Collaboration.tsx b/examples/editor/examples/Collaboration.tsx new file mode 100644 index 0000000000..8bec4b84c9 --- /dev/null +++ b/examples/editor/examples/Collaboration.tsx @@ -0,0 +1,48 @@ +import "@blocknote/core/style.css"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; + +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; + +import YPartyKitProvider from "y-partykit/provider"; +import * as Y from "yjs"; + +const doc = new Y.Doc(); + +const provider = new YPartyKitProvider( + "blocknote-dev.yousefed.partykit.dev", + // use a unique name as a "room" for your application: + "your-project-name", + doc +); + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +export function App() { + const editor = useBlockNote({ + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + collaboration: { + // The Yjs Provider responsible for transporting updates: + provider, + // Where to store BlockNote data in the Y.Doc: + fragment: doc.getXmlFragment("document-storesss"), + // Information (name and color) for this user: + user: { + name: "My Username", + color: "#ff0000", + }, + }, + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + }); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ; +} + +export default App; diff --git a/examples/editor/examples/ReactInlineContent.tsx b/examples/editor/examples/ReactInlineContent.tsx new file mode 100644 index 0000000000..07ec3deb13 --- /dev/null +++ b/examples/editor/examples/ReactInlineContent.tsx @@ -0,0 +1,90 @@ +import { defaultInlineContentSpecs } from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { + BlockNoteView, + createReactInlineContentSpec, + useBlockNote, +} from "@blocknote/react"; + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +const mention = createReactInlineContentSpec( + { + type: "mention", + propSchema: { + user: { + default: "", + }, + }, + content: "none", + }, + { + render: (props) => { + return @{props.inlineContent.props.user}; + }, + } +); + +const tag = createReactInlineContentSpec( + { + type: "tag", + propSchema: {}, + content: "styled", + }, + { + render: (props) => { + return ( + + # + + ); + }, + } +); + +export function ReactInlineContent() { + const editor = useBlockNote({ + inlineContentSpecs: { + mention, + tag, + ...defaultInlineContentSpecs, + }, + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + initialContent: [ + { + type: "paragraph", + content: [ + "I enjoy working with ", + { + type: "mention", + props: { + user: "Matthew", + }, + content: undefined, + } as any, + ], + }, + { + type: "paragraph", + content: [ + "I love ", + { + type: "tag", + // props: {}, + content: "BlockNote", + } as any, + ], + }, + ], + }); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ; +} diff --git a/examples/editor/examples/ReactStyles.tsx b/examples/editor/examples/ReactStyles.tsx new file mode 100644 index 0000000000..6c82ca2bcf --- /dev/null +++ b/examples/editor/examples/ReactStyles.tsx @@ -0,0 +1,138 @@ +import "@blocknote/core/style.css"; +import { + BlockNoteView, + FormattingToolbarPositioner, + Toolbar, + ToolbarButton, + createReactStyleSpec, + useActiveStyles, + useBlockNote, +} from "@blocknote/react"; + +import { + BlockNoteEditor, + DefaultBlockSchema, + DefaultInlineContentSchema, + StyleSchemaFromSpecs, + defaultStyleSpecs, +} from "@blocknote/core"; + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +const small = createReactStyleSpec( + { + type: "small", + propSchema: "boolean", + }, + { + render: (props) => { + return ; + }, + } +); + +const fontSize = createReactStyleSpec( + { + type: "fontSize", + propSchema: "string", + }, + { + render: (props) => { + return ( + + ); + }, + } +); + +const customReactStyles = { + ...defaultStyleSpecs, + small, + fontSize, +}; + +type MyEditorType = BlockNoteEditor< + DefaultBlockSchema, + DefaultInlineContentSchema, + StyleSchemaFromSpecs +>; + +const CustomFormattingToolbar = (props: { editor: MyEditorType }) => { + const activeStyles = useActiveStyles(props.editor); + + return ( + + { + props.editor.toggleStyles({ + small: true, + }); + }} + isSelected={activeStyles.small}> + Small + + { + props.editor.toggleStyles({ + fontSize: "30px", + }); + }} + isSelected={!!activeStyles.fontSize}> + Font size + + + ); +}; + +export function ReactStyles() { + const editor = useBlockNote( + { + styleSpecs: customReactStyles, + onEditorContentChange: (editor) => { + console.log(editor.topLevelBlocks); + }, + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + initialContent: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "large text", + styles: { + fontSize: "30px", + }, + }, + { + type: "text", + text: "small text", + styles: { + small: true, + }, + }, + ], + }, + ], + }, + [] + ); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ( + + + + ); +} diff --git a/examples/editor/package.json b/examples/editor/package.json index 3ad74335a3..dad6b03ab9 100644 --- a/examples/editor/package.json +++ b/examples/editor/package.json @@ -11,8 +11,12 @@ "dependencies": { "@blocknote/core": "^0.9.6", "@blocknote/react": "^0.9.6", + "@mantine/core": "^5.6.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "y-partykit": "^0.0.0-4c022c1", + "yjs": "^13.6.10" }, "devDependencies": { "@types/react": "^18.0.25", diff --git a/examples/editor/src/App.css b/examples/editor/src/App.css index 8a90b5cd3f..8918687e58 100644 --- a/examples/editor/src/App.css +++ b/examples/editor/src/App.css @@ -2,3 +2,12 @@ margin: 0 calc((100% - 731px) / 2); height: 100%; } + +body { + margin: 0; +} + +.root { + height: 100%; + width: 100%; +} diff --git a/examples/editor/src/main.tsx b/examples/editor/src/main.tsx index f87c123a2c..0d2f29eefe 100644 --- a/examples/editor/src/main.tsx +++ b/examples/editor/src/main.tsx @@ -1,12 +1,112 @@ +import { AppShell, Navbar, ScrollArea } from "@mantine/core"; import React from "react"; import { createRoot } from "react-dom/client"; -import App from "./App"; +import { + Link, + Outlet, + RouterProvider, + createBrowserRouter, +} from "react-router-dom"; +import { App } from "../examples/Basic"; +import { ReactInlineContent } from "../examples/ReactInlineContent"; +import { ReactStyles } from "../examples/ReactStyles"; +import "./style.css"; window.React = React; +const editors = [ + { + title: "Basic", + path: "/simple", + component: App, + }, + { + title: "React custom styles", + path: "/react-styles", + component: ReactStyles, + }, + { + title: "React inline content", + path: "/react-inline-content", + component: ReactInlineContent, + }, +]; + +function Root() { + // const linkStyles = (theme) => ({ + // root: { + // // background: "red", + // ...theme.fn.hover({ + // backgroundColor: "#dfdfdd", + // }), + + // "&[data-active]": { + // backgroundColor: "rgba(0, 0, 0, 0.04)", + // }, + // }, + // // "root:hover": { background: "blue" }, + // }); + return ( + + + {editors.map((editor, i) => ( +
+ {editor.title} +
+ ))} + + {/* manitne } + // rightSection={} + /> + } + // rightSection={} + /> */} +
+ + } + header={<>} + // header={
+ // {/* Header content */} + //
} + styles={(theme) => ({ + main: { + backgroundColor: "white", + // theme.colorScheme === "dark" + // ? theme.colors.dark[8] + // : theme.colors.gray[0], + }, + })}> + +
+ ); +} +const router = createBrowserRouter([ + { + path: "/", + element: , + children: editors.map((editor) => ({ + path: editor.path, + element: , + })), + }, +]); + const root = createRoot(document.getElementById("root")!); root.render( - - - + // TODO: StrictMode is causing duplicate mounts and conflicts with collaboration + // + // + + // ); diff --git a/examples/editor/src/style.css b/examples/editor/src/style.css new file mode 100644 index 0000000000..8918687e58 --- /dev/null +++ b/examples/editor/src/style.css @@ -0,0 +1,13 @@ +.editor { + margin: 0 calc((100% - 731px) / 2); + height: 100%; +} + +body { + margin: 0; +} + +.root { + height: 100%; + width: 100%; +} diff --git a/examples/editor/tsconfig.json b/examples/editor/tsconfig.json index 4f17a5d5b9..41460fa792 100644 --- a/examples/editor/tsconfig.json +++ b/examples/editor/tsconfig.json @@ -17,7 +17,7 @@ "jsx": "react-jsx", "composite": true }, - "include": ["src"], + "include": ["src", "examples"], "references": [ { "path": "./tsconfig.node.json" }, { "path": "../../packages/core/" }, diff --git a/examples/vanilla/src/main.tsx b/examples/vanilla/src/main.tsx index 6f8a84712a..926d39d3fb 100644 --- a/examples/vanilla/src/main.tsx +++ b/examples/vanilla/src/main.tsx @@ -1,11 +1,11 @@ import { BlockNoteEditor } from "@blocknote/core"; import "./index.css"; -import { addSideMenu } from "./ui/addSideMenu"; import { addFormattingToolbar } from "./ui/addFormattingToolbar"; -import { addSlashMenu } from "./ui/addSlashMenu"; import { addHyperlinkToolbar } from "./ui/addHyperlinkToolbar"; +import { addSideMenu } from "./ui/addSideMenu"; +import { addSlashMenu } from "./ui/addSlashMenu"; -const editor = new BlockNoteEditor({ +const editor = BlockNoteEditor.create({ parentElement: document.getElementById("root")!, onEditorContentChange: () => { console.log(editor.topLevelBlocks); diff --git a/examples/vanilla/src/ui/addSlashMenu.ts b/examples/vanilla/src/ui/addSlashMenu.ts index 3ecbd7fc46..936fcbcb75 100644 --- a/examples/vanilla/src/ui/addSlashMenu.ts +++ b/examples/vanilla/src/ui/addSlashMenu.ts @@ -9,8 +9,8 @@ export const addSlashMenu = (editor: BlockNoteEditor) => { let element: HTMLElement; function updateItems( - items: BaseSlashMenuItem[], - onClick: (item: BaseSlashMenuItem) => void, + items: BaseSlashMenuItem[], + onClick: (item: BaseSlashMenuItem) => void, selected: number ) { element.innerHTML = ""; diff --git a/package-lock.json b/package-lock.json index 9da000e7e4..a14117e164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,12 @@ "dependencies": { "@blocknote/core": "^0.9.6", "@blocknote/react": "^0.9.6", + "@mantine/core": "^5.6.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "y-partykit": "^0.0.0-4c022c1", + "yjs": "^13.6.10" }, "devDependencies": { "@types/react": "^18.0.25", @@ -603,6 +607,23 @@ } } }, + "examples/playground": { + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "next": "14.0.3", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.0.3", + "typescript": "^5" + } + }, "examples/vanilla": { "name": "@blocknote/example-vanilla", "version": "0.9.6", @@ -6170,6 +6191,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@remix-run/router": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", + "integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@resvg/resvg-wasm": { "version": "2.4.1", "license": "MPL-2.0", @@ -6190,9 +6219,10 @@ } }, "node_modules/@rushstack/eslint-patch": { - "version": "1.3.0", - "dev": true, - "license": "MIT" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz", + "integrity": "sha512-2/U3GXA6YiPYQDLGwtGlnNgKYBSwCFIHf8Y9LUY5VATHdtbLlU0Y1R3QoBnT0aB4qv/BEiVVsj7LJXoQCgJ2vA==", + "dev": true }, "node_modules/@shuding/opentype.js": { "version": "1.4.0-beta.0", @@ -7727,14 +7757,15 @@ "license": "MIT" }, "node_modules/array-includes": { - "version": "3.1.6", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", "is-string": "^1.0.7" }, "engines": { @@ -7753,16 +7784,16 @@ } }, "node_modules/array.prototype.findlastindex": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.2.tgz", - "integrity": "sha512-tb5thFFlUcp7NdNF6/MpDk/1r/4awWG1FIz3YqDf+/zJSTezBb+/5WViH41obXULHVpDzoiCLpJ/ZO9YbJMsdw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" + "get-intrinsic": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -7772,13 +7803,14 @@ } }, "node_modules/array.prototype.flat": { - "version": "1.3.1", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -7789,13 +7821,14 @@ } }, "node_modules/array.prototype.flatmap": { - "version": "1.3.1", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -7817,6 +7850,27 @@ "get-intrinsic": "^1.1.3" } }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/arrify": { "version": "1.0.1", "dev": true, @@ -7848,6 +7902,15 @@ "dev": true, "license": "MIT" }, + "node_modules/asynciterator.prototype": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", + "integrity": "sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + } + }, "node_modules/asynckit": { "version": "0.4.0", "dev": true, @@ -8261,12 +8324,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dev": true, - "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9134,6 +9199,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "dev": true, @@ -9143,10 +9222,12 @@ } }, "node_modules/define-properties": { - "version": "1.2.0", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, - "license": "MIT", "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -9403,24 +9484,26 @@ } }, "node_modules/es-abstract": { - "version": "1.21.2", + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", "dev": true, - "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.5", "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.0", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", "get-symbol-description": "^1.0.0", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", "has-property-descriptors": "^1.0.0", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", + "hasown": "^2.0.0", "internal-slot": "^1.0.5", "is-array-buffer": "^3.0.2", "is-callable": "^1.2.7", @@ -9428,19 +9511,23 @@ "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", + "is-typed-array": "^1.1.12", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", "typed-array-length": "^1.0.4", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.9" + "which-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -9468,6 +9555,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-iterator-helpers": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", + "integrity": "sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==", + "dev": true, + "dependencies": { + "asynciterator.prototype": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.1", + "es-set-tostringtag": "^2.0.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.0.1" + } + }, "node_modules/es-set-tostringtag": { "version": "2.0.1", "dev": true, @@ -10053,13 +10162,14 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.7", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, - "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.11.0", - "resolve": "^1.22.1" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -10112,27 +10222,26 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.0.tgz", - "integrity": "sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", + "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", "dev": true, "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.findlastindex": "^1.2.2", - "array.prototype.flat": "^1.3.1", - "array.prototype.flatmap": "^1.3.1", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.7", + "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.8.0", - "has": "^1.0.3", - "is-core-module": "^2.12.1", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.fromentries": "^2.0.6", - "object.groupby": "^1.0.0", - "object.values": "^1.1.6", - "resolve": "^1.22.3", + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", "semver": "^6.3.1", "tsconfig-paths": "^3.14.2" }, @@ -10215,14 +10324,16 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.32.2", + "version": "7.33.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", + "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", "dev": true, - "license": "MIT", "dependencies": { "array-includes": "^3.1.6", "array.prototype.flatmap": "^1.3.1", "array.prototype.tosorted": "^1.1.1", "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.12", "estraverse": "^5.3.0", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", @@ -10232,7 +10343,7 @@ "object.values": "^1.1.6", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.4", - "semver": "^6.3.0", + "semver": "^6.3.1", "string.prototype.matchall": "^4.0.8" }, "engines": { @@ -10541,9 +10652,10 @@ "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.2.12", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -10807,18 +10919,23 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "license": "MIT" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { - "version": "1.1.5", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { "node": ">= 0.4" @@ -10877,14 +10994,15 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.1", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dev": true, - "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11219,6 +11337,7 @@ }, "node_modules/has": { "version": "1.0.3", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.1" @@ -11294,6 +11413,17 @@ "dev": true, "license": "ISC" }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-embedded": { "version": "2.0.1", "license": "MIT", @@ -11580,6 +11710,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.0.tgz", + "integrity": "sha512-KlClZ3/Qy5UgvpvVvDomGhnQhNWH5INE8GwvSIQ9CWt1K0zbbXrl7eN5bWaafOZgtmO3jMPwUqmrmEwinhPq1w==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "dev": true, @@ -11990,6 +12129,21 @@ "version": "0.2.1", "license": "MIT" }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-bigint": { "version": "1.0.4", "dev": true, @@ -12071,10 +12225,11 @@ } }, "node_modules/is-core-module": { - "version": "2.12.1", - "license": "MIT", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12136,6 +12291,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "dev": true, @@ -12144,6 +12311,21 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "dev": true, @@ -12339,15 +12521,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.10", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", "dev": true, - "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "which-typed-array": "^1.1.11" }, "engines": { "node": ">= 0.4" @@ -12452,6 +12631,19 @@ "node": ">=0.12" } }, + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + } + }, "node_modules/jake": { "version": "10.8.7", "dev": true, @@ -12833,8 +13025,9 @@ } }, "node_modules/lib0": { - "version": "0.2.78", - "license": "MIT", + "version": "0.2.88", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.88.tgz", + "integrity": "sha512-KyroiEvCeZcZEMx5Ys+b4u4eEBbA1ch7XUaBhYpwa/nPMrzTjUhI4RfcytmQfYoTBPcdyx+FX6WFNIoNuJzJfQ==", "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -15382,9 +15575,10 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -15443,13 +15637,14 @@ } }, "node_modules/object.fromentries": { - "version": "2.0.6", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -15459,14 +15654,14 @@ } }, "node_modules/object.groupby": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.0.tgz", - "integrity": "sha512-70MWG6NfRH9GnbZOikuhPPYzpUpof9iW2J9E4dW7FXTqPNb6rllE6u39SKwwiNh8lCwX3DDb5OgcKGiEBrTTyw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", - "es-abstract": "^1.21.2", + "es-abstract": "^1.22.1", "get-intrinsic": "^1.2.1" } }, @@ -15503,13 +15698,14 @@ } }, "node_modules/object.values": { - "version": "1.1.6", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -16407,7 +16603,9 @@ } }, "node_modules/postcss": { - "version": "8.4.24", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { @@ -16423,7 +16621,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -16842,6 +17039,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz", + "integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==", + "dependencies": { + "@remix-run/router": "1.13.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz", + "integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==", + "dependencies": { + "@remix-run/router": "1.13.0", + "react-router": "6.20.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-textarea-autosize": { "version": "8.3.4", "license": "MIT", @@ -17183,6 +17410,26 @@ "node": ">=8" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", + "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regenerate": { "version": "1.4.2", "dev": true, @@ -17212,13 +17459,14 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.0", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" + "set-function-name": "^2.0.0" }, "engines": { "node": ">= 0.4" @@ -17261,6 +17509,156 @@ "jsesc": "bin/jsesc" } }, + "node_modules/rehype-format": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.0.tgz", + "integrity": "sha512-kM4II8krCHmUhxrlvzFSptvaWh280Fr7UGNJU5DCMuvmAwGCNmGfi9CvFAQK6JDjsNoRMWQStglK3zKJH685Wg==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "rehype-minify-whitespace": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/@types/hast": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.3.tgz", + "integrity": "sha512-2fYGlaDy/qyLlhidX42wAH0KBi2TCjKMH8CHmBXgRlJ3Y+OXTiqsPQ6IWarZKwF1JoUcAJdPogv1d4b0COTpmQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/rehype-format/node_modules/@types/unist": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", + "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" + }, + "node_modules/rehype-format/node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-is-body-ok-link": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.0.tgz", + "integrity": "sha512-VFHY5bo2nY8HiV6nir2ynmEB1XkxzuUffhEGeVx7orbu/B1KaGyeGgMZldvMVx5xWrDlLLG/kQ6YkJAMkBEx0w==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/rehype-minify-whitespace": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-minify-whitespace/-/rehype-minify-whitespace-6.0.0.tgz", + "integrity": "sha512-i9It4YHR0Sf3GsnlR5jFUKXRr9oayvEk9GKQUkwZv6hs70OH9q3OCZrq9PpLvIGKt3W+JxBOxCidNVpH/6rWdA==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-minify-whitespace": { "version": "5.0.1", "license": "MIT", @@ -17390,11 +17788,11 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.3.tgz", - "integrity": "sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dependencies": { - "is-core-module": "^2.12.0", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -17565,6 +17963,24 @@ "node": ">=6" } }, + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "dev": true, @@ -17774,6 +18190,35 @@ "dev": true, "license": "ISC" }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shallow-clone": { "version": "3.0.1", "dev": true, @@ -18064,13 +18509,14 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.7", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -18080,26 +18526,28 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.6", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.6", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -18540,6 +18988,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typed-array-length": { "version": "1.0.4", "dev": true, @@ -19377,6 +19876,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-builtin-type": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", + "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", + "dev": true, + "dependencies": { + "function.prototype.name": "^1.1.5", + "has-tostringtag": "^1.0.0", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-collection": { "version": "1.0.1", "dev": true, @@ -19392,16 +19917,16 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.9", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", "dev": true, - "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.4", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -19682,6 +20207,18 @@ "node": ">=0.4" } }, + "node_modules/y-partykit": { + "version": "0.0.0-4c022c1", + "resolved": "https://registry.npmjs.org/y-partykit/-/y-partykit-0.0.0-4c022c1.tgz", + "integrity": "sha512-DC4+2SdYjp4TfgcPjZFmHiMrhBkmBgapAN/KQ2ZlnSUYGnFtZZ11+Mkk6bAMCmwRKYKWA0lwVjznd7jpsoQe8g==", + "dependencies": { + "lib0": "^0.2.86", + "lodash.debounce": "^4.0.8", + "react": "^18.2.0", + "y-protocols": "^1.0.6", + "yjs": "^13.6.8" + } + }, "node_modules/y-prosemirror": { "version": "1.0.20", "license": "MIT", @@ -19701,14 +20238,22 @@ } }, "node_modules/y-protocols": { - "version": "1.0.5", - "license": "MIT", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", "dependencies": { - "lib0": "^0.2.42" + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" }, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" } }, "node_modules/y18n": { @@ -19756,10 +20301,11 @@ } }, "node_modules/yjs": { - "version": "13.6.1", - "license": "MIT", + "version": "13.6.10", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.10.tgz", + "integrity": "sha512-1JcyQek1vaMyrDm7Fqfa+pvHg/DURSbVo4VmeN7wjnTKB/lZrfIPhdCj7d8sboK6zLfRBJXegTjc9JlaDd8/Zw==", "dependencies": { - "lib0": "^0.2.74" + "lib0": "^0.2.86" }, "engines": { "node": ">=16.0.0", @@ -19828,6 +20374,7 @@ "prosemirror-tables": "^1.3.4", "prosemirror-transform": "^1.7.2", "prosemirror-view": "^1.31.4", + "rehype-format": "^5.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", diff --git a/packages/core/package.json b/packages/core/package.json index 4ed2dc62aa..2984e7b9d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,6 +82,7 @@ "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", + "rehype-format":"^5.0.0", "remark-gfm": "^3.0.1", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", diff --git a/packages/core/src/BlockNoteEditor.test.ts b/packages/core/src/BlockNoteEditor.test.ts index 9c1b60fb12..f295c76fab 100644 --- a/packages/core/src/BlockNoteEditor.test.ts +++ b/packages/core/src/BlockNoteEditor.test.ts @@ -6,7 +6,7 @@ import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFro * @vitest-environment jsdom */ it("creates an editor", () => { - const editor = new BlockNoteEditor({}); + const editor = BlockNoteEditor.create(); const blockInfo = getBlockInfoFromPos(editor._tiptapEditor.state.doc, 2); expect(blockInfo?.contentNode.type.name).toEqual("paragraph"); }); diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index d11c03644f..bf41f4ba60 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -1,5 +1,5 @@ import { Editor, EditorOptions, Extension } from "@tiptap/core"; -import { Node } from "prosemirror-model"; +import { Fragment, Node, Slice } from "prosemirror-model"; // import "./blocknote.css"; import { Editor as TiptapEditor } from "@tiptap/core/dist/packages/core/src/Editor"; import * as Y from "yjs"; @@ -20,23 +20,45 @@ import { BlockIdentifier, BlockNoteDOMAttributes, BlockSchema, + BlockSchemaFromSpecs, + BlockSchemaWithBlock, + BlockSpecs, PartialBlock, -} from "./extensions/Blocks/api/blockTypes"; -import { TextCursorPosition } from "./extensions/Blocks/api/cursorPositionTypes"; +} from "./extensions/Blocks/api/blocks/types"; import { DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, defaultBlockSchema, + defaultBlockSpecs, + defaultInlineContentSpecs, + defaultStyleSpecs, } from "./extensions/Blocks/api/defaultBlocks"; +import { Selection } from "./extensions/Blocks/api/selectionTypes"; import { - ColorStyle, + StyleSchema, + StyleSchemaFromSpecs, + StyleSpecs, Styles, - ToggledStyle, -} from "./extensions/Blocks/api/inlineContentTypes"; -import { Selection } from "./extensions/Blocks/api/selectionTypes"; +} from "./extensions/Blocks/api/styles/types"; import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos"; import "prosemirror-tables/style/tables.css"; + +import { createExternalHTMLExporter } from "./api/exporters/html/externalHTMLExporter"; +import { blocksToMarkdown } from "./api/exporters/markdown/markdownExporter"; +import { HTMLToBlocks } from "./api/parsers/html/parseHTML"; +import { markdownToBlocks } from "./api/parsers/markdown/parseMarkdown"; import "./editor.css"; +import { getBlockSchemaFromSpecs } from "./extensions/Blocks/api/blocks/internal"; +import { TextCursorPosition } from "./extensions/Blocks/api/cursorPositionTypes"; +import { getInlineContentSchemaFromSpecs } from "./extensions/Blocks/api/inlineContent/internal"; +import { + InlineContentSchema, + InlineContentSchemaFromSpecs, + InlineContentSpecs, +} from "./extensions/Blocks/api/inlineContent/types"; +import { getStyleSchemaFromSpecs } from "./extensions/Blocks/api/styles/internal"; import { FormattingToolbarProsemirrorPlugin } from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; import { HyperlinkToolbarProsemirrorPlugin } from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; import { ImageToolbarProsemirrorPlugin } from "./extensions/ImageToolbar/ImageToolbarPlugin"; @@ -48,7 +70,11 @@ import { TableHandlesProsemirrorPlugin } from "./extensions/TableHandles/TableHa import { UniqueID } from "./extensions/UniqueID/UniqueID"; import { UnreachableCaseError, mergeCSSClasses } from "./shared/utils"; -export type BlockNoteEditorOptions = { +export type BlockNoteEditorOptions< + BSpecs extends BlockSpecs, + ISpecs extends InlineContentSpecs, + SSpecs extends StyleSpecs +> = { // TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them. enableBlockNoteExtensions: boolean; /** @@ -57,7 +83,11 @@ export type BlockNoteEditorOptions = { * * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ - slashMenuItems: BaseSlashMenuItem[]; + slashMenuItems: BaseSlashMenuItem< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + >[]; /** * The HTML element that should be used as the parent element for the editor. @@ -74,15 +104,33 @@ export type BlockNoteEditorOptions = { /** * A callback function that runs when the editor is ready to be used. */ - onEditorReady: (editor: BlockNoteEditor) => void; + onEditorReady: ( + editor: BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + ) => void; /** * A callback function that runs whenever the editor's contents change. */ - onEditorContentChange: (editor: BlockNoteEditor) => void; + onEditorContentChange: ( + editor: BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + ) => void; /** * A callback function that runs whenever the text cursor position changes. */ - onTextCursorPositionChange: (editor: BlockNoteEditor) => void; + onTextCursorPositionChange: ( + editor: BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + ) => void; /** * Locks the editor from being editable by the user if set to `false`. */ @@ -90,7 +138,11 @@ export type BlockNoteEditorOptions = { /** * The content that should be in the editor when it's created, represented as an array of partial block objects. */ - initialContent: PartialBlock[]; + initialContent: PartialBlock< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + >[]; /** * Use default BlockNote font and reset the styles of

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

    Heading 2

    Paragraph

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

    Heading 2

    Paragraph

    \ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/basic/external.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/nested/external.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/nested/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/styled/external.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/customParagraph/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/customParagraph/styled/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/internal.html diff --git a/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html new file mode 100644 index 0000000000..49b9ce6858 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html @@ -0,0 +1 @@ +

    This is text with a custom fontSize

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

    This is text with a custom fontSize

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

    Text1
    Text2

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

    Text1
    Text2

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

    Link1
    Link2

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

    Text1

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

    Text1

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

    Link1
    Link1

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

    Text1
    Text2
    Text3

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

    Text1
    Text2
    Text3

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


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


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


    Text1

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


    Text1

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

    Text1
    Text2

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

    Text1
    Text2

    \ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/basic/external.html rename to packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/button/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/button/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/button/external.html rename to packages/core/src/api/exporters/html/__snapshots__/image/button/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/button/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/button/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/nested/external.html rename to packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/image/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/image/nested/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html new file mode 100644 index 0000000000..8876f46341 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html @@ -0,0 +1 @@ +

    WebsiteWebsite2

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

    Website

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

    Website

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

    I enjoy working with@Matthew

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

    I enjoy working with@Matthew

    \ No newline at end of file diff --git a/packages/core/src/api/serialization/html/__snapshots__/paragraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/external.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/paragraph/basic/external.html rename to packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/external.html diff --git a/packages/core/src/api/serialization/html/__snapshots__/paragraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/internal.html similarity index 100% rename from packages/core/src/api/serialization/html/__snapshots__/paragraph/basic/internal.html rename to packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/internal.html diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html new file mode 100644 index 0000000000..c659260f6e --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html @@ -0,0 +1 @@ +

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

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

    This is a small text

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

    This is a small text

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

    I love #BlockNote

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

    I love #BlockNote

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

    Heading

    Paragraph

    • Bullet List Item

    1. Numbered List Item

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

    Heading

    Paragraph

    • Bullet List Item

      1. Numbered List Item

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

    BoldItalicUnderlineStrikethroughTextColorBackgroundColorMultiple

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

    Heading 1

    Heading 2

    Heading 3

    Paragraph

    Paragraph

    Paragraph

    • Bullet List Item

    • Bullet List Item

      • Bullet List Item

        • Bullet List Item

        Paragraph

        1. Numbered List Item

        2. Numbered List Item

        3. Numbered List Item

          1. Numbered List Item

        • Bullet List Item

      • Bullet List Item

    • Bullet List Item

    `; -// // const output = await editor.HTMLToBlocks(html); -// // -// // expect(output).toMatchSnapshot(); -// // }); -// // // Failing due to nested block parsing bug. -// // it("Convert complex Markdown to blocks", async () => { -// // const markdown = `# Heading 1 -// // -// // ## Heading 2 -// // -// // ### Heading 3 -// // -// // Paragraph -// // -// // P**ara***grap*h -// // -// // P*ara*~~grap~~h -// // -// // * Bullet List Item -// // -// // * Bullet List Item -// // -// // * Bullet List Item -// // -// // * Bullet List Item -// // -// // Paragraph -// // -// // 1. Numbered List Item -// // -// // 2. Numbered List Item -// // -// // 3. Numbered List Item -// // -// // 1. Numbered List Item -// // -// // * Bullet List Item -// // -// // * Bullet List Item -// // -// // * Bullet List Item -// // `; -// // const output = await editor.markdownToBlocks(markdown); -// // -// // expect(output).toMatchSnapshot(); -// // }); -// }); diff --git a/packages/core/src/api/formatConversions/formatConversions.ts b/packages/core/src/api/formatConversions/formatConversions.ts deleted file mode 100644 index 5f82683cd8..0000000000 --- a/packages/core/src/api/formatConversions/formatConversions.ts +++ /dev/null @@ -1,140 +0,0 @@ -import rehypeParse from "rehype-parse"; -import rehypeRemark from "rehype-remark"; -import remarkGfm from "remark-gfm"; -import remarkStringify from "remark-stringify"; -import { unified } from "unified"; - -import { removeUnderlines } from "./removeUnderlinesRehypePlugin"; - -// export async function blocksToHTML( -// blocks: Block[], -// schema: Schema, -// editor: BlockNoteEditor -// ): Promise { -// const htmlParentElement = document.createElement("div"); -// const serializer = createInternalHTMLSerializer(schema, editor); -// -// for (const block of blocks) { -// const node = blockToNode(block, schema); -// const htmlNode = serializer.serializeNode(node); -// htmlParentElement.appendChild(htmlNode); -// } -// -// const htmlString = await unified() -// .use(rehypeParse, { fragment: true }) -// .use(simplifyBlocks, { -// orderedListItemBlockTypes: new Set(["numberedListItem"]), -// unorderedListItemBlockTypes: new Set(["bulletListItem"]), -// }) -// .use(rehypeStringify) -// .process(htmlParentElement.innerHTML); -// -// return htmlString.value as string; -// } -// -// export async function HTMLToBlocks( -// html: string, -// blockSchema: BSchema, -// schema: Schema -// ): Promise[]> { -// const htmlNode = document.createElement("div"); -// htmlNode.innerHTML = html.trim(); -// -// const parser = DOMParser.fromSchema(schema); -// const parentNode = parser.parse(htmlNode); //, { preserveWhitespace: "full" }); -// -// const blocks: Block[] = []; -// -// for (let i = 0; i < parentNode.firstChild!.childCount; i++) { -// blocks.push(nodeToBlock(parentNode.firstChild!.child(i), blockSchema)); -// } -// -// return blocks; -// } -// -// export async function blocksToMarkdown( -// blocks: Block[], -// schema: Schema, -// editor: BlockNoteEditor -// ): Promise { -// const markdownString = await unified() -// .use(rehypeParse, { fragment: true }) -// .use(removeUnderlines) -// .use(rehypeRemark) -// .use(remarkGfm) -// .use(remarkStringify) -// .process(await blocksToHTML(blocks, schema, editor)); -// -// return markdownString.value as string; -// } -// -// // modefied version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js -// // that outputs a data-language attribute instead of a CSS class (e.g.: language-typescript) -// function code(state: any, node: any) { -// const value = node.value ? node.value + "\n" : ""; -// /** @type {Properties} */ -// const properties: any = {}; -// -// if (node.lang) { -// // changed line -// properties["data-language"] = node.lang; -// } -// -// // Create ``. -// /** @type {Element} */ -// let result: any = { -// type: "element", -// tagName: "code", -// properties, -// children: [{ type: "text", value }], -// }; -// -// if (node.meta) { -// result.data = { meta: node.meta }; -// } -// -// state.patch(node, result); -// result = state.applyData(node, result); -// -// // Create `
    `.
    -//   result = {
    -//     type: "element",
    -//     tagName: "pre",
    -//     properties: {},
    -//     children: [result],
    -//   };
    -//   state.patch(node, result);
    -//   return result;
    -// }
    -//
    -// export async function markdownToBlocks(
    -//   markdown: string,
    -//   blockSchema: BSchema,
    -//   schema: Schema
    -// ): Promise[]> {
    -//   const htmlString = await unified()
    -//     .use(remarkParse)
    -//     .use(remarkGfm)
    -//     .use(remarkRehype, {
    -//       handlers: {
    -//         ...(defaultHandlers as any),
    -//         code,
    -//       },
    -//     })
    -//     .use(rehypeStringify)
    -//     .process(markdown);
    -//
    -//   return HTMLToBlocks(htmlString.value as string, blockSchema, schema);
    -// }
    -
    -export function markdown(cleanHTMLString: string) {
    -  const markdownString = unified()
    -    .use(rehypeParse, { fragment: true })
    -    .use(removeUnderlines)
    -    .use(rehypeRemark)
    -    .use(remarkGfm)
    -    .use(remarkStringify)
    -    .processSync(cleanHTMLString);
    -
    -  return markdownString.value as string;
    -}
    diff --git a/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap b/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap
    index 92be1196e5..41b67fb5ca 100644
    --- a/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap
    +++ b/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap
    @@ -1,6 +1,134 @@
     // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
     
    -exports[`Complex ProseMirror Node Conversions > Convert complex block to node 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: custom inline content schema > Convert mention/basic to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
    +        {
    +          "text": "I enjoy working with",
    +          "type": "text",
    +        },
    +        {
    +          "attrs": {
    +            "user": "Matthew",
    +          },
    +          "type": "mention",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: custom inline content schema > Convert tag/basic to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
    +        {
    +          "text": "I love ",
    +          "type": "text",
    +        },
    +        {
    +          "content": [
    +            {
    +              "text": "BlockNote",
    +              "type": "text",
    +            },
    +          ],
    +          "type": "tag",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert fontSize/basic to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "stringValue": "18px",
    +              },
    +              "type": "fontSize",
    +            },
    +          ],
    +          "text": "This is text with a custom fontSize",
    +          "type": "text",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert small/basic to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
    +        {
    +          "marks": [
    +            {
    +              "type": "small",
    +            },
    +          ],
    +          "text": "This is a small text",
    +          "type": "text",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert complex/misc to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "blue",
    @@ -89,68 +217,91 @@ exports[`Complex ProseMirror Node Conversions > Convert complex block to node 1`
     }
     `;
     
    -exports[`Complex ProseMirror Node Conversions > Convert complex node to block 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/basic to/from prosemirror 1`] = `
     {
    -  "children": [
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
         {
    -      "children": [],
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
           "content": [
             {
    -          "styles": {},
    -          "text": "Paragraph",
    +          "text": "Text1",
    +          "type": "text",
    +        },
    +        {
    +          "type": "hardBreak",
    +        },
    +        {
    +          "text": "Text2",
               "type": "text",
             },
           ],
    -      "id": "2",
    -      "props": {
    -        "backgroundColor": "red",
    -        "textAlignment": "left",
    -        "textColor": "default",
    -      },
           "type": "paragraph",
         },
    -    {
    -      "children": [],
    -      "content": [],
    -      "id": "3",
    -      "props": {
    -        "backgroundColor": "default",
    -        "textAlignment": "left",
    -        "textColor": "default",
    -      },
    -      "type": "bulletListItem",
    -    },
       ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/between-links to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
       "content": [
         {
    -      "styles": {
    -        "bold": true,
    -        "underline": true,
    -      },
    -      "text": "Heading ",
    -      "type": "text",
    -    },
    -    {
    -      "styles": {
    -        "italic": true,
    -        "strike": true,
    +      "attrs": {
    +        "textAlignment": "left",
           },
    -      "text": "2",
    -      "type": "text",
    +      "content": [
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "class": null,
    +                "href": "https://www.website.com",
    +                "target": "_blank",
    +              },
    +              "type": "link",
    +            },
    +          ],
    +          "text": "Link1",
    +          "type": "text",
    +        },
    +        {
    +          "type": "hardBreak",
    +        },
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "class": null,
    +                "href": "https://www.website2.com",
    +                "target": "_blank",
    +              },
    +              "type": "link",
    +            },
    +          ],
    +          "text": "Link2",
    +          "type": "text",
    +        },
    +      ],
    +      "type": "paragraph",
         },
       ],
    -  "id": "1",
    -  "props": {
    -    "backgroundColor": "blue",
    -    "level": 2,
    -    "textAlignment": "right",
    -    "textColor": "yellow",
    -  },
    -  "type": "heading",
    +  "type": "blockContainer",
     }
     `;
     
    -exports[`Simple ProseMirror Node Conversions > Convert simple block to node 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/end to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -162,6 +313,15 @@ exports[`Simple ProseMirror Node Conversions > Convert simple block to node 1`]
           "attrs": {
             "textAlignment": "left",
           },
    +      "content": [
    +        {
    +          "text": "Text1",
    +          "type": "text",
    +        },
    +        {
    +          "type": "hardBreak",
    +        },
    +      ],
           "type": "paragraph",
         },
       ],
    @@ -169,21 +329,59 @@ exports[`Simple ProseMirror Node Conversions > Convert simple block to node 1`]
     }
     `;
     
    -exports[`Simple ProseMirror Node Conversions > Convert simple node to block 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/link to/from prosemirror 1`] = `
     {
    -  "children": [],
    -  "content": [],
    -  "id": "1",
    -  "props": {
    +  "attrs": {
         "backgroundColor": "default",
    -    "textAlignment": "left",
    +    "id": "1",
         "textColor": "default",
       },
    -  "type": "paragraph",
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "class": null,
    +                "href": "https://www.website.com",
    +                "target": "_blank",
    +              },
    +              "type": "link",
    +            },
    +          ],
    +          "text": "Link1",
    +          "type": "text",
    +        },
    +        {
    +          "type": "hardBreak",
    +        },
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "class": null,
    +                "href": "https://www.website.com",
    +                "target": "_blank",
    +              },
    +              "type": "link",
    +            },
    +          ],
    +          "text": "Link1",
    +          "type": "text",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/multiple to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -207,6 +405,13 @@ exports[`hard breaks > Convert a block with a hard break 1`] = `
               "text": "Text2",
               "type": "text",
             },
    +        {
    +          "type": "hardBreak",
    +        },
    +        {
    +          "text": "Text3",
    +          "type": "text",
    +        },
           ],
           "type": "paragraph",
         },
    @@ -215,7 +420,7 @@ exports[`hard breaks > Convert a block with a hard break 1`] = `
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break and different styles 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/only to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -229,19 +434,34 @@ exports[`hard breaks > Convert a block with a hard break and different styles 1`
           },
           "content": [
             {
    -          "text": "Text1",
    -          "type": "text",
    +          "type": "hardBreak",
             },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/start to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
             {
               "type": "hardBreak",
             },
             {
    -          "marks": [
    -            {
    -              "type": "bold",
    -            },
    -          ],
    -          "text": "Text2",
    +          "text": "Text1",
               "type": "text",
             },
           ],
    @@ -252,7 +472,7 @@ exports[`hard breaks > Convert a block with a hard break and different styles 1`
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break at the end 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/styles to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -272,6 +492,15 @@ exports[`hard breaks > Convert a block with a hard break at the end 1`] = `
             {
               "type": "hardBreak",
             },
    +        {
    +          "marks": [
    +            {
    +              "type": "bold",
    +            },
    +          ],
    +          "text": "Text2",
    +          "type": "text",
    +        },
           ],
           "type": "paragraph",
         },
    @@ -280,7 +509,7 @@ exports[`hard breaks > Convert a block with a hard break at the end 1`] = `
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break at the start 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert image/basic to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -290,25 +519,87 @@ exports[`hard breaks > Convert a block with a hard break at the start 1`] = `
       "content": [
         {
           "attrs": {
    +        "caption": "Caption",
             "textAlignment": "left",
    +        "url": "exampleURL",
    +        "width": 256,
           },
    +      "type": "image",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert image/button to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "caption": "",
    +        "textAlignment": "left",
    +        "url": "",
    +        "width": 512,
    +      },
    +      "type": "image",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert image/nested to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "caption": "Caption",
    +        "textAlignment": "left",
    +        "url": "exampleURL",
    +        "width": 256,
    +      },
    +      "type": "image",
    +    },
    +    {
           "content": [
             {
    -          "type": "hardBreak",
    -        },
    -        {
    -          "text": "Text1",
    -          "type": "text",
    +          "attrs": {
    +            "backgroundColor": "default",
    +            "id": "2",
    +            "textColor": "default",
    +          },
    +          "content": [
    +            {
    +              "attrs": {
    +                "caption": "Caption",
    +                "textAlignment": "left",
    +                "url": "exampleURL",
    +                "width": 256,
    +              },
    +              "type": "image",
    +            },
    +          ],
    +          "type": "blockContainer",
             },
           ],
    -      "type": "paragraph",
    +      "type": "blockGroup",
         },
       ],
       "type": "blockContainer",
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break between links 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert link/adjacent to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -332,12 +623,9 @@ exports[`hard breaks > Convert a block with a hard break between links 1`] = `
                   "type": "link",
                 },
               ],
    -          "text": "Link1",
    +          "text": "Website",
               "type": "text",
             },
    -        {
    -          "type": "hardBreak",
    -        },
             {
               "marks": [
                 {
    @@ -349,7 +637,7 @@ exports[`hard breaks > Convert a block with a hard break between links 1`] = `
                   "type": "link",
                 },
               ],
    -          "text": "Link2",
    +          "text": "Website2",
               "type": "text",
             },
           ],
    @@ -360,7 +648,7 @@ exports[`hard breaks > Convert a block with a hard break between links 1`] = `
     }
     `;
     
    -exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert link/basic to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -384,11 +672,46 @@ exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
                   "type": "link",
                 },
               ],
    -          "text": "Link1",
    +          "text": "Website",
               "type": "text",
             },
    +      ],
    +      "type": "paragraph",
    +    },
    +  ],
    +  "type": "blockContainer",
    +}
    +`;
    +
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert link/styled to/from prosemirror 1`] = `
    +{
    +  "attrs": {
    +    "backgroundColor": "default",
    +    "id": "1",
    +    "textColor": "default",
    +  },
    +  "content": [
    +    {
    +      "attrs": {
    +        "textAlignment": "left",
    +      },
    +      "content": [
             {
    -          "type": "hardBreak",
    +          "marks": [
    +            {
    +              "type": "bold",
    +            },
    +            {
    +              "attrs": {
    +                "class": null,
    +                "href": "https://www.website.com",
    +                "target": "_blank",
    +              },
    +              "type": "link",
    +            },
    +          ],
    +          "text": "Web",
    +          "type": "text",
             },
             {
               "marks": [
    @@ -401,7 +724,7 @@ exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
                   "type": "link",
                 },
               ],
    -          "text": "Link1",
    +          "text": "site",
               "type": "text",
             },
           ],
    @@ -412,7 +735,7 @@ exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
     }
     `;
     
    -exports[`hard breaks > Convert a block with multiple hard breaks 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/basic to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -426,21 +749,7 @@ exports[`hard breaks > Convert a block with multiple hard breaks 1`] = `
           },
           "content": [
             {
    -          "text": "Text1",
    -          "type": "text",
    -        },
    -        {
    -          "type": "hardBreak",
    -        },
    -        {
    -          "text": "Text2",
    -          "type": "text",
    -        },
    -        {
    -          "type": "hardBreak",
    -        },
    -        {
    -          "text": "Text3",
    +          "text": "Paragraph",
               "type": "text",
             },
           ],
    @@ -451,7 +760,7 @@ exports[`hard breaks > Convert a block with multiple hard breaks 1`] = `
     }
     `;
     
    -exports[`hard breaks > Convert a block with only a hard break 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/empty to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -463,11 +772,6 @@ exports[`hard breaks > Convert a block with only a hard break 1`] = `
           "attrs": {
             "textAlignment": "left",
           },
    -      "content": [
    -        {
    -          "type": "hardBreak",
    -        },
    -      ],
           "type": "paragraph",
         },
       ],
    @@ -475,7 +779,7 @@ exports[`hard breaks > Convert a block with only a hard break 1`] = `
     }
     `;
     
    -exports[`links > Convert a block with link 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/nested to/from prosemirror 1`] = `
     {
       "attrs": {
         "backgroundColor": "default",
    @@ -489,66 +793,123 @@ exports[`links > Convert a block with link 1`] = `
           },
           "content": [
             {
    -          "marks": [
    +          "text": "Paragraph",
    +          "type": "text",
    +        },
    +      ],
    +      "type": "paragraph",
    +    },
    +    {
    +      "content": [
    +        {
    +          "attrs": {
    +            "backgroundColor": "default",
    +            "id": "2",
    +            "textColor": "default",
    +          },
    +          "content": [
                 {
                   "attrs": {
    -                "class": null,
    -                "href": "https://www.website.com",
    -                "target": "_blank",
    +                "textAlignment": "left",
                   },
    -              "type": "link",
    +              "content": [
    +                {
    +                  "text": "Nested Paragraph 1",
    +                  "type": "text",
    +                },
    +              ],
    +              "type": "paragraph",
                 },
               ],
    -          "text": "Website",
    -          "type": "text",
    +          "type": "blockContainer",
    +        },
    +        {
    +          "attrs": {
    +            "backgroundColor": "default",
    +            "id": "3",
    +            "textColor": "default",
    +          },
    +          "content": [
    +            {
    +              "attrs": {
    +                "textAlignment": "left",
    +              },
    +              "content": [
    +                {
    +                  "text": "Nested Paragraph 2",
    +                  "type": "text",
    +                },
    +              ],
    +              "type": "paragraph",
    +            },
    +          ],
    +          "type": "blockContainer",
             },
           ],
    -      "type": "paragraph",
    +      "type": "blockGroup",
         },
       ],
       "type": "blockContainer",
     }
     `;
     
    -exports[`links > Convert two adjacent links in a block 1`] = `
    +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/styled to/from prosemirror 1`] = `
     {
       "attrs": {
    -    "backgroundColor": "default",
    +    "backgroundColor": "pink",
         "id": "1",
    -    "textColor": "default",
    +    "textColor": "orange",
       },
       "content": [
         {
           "attrs": {
    -        "textAlignment": "left",
    +        "textAlignment": "center",
           },
           "content": [
    +        {
    +          "text": "Plain ",
    +          "type": "text",
    +        },
             {
               "marks": [
                 {
                   "attrs": {
    -                "class": null,
    -                "href": "https://www.website.com",
    -                "target": "_blank",
    +                "stringValue": "red",
                   },
    -              "type": "link",
    +              "type": "textColor",
                 },
               ],
    -          "text": "Website",
    +          "text": "Red Text ",
               "type": "text",
             },
             {
               "marks": [
                 {
                   "attrs": {
    -                "class": null,
    -                "href": "https://www.website2.com",
    -                "target": "_blank",
    +                "stringValue": "blue",
                   },
    -              "type": "link",
    +              "type": "backgroundColor",
                 },
               ],
    -          "text": "Website2",
    +          "text": "Blue Background ",
    +          "type": "text",
    +        },
    +        {
    +          "marks": [
    +            {
    +              "attrs": {
    +                "stringValue": "red",
    +              },
    +              "type": "textColor",
    +            },
    +            {
    +              "attrs": {
    +                "stringValue": "blue",
    +              },
    +              "type": "backgroundColor",
    +            },
    +          ],
    +          "text": "Mixed Colors",
               "type": "text",
             },
           ],
    diff --git a/packages/core/src/api/nodeConversions/nodeConversions.test.ts b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
    index 0207f27054..be1d1cfaf2 100644
    --- a/packages/core/src/api/nodeConversions/nodeConversions.test.ts
    +++ b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
    @@ -1,464 +1,70 @@
    -import { Editor } from "@tiptap/core";
     import { afterEach, beforeEach, describe, expect, it } from "vitest";
    -import { BlockNoteEditor, PartialBlock } from "../..";
    -import {
    -  DefaultBlockSchema,
    -  defaultBlockSchema,
    -} from "../../extensions/Blocks/api/defaultBlocks";
    -import UniqueID from "../../extensions/UniqueID/UniqueID";
    -import { blockToNode, nodeToBlock } from "./nodeConversions";
    -import { partialBlockToBlockForTesting } from "./testUtil";
    -
    -let editor: BlockNoteEditor;
    -let tt: Editor;
    -
    -beforeEach(() => {
    -  editor = new BlockNoteEditor();
    -  tt = editor._tiptapEditor;
    -});
    -
    -afterEach(() => {
    -  tt.destroy();
    -  editor = undefined as any;
    -  tt = undefined as any;
    -});
    -
    -describe("Simple ProseMirror Node Conversions", () => {
    -  it("Convert simple block to node", async () => {
    -    const block: PartialBlock = {
    -      type: "paragraph",
    -    };
    -    const firstNodeConversion = blockToNode(
    -      block,
    -      tt.schema
    -    );
    -
    -    expect(firstNodeConversion).toMatchSnapshot();
    -  });
    -
    -  it("Convert simple node to block", async () => {
    -    const node = tt.schema.nodes["blockContainer"].create(
    -      { id: UniqueID.options.generateID() },
    -      tt.schema.nodes["paragraph"].create()
    -    );
    -    const firstBlockConversion = nodeToBlock(node, defaultBlockSchema);
    -
    -    expect(firstBlockConversion).toMatchSnapshot();
    -
    -    const firstNodeConversion = blockToNode(
    -      firstBlockConversion,
    -      tt.schema
    -    );
    -
    -    expect(firstNodeConversion).toStrictEqual(node);
    -  });
    -});
    -
    -describe("Complex ProseMirror Node Conversions", () => {
    -  it("Convert complex block to node", async () => {
    -    const block: PartialBlock = {
    -      type: "heading",
    -      props: {
    -        backgroundColor: "blue",
    -        textColor: "yellow",
    -        textAlignment: "right",
    -        level: 2,
    -      },
    -      content: [
    -        {
    -          type: "text",
    -          text: "Heading ",
    -          styles: {
    -            bold: true,
    -            underline: true,
    -          },
    -        },
    -        {
    -          type: "text",
    -          text: "2",
    -          styles: {
    -            italic: true,
    -            strike: true,
    -          },
    -        },
    -      ],
    -      children: [
    -        {
    -          type: "paragraph",
    -          props: {
    -            backgroundColor: "red",
    -          },
    -          content: "Paragraph",
    -          children: [],
    -        },
    -        {
    -          type: "bulletListItem",
    -        },
    -      ],
    -    };
    -    const firstNodeConversion = blockToNode(
    -      block,
    -      tt.schema
    -    );
    -
    -    expect(firstNodeConversion).toMatchSnapshot();
    -  });
    -
    -  it("Convert complex node to block", async () => {
    -    const node = tt.schema.nodes["blockContainer"].create(
    -      {
    -        id: UniqueID.options.generateID(),
    -        backgroundColor: "blue",
    -        textColor: "yellow",
    -      },
    -      [
    -        tt.schema.nodes["heading"].create(
    -          { textAlignment: "right", level: 2 },
    -          [
    -            tt.schema.text("Heading ", [
    -              tt.schema.mark("bold"),
    -              tt.schema.mark("underline"),
    -            ]),
    -            tt.schema.text("2", [
    -              tt.schema.mark("italic"),
    -              tt.schema.mark("strike"),
    -            ]),
    -          ]
    -        ),
    -        tt.schema.nodes["blockGroup"].create({}, [
    -          tt.schema.nodes["blockContainer"].create(
    -            { id: UniqueID.options.generateID(), backgroundColor: "red" },
    -            [
    -              tt.schema.nodes["paragraph"].create(
    -                {},
    -                tt.schema.text("Paragraph")
    -              ),
    -            ]
    -          ),
    -          tt.schema.nodes["blockContainer"].create(
    -            { id: UniqueID.options.generateID() },
    -            [tt.schema.nodes["bulletListItem"].create()]
    -          ),
    -        ]),
    -      ]
    -    );
    -    const firstBlockConversion = nodeToBlock(node, defaultBlockSchema);
    -
    -    expect(firstBlockConversion).toMatchSnapshot();
    -
    -    const firstNodeConversion = blockToNode(
    -      firstBlockConversion,
    -      tt.schema
    -    );
    -
    -    expect(firstNodeConversion).toStrictEqual(node);
    -  });
    -});
    -
    -describe("links", () => {
    -  it("Convert a block with link", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "link",
    -          href: "https://www.website.com",
    -          content: "Website",
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert link block with marks", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "link",
    -          href: "https://www.website.com",
    -          content: [
    -            {
    -              type: "text",
    -              text: "Web",
    -              styles: {
    -                bold: true,
    -              },
    -            },
    -            {
    -              type: "text",
    -              text: "site",
    -              styles: {},
    -            },
    -          ],
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    // expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert two adjacent links in a block", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "link",
    -          href: "https://www.website.com",
    -          content: "Website",
    -        },
    -        {
    -          type: "link",
    -          href: "https://www.website2.com",
    -          content: "Website2",
    -        },
    -      ],
    -    };
    -
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
     
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -});
    -
    -describe("hard breaks", () => {
    -  it("Convert a block with a hard break", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "Text1\nText2",
    -          styles: {},
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with multiple hard breaks", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "Text1\nText2\nText3",
    -          styles: {},
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with a hard break at the start", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "\nText1",
    -          styles: {},
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with a hard break at the end", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "Text1\n",
    -          styles: {},
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with only a hard break", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "\n",
    -          styles: {},
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with a hard break and different styles", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "text",
    -          text: "Text1\n",
    -          styles: {},
    -        },
    -        {
    -          type: "text",
    -          text: "Text2",
    -          styles: { bold: true },
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with a hard break in a link", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "link",
    -          href: "https://www.website.com",
    -          content: "Link1\nLink1",
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    -
    -  it("Convert a block with a hard break between links", async () => {
    -    const block: PartialBlock = {
    -      id: UniqueID.options.generateID(),
    -      type: "paragraph",
    -      content: [
    -        {
    -          type: "link",
    -          href: "https://www.website.com",
    -          content: "Link1\n",
    -        },
    -        {
    -          type: "link",
    -          href: "https://www.website2.com",
    -          content: "Link2",
    -        },
    -      ],
    -    };
    -    const node = blockToNode(block, tt.schema);
    -    expect(node).toMatchSnapshot();
    -    const outputBlock = nodeToBlock(node, defaultBlockSchema);
    -
    -    // Temporary fix to set props to {}, because at this point
    -    // we don't have an easy way to access default props at runtime,
    -    // so partialBlockToBlockForTesting will not set them.
    -    (outputBlock as any).props = {};
    -    const fullOriginalBlock = partialBlockToBlockForTesting(block);
    -
    -    expect(outputBlock).toStrictEqual(fullOriginalBlock);
    -  });
    +import { BlockNoteEditor } from "../../BlockNoteEditor";
    +import { PartialBlock } from "../../extensions/Blocks/api/blocks/types";
    +import { customInlineContentTestCases } from "../testCases/cases/customInlineContent";
    +import { customStylesTestCases } from "../testCases/cases/customStyles";
    +import { defaultSchemaTestCases } from "../testCases/cases/defaultSchema";
    +import { blockToNode, nodeToBlock } from "./nodeConversions";
    +import { addIdsToBlock, partialBlockToBlockForTesting } from "./testUtil";
    +
    +function validateConversion(
    +  block: PartialBlock,
    +  editor: BlockNoteEditor
    +) {
    +  addIdsToBlock(block);
    +  const node = blockToNode(
    +    block,
    +    editor._tiptapEditor.schema,
    +    editor.styleSchema
    +  );
    +
    +  expect(node).toMatchSnapshot();
    +
    +  const outputBlock = nodeToBlock(
    +    node,
    +    editor.blockSchema,
    +    editor.inlineContentSchema,
    +    editor.styleSchema
    +  );
    +
    +  const fullOriginalBlock = partialBlockToBlockForTesting(
    +    editor.blockSchema,
    +    block
    +  );
    +
    +  expect(outputBlock).toStrictEqual(fullOriginalBlock);
    +}
    +
    +const testCases = [
    +  defaultSchemaTestCases,
    +  customStylesTestCases,
    +  customInlineContentTestCases,
    +];
    +
    +describe("Test BlockNote-Prosemirror conversion", () => {
    +  for (const testCase of testCases) {
    +    describe("Case: " + testCase.name, () => {
    +      let editor: BlockNoteEditor;
    +
    +      beforeEach(() => {
    +        editor = testCase.createEditor();
    +      });
    +
    +      afterEach(() => {
    +        editor._tiptapEditor.destroy();
    +        editor = undefined as any;
    +
    +        delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
    +      });
    +
    +      for (const document of testCase.documents) {
    +        // eslint-disable-next-line no-loop-func
    +        it("Convert " + document.name + " to/from prosemirror", () => {
    +          // NOTE: only converts first block
    +          validateConversion(document.blocks[0], editor);
    +        });
    +      }
    +    });
    +  }
     });
    diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts
    index 2974824ebd..391fbe1e5d 100644
    --- a/packages/core/src/api/nodeConversions/nodeConversions.ts
    +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts
    @@ -2,47 +2,53 @@ import { Mark } from "@tiptap/pm/model";
     import { Node, Schema } from "prosemirror-model";
     import {
       Block,
    -  BlockConfig,
       BlockSchema,
    -  BlockSpec,
       PartialBlock,
       PartialTableContent,
       TableContent,
    -} from "../../extensions/Blocks/api/blockTypes";
    +} from "../../extensions/Blocks/api/blocks/types";
     import {
    -  ColorStyle,
    +  CustomInlineContentConfig,
    +  CustomInlineContentFromConfig,
       InlineContent,
    +  InlineContentFromConfig,
    +  InlineContentSchema,
    +  PartialCustomInlineContentFromConfig,
       PartialInlineContent,
       PartialLink,
       StyledText,
    -  Styles,
    -  ToggledStyle,
    -} from "../../extensions/Blocks/api/inlineContentTypes";
    +  isLinkInlineContent,
    +  isPartialLinkInlineContent,
    +  isStyledTextInlineContent,
    +} from "../../extensions/Blocks/api/inlineContent/types";
    +import { StyleSchema, Styles } from "../../extensions/Blocks/api/styles/types";
     import { getBlockInfo } from "../../extensions/Blocks/helpers/getBlockInfoFromPos";
     import UniqueID from "../../extensions/UniqueID/UniqueID";
     import { UnreachableCaseError } from "../../shared/utils";
     
    -const toggleStyles = new Set([
    -  "bold",
    -  "italic",
    -  "underline",
    -  "strike",
    -  "code",
    -]);
    -const colorStyles = new Set(["textColor", "backgroundColor"]);
    -
     /**
      * Convert a StyledText inline element to a
      * prosemirror text node with the appropriate marks
      */
    -function styledTextToNodes(styledText: StyledText, schema: Schema): Node[] {
    +function styledTextToNodes(
    +  styledText: StyledText,
    +  schema: Schema,
    +  styleSchema: T
    +): Node[] {
       const marks: Mark[] = [];
     
       for (const [style, value] of Object.entries(styledText.styles)) {
    -    if (toggleStyles.has(style as ToggledStyle)) {
    +    const config = styleSchema[style];
    +    if (!config) {
    +      throw new Error(`style ${style} not found in styleSchema`);
    +    }
    +
    +    if (config.propSchema === "boolean") {
           marks.push(schema.mark(style));
    -    } else if (colorStyles.has(style as ColorStyle)) {
    -      marks.push(schema.mark(style, { color: value }));
    +    } else if (config.propSchema === "string") {
    +      marks.push(schema.mark(style, { stringValue: value }));
    +    } else {
    +      throw new UnreachableCaseError(config.propSchema);
         }
       }
     
    @@ -68,42 +74,53 @@ function styledTextToNodes(styledText: StyledText, schema: Schema): Node[] {
      * Converts a Link inline content element to
      * prosemirror text nodes with the appropriate marks
      */
    -function linkToNodes(link: PartialLink, schema: Schema): Node[] {
    +function linkToNodes(
    +  link: PartialLink,
    +  schema: Schema,
    +  styleSchema: StyleSchema
    +): Node[] {
       const linkMark = schema.marks.link.create({
         href: link.href,
       });
     
    -  return styledTextArrayToNodes(link.content, schema).map((node) => {
    -    if (node.type.name === "text") {
    -      return node.mark([...node.marks, linkMark]);
    -    }
    +  return styledTextArrayToNodes(link.content, schema, styleSchema).map(
    +    (node) => {
    +      if (node.type.name === "text") {
    +        return node.mark([...node.marks, linkMark]);
    +      }
     
    -    if (node.type.name === "hardBreak") {
    -      return node;
    +      if (node.type.name === "hardBreak") {
    +        return node;
    +      }
    +      throw new Error("unexpected node type");
         }
    -    throw new Error("unexpected node type");
    -  });
    +  );
     }
     
     /**
      * Converts an array of StyledText inline content elements to
      * prosemirror text nodes with the appropriate marks
      */
    -function styledTextArrayToNodes(
    -  content: string | StyledText[],
    -  schema: Schema
    +function styledTextArrayToNodes(
    +  content: string | StyledText[],
    +  schema: Schema,
    +  styleSchema: S
     ): Node[] {
       const nodes: Node[] = [];
     
       if (typeof content === "string") {
         nodes.push(
    -      ...styledTextToNodes({ type: "text", text: content, styles: {} }, schema)
    +      ...styledTextToNodes(
    +        { type: "text", text: content, styles: {} },
    +        schema,
    +        styleSchema
    +      )
         );
         return nodes;
       }
     
       for (const styledText of content) {
    -    nodes.push(...styledTextToNodes(styledText, schema));
    +    nodes.push(...styledTextToNodes(styledText, schema, styleSchema));
       }
       return nodes;
     }
    @@ -111,19 +128,27 @@ function styledTextArrayToNodes(
     /**
      * converts an array of inline content elements to prosemirror nodes
      */
    -export function inlineContentToNodes(
    -  blockContent: PartialInlineContent[],
    -  schema: Schema
    +export function inlineContentToNodes<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  blockContent: PartialInlineContent,
    +  schema: Schema,
    +  styleSchema: S
     ): Node[] {
       const nodes: Node[] = [];
     
       for (const content of blockContent) {
    -    if (content.type === "link") {
    -      nodes.push(...linkToNodes(content, schema));
    -    } else if (content.type === "text") {
    -      nodes.push(...styledTextArrayToNodes([content], schema));
    +    if (typeof content === "string") {
    +      nodes.push(...styledTextArrayToNodes(content, schema, styleSchema));
    +    } else if (isPartialLinkInlineContent(content)) {
    +      nodes.push(...linkToNodes(content, schema, styleSchema));
    +    } else if (isStyledTextInlineContent(content)) {
    +      nodes.push(...styledTextArrayToNodes([content], schema, styleSchema));
         } else {
    -      throw new UnreachableCaseError(content);
    +      nodes.push(
    +        blockOrInlineContentToContentNode(content, schema, styleSchema)
    +      );
         }
       }
       return nodes;
    @@ -132,9 +157,13 @@ export function inlineContentToNodes(
     /**
      * converts an array of inline content elements to prosemirror nodes
      */
    -export function tableContentToNodes(
    -  tableContent: PartialTableContent,
    -  schema: Schema
    +export function tableContentToNodes<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  tableContent: PartialTableContent,
    +  schema: Schema,
    +  styleSchema: StyleSchema
     ): Node[] {
       const rowNodes: Node[] = [];
     
    @@ -147,7 +176,7 @@ export function tableContentToNodes(
           } else if (typeof cell === "string") {
             pNode = schema.nodes["tableParagraph"].create({}, schema.text(cell));
           } else {
    -        const textNodes = inlineContentToNodes(cell, schema);
    +        const textNodes = inlineContentToNodes(cell, schema, styleSchema);
             pNode = schema.nodes["tableParagraph"].create({}, textNodes);
           }
     
    @@ -160,26 +189,24 @@ export function tableContentToNodes(
       return rowNodes;
     }
     
    -/**
    - * Converts a BlockNote block to a TipTap node.
    - */
    -export function blockToNode(
    -  block: PartialBlock,
    -  schema: Schema
    +function blockOrInlineContentToContentNode(
    +  block:
    +    | PartialBlock
    +    | PartialCustomInlineContentFromConfig,
    +  schema: Schema,
    +  styleSchema: StyleSchema
     ) {
    -  let id = block.id;
    -
    -  if (id === undefined) {
    -    id = UniqueID.options.generateID();
    -  }
    -
    +  let contentNode: Node;
       let type = block.type;
     
    +  // TODO: needed? came from previous code
       if (type === undefined) {
         type = "paragraph";
       }
     
    -  let contentNode: Node;
    +  if (!schema.nodes[type]) {
    +    throw new Error(`node type ${type} not found in schema`);
    +  }
     
       if (!block.content) {
         contentNode = schema.nodes[type].create(block.props);
    @@ -189,20 +216,41 @@ export function blockToNode(
           schema.text(block.content)
         );
       } else if (Array.isArray(block.content)) {
    -    const nodes = inlineContentToNodes(block.content, schema);
    +    const nodes = inlineContentToNodes(block.content, schema, styleSchema);
         contentNode = schema.nodes[type].create(block.props, nodes);
       } else if (block.content.type === "tableContent") {
    -    const nodes = tableContentToNodes(block.content, schema);
    +    const nodes = tableContentToNodes(block.content, schema, styleSchema);
         contentNode = schema.nodes[type].create(block.props, nodes);
       } else {
         throw new UnreachableCaseError(block.content.type);
       }
    +  return contentNode;
    +}
    +/**
    + * Converts a BlockNote block to a TipTap node.
    + */
    +export function blockToNode(
    +  block: PartialBlock,
    +  schema: Schema,
    +  styleSchema: StyleSchema
    +) {
    +  let id = block.id;
    +
    +  if (id === undefined) {
    +    id = UniqueID.options.generateID();
    +  }
    +
    +  const contentNode = blockOrInlineContentToContentNode(
    +    block,
    +    schema,
    +    styleSchema
    +  );
     
       const children: Node[] = [];
     
       if (block.children) {
         for (const child of block.children) {
    -      children.push(blockToNode(child, schema));
    +      children.push(blockToNode(child, schema, styleSchema));
         }
       }
     
    @@ -220,19 +268,28 @@ export function blockToNode(
     /**
      * Converts an internal (prosemirror) table node contentto a BlockNote Tablecontent
      */
    -function contentNodeToTableContent(contentNode: Node) {
    -  const ret: TableContent = {
    +function contentNodeToTableContent<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
    +  const ret: TableContent = {
         type: "tableContent",
         rows: [],
       };
     
       contentNode.content.forEach((rowNode) => {
    -    const row: TableContent["rows"][0] = {
    +    const row: TableContent["rows"][0] = {
           cells: [],
         };
     
         rowNode.content.forEach((cellNode) => {
    -      row.cells.push(contentNodeToInlineContent(cellNode.firstChild!));
    +      row.cells.push(
    +        contentNodeToInlineContent(
    +          cellNode.firstChild!,
    +          inlineContentSchema,
    +          styleSchema
    +        )
    +      );
         });
     
         ret.rows.push(row);
    @@ -244,9 +301,12 @@ function contentNodeToTableContent(contentNode: Node) {
     /**
      * Converts an internal (prosemirror) content node to a BlockNote InlineContent array.
      */
    -function contentNodeToInlineContent(contentNode: Node) {
    -  const content: InlineContent[] = [];
    -  let currentContent: InlineContent | undefined = undefined;
    +export function contentNodeToInlineContent<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
    +  const content: InlineContent[] = [];
    +  let currentContent: InlineContent | undefined = undefined;
     
       // Most of the logic below is for handling links because in ProseMirror links are marks
       // while in BlockNote links are a type of inline content
    @@ -256,13 +316,15 @@ function contentNodeToInlineContent(contentNode: Node) {
         if (node.type.name === "hardBreak") {
           if (currentContent) {
             // Current content exists.
    -        if (currentContent.type === "text") {
    +        if (isStyledTextInlineContent(currentContent)) {
               // Current content is text.
               currentContent.text += "\n";
    -        } else if (currentContent.type === "link") {
    +        } else if (isLinkInlineContent(currentContent)) {
               // Current content is a link.
               currentContent.content[currentContent.content.length - 1].text +=
                 "\n";
    +        } else {
    +          throw new Error("unexpected");
             }
           } else {
             // Current content does not exist.
    @@ -276,18 +338,41 @@ function contentNodeToInlineContent(contentNode: Node) {
           return;
         }
     
    -    const styles: Styles = {};
    +    if (
    +      node.type.name !== "link" &&
    +      node.type.name !== "text" &&
    +      inlineContentSchema[node.type.name]
    +    ) {
    +      if (currentContent) {
    +        content.push(currentContent);
    +        currentContent = undefined;
    +      }
    +
    +      content.push(
    +        nodeToCustomInlineContent(node, inlineContentSchema, styleSchema)
    +      );
    +
    +      return;
    +    }
    +
    +    const styles: Styles = {};
         let linkMark: Mark | undefined;
     
         for (const mark of node.marks) {
           if (mark.type.name === "link") {
             linkMark = mark;
    -      } else if (toggleStyles.has(mark.type.name as ToggledStyle)) {
    -        styles[mark.type.name as ToggledStyle] = true;
    -      } else if (colorStyles.has(mark.type.name as ColorStyle)) {
    -        styles[mark.type.name as ColorStyle] = mark.attrs.color;
           } else {
    -        throw Error("Mark is of an unrecognized type: " + mark.type.name);
    +        const config = styleSchema[mark.type.name];
    +        if (!config) {
    +          throw new Error(`style ${mark.type.name} not found in styleSchema`);
    +        }
    +        if (config.propSchema === "boolean") {
    +          (styles as any)[config.type] = true;
    +        } else if (config.propSchema === "string") {
    +          (styles as any)[config.type] = mark.attrs.stringValue;
    +        } else {
    +          throw new UnreachableCaseError(config.propSchema);
    +        }
           }
         }
     
    @@ -295,7 +380,7 @@ function contentNodeToInlineContent(contentNode: Node) {
         // Current content exists.
         if (currentContent) {
           // Current content is text.
    -      if (currentContent.type === "text") {
    +      if (isStyledTextInlineContent(currentContent)) {
             if (!linkMark) {
               // Node is text (same type as current content).
               if (
    @@ -327,7 +412,7 @@ function contentNodeToInlineContent(contentNode: Node) {
                 ],
               };
             }
    -      } else if (currentContent.type === "link") {
    +      } else if (isLinkInlineContent(currentContent)) {
             // Current content is a link.
             if (linkMark) {
               // Node is a link (same type as current content).
    @@ -373,6 +458,8 @@ function contentNodeToInlineContent(contentNode: Node) {
                 styles,
               };
             }
    +      } else {
    +        // TODO
           }
         }
         // Current content does not exist.
    @@ -406,17 +493,66 @@ function contentNodeToInlineContent(contentNode: Node) {
         content.push(currentContent);
       }
     
    -  return content;
    +  return content as InlineContent[];
    +}
    +
    +export function nodeToCustomInlineContent<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(node: Node, inlineContentSchema: I, styleSchema: S): InlineContent {
    +  if (node.type.name === "text" || node.type.name === "link") {
    +    throw new Error("unexpected");
    +  }
    +  const props: any = {};
    +  const icConfig = inlineContentSchema[
    +    node.type.name
    +  ] as CustomInlineContentConfig;
    +  for (const [attr, value] of Object.entries(node.attrs)) {
    +    if (!icConfig) {
    +      throw Error("ic node is of an unrecognized type: " + node.type.name);
    +    }
    +
    +    const propSchema = icConfig.propSchema;
    +
    +    if (attr in propSchema) {
    +      props[attr] = value;
    +    }
    +  }
    +
    +  let content: CustomInlineContentFromConfig["content"];
    +
    +  if (icConfig.content === "styled") {
    +    content = contentNodeToInlineContent(
    +      node,
    +      inlineContentSchema,
    +      styleSchema
    +    ) as any; // TODO: is this safe? could we have Links here that are undesired?
    +  } else {
    +    content = undefined;
    +  }
    +
    +  const ic = {
    +    type: node.type.name,
    +    props,
    +    content,
    +  } as InlineContentFromConfig;
    +  return ic;
     }
     
     /**
      * Convert a TipTap node to a BlockNote block.
      */
    -export function nodeToBlock(
    +export function nodeToBlock<
    +  BSchema extends BlockSchema,
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
       node: Node,
       blockSchema: BSchema,
    -  blockCache?: WeakMap>
    -): Block {
    +  inlineContentSchema: I,
    +  styleSchema: S,
    +  blockCache?: WeakMap>
    +): Block {
       if (node.type.name !== "blockContainer") {
         throw Error(
           "Node must be of type blockContainer, but is of type" +
    @@ -445,9 +581,7 @@ export function nodeToBlock(
         ...node.attrs,
         ...blockInfo.contentNode.attrs,
       })) {
    -    const blockSpec = blockSchema[
    -      blockInfo.contentType.name
    -    ] as BlockSpec; // TODO: fix cast
    +    const blockSpec = blockSchema[blockInfo.contentType.name];
     
         if (!blockSpec) {
           throw Error(
    @@ -455,43 +589,55 @@ export function nodeToBlock(
           );
         }
     
    -    const propSchema = blockSpec.config.propSchema;
    +    const propSchema = blockSpec.propSchema;
     
         if (attr in propSchema) {
           props[attr] = value;
         }
       }
     
    -  const blockSpec = blockSchema[
    -    blockInfo.contentType.name
    -  ] as BlockSpec; // TODO: fix cast
    +  const blockConfig = blockSchema[blockInfo.contentType.name];
     
    -  const children: Block[] = [];
    +  const children: Block[] = [];
       for (let i = 0; i < blockInfo.numChildBlocks; i++) {
         children.push(
    -      nodeToBlock(node.lastChild!.child(i), blockSchema, blockCache)
    +      nodeToBlock(
    +        node.lastChild!.child(i),
    +        blockSchema,
    +        inlineContentSchema,
    +        styleSchema,
    +        blockCache
    +      )
         );
       }
     
    -  let content: Block["content"];
    +  let content: Block["content"];
     
    -  if (blockSpec.config.content === "inline") {
    -    content = contentNodeToInlineContent(blockInfo.contentNode);
    -  } else if (blockSpec.config.content === "table") {
    -    content = contentNodeToTableContent(blockInfo.contentNode);
    -  } else if (blockSpec.config.content === "none") {
    +  if (blockConfig.content === "inline") {
    +    content = contentNodeToInlineContent(
    +      blockInfo.contentNode,
    +      inlineContentSchema,
    +      styleSchema
    +    );
    +  } else if (blockConfig.content === "table") {
    +    content = contentNodeToTableContent(
    +      blockInfo.contentNode,
    +      inlineContentSchema,
    +      styleSchema
    +    );
    +  } else if (blockConfig.content === "none") {
         content = undefined;
       } else {
    -    throw new UnreachableCaseError(blockSpec.config.content);
    +    throw new UnreachableCaseError(blockConfig.content);
       }
     
       const block = {
         id,
    -    type: blockSpec.config.type,
    +    type: blockConfig.type,
         props,
         content,
         children,
    -  } as Block;
    +  } as Block;
     
       blockCache?.set(node, block);
     
    diff --git a/packages/core/src/api/nodeConversions/testUtil.ts b/packages/core/src/api/nodeConversions/testUtil.ts
    index 490f989958..3398e19d2d 100644
    --- a/packages/core/src/api/nodeConversions/testUtil.ts
    +++ b/packages/core/src/api/nodeConversions/testUtil.ts
    @@ -3,16 +3,21 @@ import {
       BlockSchema,
       PartialBlock,
       TableContent,
    -} from "../../extensions/Blocks/api/blockTypes";
    +} from "../../extensions/Blocks/api/blocks/types";
     import {
       InlineContent,
    +  InlineContentSchema,
       PartialInlineContent,
       StyledText,
    -} from "../../extensions/Blocks/api/inlineContentTypes";
    +  isPartialLinkInlineContent,
    +  isStyledTextInlineContent,
    +} from "../../extensions/Blocks/api/inlineContent/types";
    +import { StyleSchema } from "../../extensions/Blocks/api/styles/types";
    +import UniqueID from "../../extensions/UniqueID/UniqueID";
     
     function textShorthandToStyledText(
    -  content: string | StyledText[] = ""
    -): StyledText[] {
    +  content: string | StyledText[] = ""
    +): StyledText[] {
       if (typeof content === "string") {
         return [
           {
    @@ -26,21 +31,31 @@ function textShorthandToStyledText(
     }
     
     function partialContentToInlineContent(
    -  content: string | PartialInlineContent[] | TableContent = ""
    -): InlineContent[] | TableContent {
    +  content: PartialInlineContent | TableContent | undefined
    +): InlineContent[] | TableContent | undefined {
       if (typeof content === "string") {
         return textShorthandToStyledText(content);
       }
     
       if (Array.isArray(content)) {
    -    return content.map((partialContent) => {
    -      if (partialContent.type === "link") {
    +    return content.flatMap((partialContent) => {
    +      if (typeof partialContent === "string") {
    +        return textShorthandToStyledText(partialContent);
    +      } else if (isPartialLinkInlineContent(partialContent)) {
             return {
               ...partialContent,
               content: textShorthandToStyledText(partialContent.content),
             };
    -      } else {
    +      } else if (isStyledTextInlineContent(partialContent)) {
             return partialContent;
    +      } else {
    +        // custom inline content
    +
    +        return {
    +          props: {},
    +          ...partialContent,
    +          content: partialContentToInlineContent(partialContent.content),
    +        } as any;
           }
         });
       }
    @@ -48,23 +63,65 @@ function partialContentToInlineContent(
       return content;
     }
     
    -export function partialBlockToBlockForTesting(
    -  partialBlock: PartialBlock
    -): Block {
    -  const withDefaults: Block = {
    +export function partialBlocksToBlocksForTesting<
    +  BSchema extends BlockSchema,
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  schema: BSchema,
    +  partialBlocks: Array>
    +): Array> {
    +  return partialBlocks.map((partialBlock) =>
    +    partialBlockToBlockForTesting(schema, partialBlock)
    +  );
    +}
    +
    +export function partialBlockToBlockForTesting<
    +  BSchema extends BlockSchema,
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  schema: BSchema,
    +  partialBlock: PartialBlock
    +): Block {
    +  const withDefaults: Block = {
         id: "",
    -    type: "paragraph",
    -    // because at this point we don't have an easy way to access default props at runtime,
    -    // partialBlockToBlockForTesting will not set them.
    +    type: partialBlock.type!,
         props: {} as any,
    -    content: [] as any,
    +    content:
    +      schema[partialBlock.type!].content === "inline" ? [] : (undefined as any),
         children: [] as any,
         ...partialBlock,
       };
     
    +  Object.entries(schema[partialBlock.type!].propSchema).forEach(
    +    ([propKey, propValue]) => {
    +      if (withDefaults.props[propKey] === undefined) {
    +        (withDefaults.props as any)[propKey] = propValue.default;
    +      }
    +    }
    +  );
    +
       return {
         ...withDefaults,
         content: partialContentToInlineContent(withDefaults.content),
    -    children: withDefaults.children.map(partialBlockToBlockForTesting),
    +    children: withDefaults.children.map((c) => {
    +      return partialBlockToBlockForTesting(schema, c);
    +    }),
       } as any;
     }
    +
    +export function addIdsToBlock(block: PartialBlock) {
    +  if (!block.id) {
    +    block.id = UniqueID.options.generateID();
    +  }
    +  if (block.children) {
    +    addIdsToBlocks(block.children);
    +  }
    +}
    +
    +export function addIdsToBlocks(blocks: PartialBlock[]) {
    +  for (const block of blocks) {
    +    addIdsToBlock(block);
    +  }
    +}
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json b/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json
    new file mode 100644
    index 0000000000..7ef10bf491
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json
    @@ -0,0 +1,105 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "First",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Second",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "3",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Third",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "4",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Five Parent",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "5",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Child 1",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "6",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Child 2",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json
    new file mode 100644
    index 0000000000..2d11e081f6
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json
    @@ -0,0 +1,140 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 1
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 1",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 2
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 2",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "3",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 3
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 3",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "4",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Paragraph",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "5",
    +    "type": "image",
    +    "props": {
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "url": "exampleURL",
    +      "caption": "Image Caption",
    +      "width": 512
    +    },
    +    "children": []
    +  },
    +  {
    +    "id": "6",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "None ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "Bold ",
    +        "styles": {
    +          "bold": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Italic ",
    +        "styles": {
    +          "italic": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Underline ",
    +        "styles": {
    +          "underline": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Strikethrough ",
    +        "styles": {
    +          "strike": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "All",
    +        "styles": {
    +          "bold": true,
    +          "italic": true,
    +          "underline": true,
    +          "strike": true
    +        }
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json
    new file mode 100644
    index 0000000000..ae11e36cb7
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json
    @@ -0,0 +1,240 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Outer 1 Div Before",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": " Outer 2 Div Before",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "3",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": " Outer 3 Div Before",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "4",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": " Outer 4 Div Before",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "5",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 1
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 1",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "6",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 2
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 2",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "7",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 3
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 3",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "8",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Paragraph",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "9",
    +    "type": "image",
    +    "props": {
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "url": "exampleURL",
    +      "caption": "Image Caption",
    +      "width": 512
    +    },
    +    "children": []
    +  },
    +  {
    +    "id": "10",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bold",
    +        "styles": {
    +          "bold": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": " ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "Italic",
    +        "styles": {
    +          "italic": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": " ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "Underline",
    +        "styles": {
    +          "underline": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": " ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "Strikethrough",
    +        "styles": {
    +          "strike": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": " ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "All",
    +        "styles": {
    +          "bold": true,
    +          "italic": true,
    +          "underline": true,
    +          "strike": true
    +        }
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "11",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": " Outer 4 Div After Outer 3 Div After Outer 2 Div After Outer 1 Div After",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json
    new file mode 100644
    index 0000000000..d06969a05f
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json
    @@ -0,0 +1,91 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "None ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "Bold ",
    +        "styles": {
    +          "bold": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Italic ",
    +        "styles": {
    +          "italic": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Underline ",
    +        "styles": {
    +          "underline": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Strikethrough ",
    +        "styles": {
    +          "strike": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "All",
    +        "styles": {
    +          "bold": true,
    +          "italic": true,
    +          "underline": true,
    +          "strike": true
    +        }
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "3",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Paragraph",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json
    new file mode 100644
    index 0000000000..764afd66ac
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json
    @@ -0,0 +1,121 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Single Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": " Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "3",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "4",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "5",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Single Div 2",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "6",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "7",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json
    new file mode 100644
    index 0000000000..86a0cb8168
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json
    @@ -0,0 +1,31 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "image",
    +    "props": {
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "url": "exampleURL",
    +      "caption": "",
    +      "width": 512
    +    },
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Image Caption",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json
    new file mode 100644
    index 0000000000..7bb12cd2cb
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json
    @@ -0,0 +1,140 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "2",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "3",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "4",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "5",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "6",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "7",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "8",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json
    new file mode 100644
    index 0000000000..cc6065d2d4
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json
    @@ -0,0 +1,140 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "2",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "3",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "4",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "5",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "6",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "7",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "8",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json
    new file mode 100644
    index 0000000000..e20435c9c8
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json
    @@ -0,0 +1,157 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "3",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "4",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "5",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "6",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "7",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "8",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "9",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json
    new file mode 100644
    index 0000000000..aa21de34f0
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json
    @@ -0,0 +1,36 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Single Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "second Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/parseHTML.test.ts b/packages/core/src/api/parsers/html/parseHTML.test.ts
    new file mode 100644
    index 0000000000..5bd8238e3f
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/parseHTML.test.ts
    @@ -0,0 +1,267 @@
    +import { describe, expect, it } from "vitest";
    +import { BlockNoteEditor } from "../../..";
    +import { nestedListsToBlockNoteStructure } from "./util/nestedLists";
    +
    +async function parseHTMLAndCompareSnapshots(
    +  html: string,
    +  snapshotName: string
    +) {
    +  const view: any = await import("prosemirror-view");
    +
    +  const editor = BlockNoteEditor.create();
    +  const blocks = await editor.tryParseHTMLToBlocks(html);
    +
    +  const snapshotPath = "./__snapshots__/paste/" + snapshotName + ".json";
    +  expect(JSON.stringify(blocks, undefined, 2)).toMatchFileSnapshot(
    +    snapshotPath
    +  );
    +
    +  // Now, we also want to test actually pasting in the editor, and not just calling
    +  // tryParseHTMLToBlocks directly.
    +  // The reason is that the prosemirror logic for pasting can be a bit different, because
    +  // it's related to the context of where the user is pasting exactly (selection)
    +  //
    +  // The internal difference come that in tryParseHTMLToBlocks, we use DOMParser.parse,
    +  // while when pasting, Prosemirror uses DOMParser.parseSlice, and then tries to fit the
    +  // slice in the document. This fitting might change the structure / interpretation of the pasted blocks
    +
    +  // Simulate a paste event (this uses DOMParser.parseSlice internally)
    +
    +  (window as any).__TEST_OPTIONS.mockID = 0; // reset id counter
    +  const htmlNode = nestedListsToBlockNoteStructure(html);
    +  const tt = editor._tiptapEditor;
    +
    +  const slice = view.__parseFromClipboard(
    +    tt.view,
    +    "",
    +    htmlNode.innerHTML,
    +    false,
    +    tt.view.state.selection.$from
    +  );
    +  tt.view.dispatch(tt.view.state.tr.replaceSelection(slice));
    +
    +  // alternative paste simulation doesn't work in a non-browser vitest env
    +  //   editor._tiptapEditor.view.pasteHTML(html, {
    +  //     preventDefault: () => {
    +  //       // noop
    +  //     },
    +  //     clipboardData: {
    +  //       types: ["text/html"],
    +  //       getData: () => html,
    +  //     },
    +  //   } as any);
    +
    +  const pastedBlocks = editor.topLevelBlocks;
    +  pastedBlocks.pop(); // trailing paragraph
    +  expect(pastedBlocks).toStrictEqual(blocks);
    +}
    +
    +describe("Parse HTML", () => {
    +  it("Parse basic block types", async () => {
    +    const html = `

    Heading 1

    +

    Heading 2

    +

    Heading 3

    +

    Paragraph

    +
    Image Caption
    +

    None Bold Italic Underline Strikethrough All

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

      Bullet List Item

      +
        +
      • +

        Nested Bullet List Item

        +
      • +
      • +

        Nested Bullet List Item

        +
      • +
      +
    • +
    • +

      Bullet List Item

      +
    • +
    +
      +
    1. +

      Numbered List Item

      +
        +
      1. +

        Nested Numbered List Item

        +
      2. +
      3. +

        Nested Numbered List Item

        +
      4. +
      +
    2. +
    3. +

      Numbered List Item

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

        Nested Bullet List Item

        +
      • +
      • +

        Nested Bullet List Item

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

    Image Caption

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

    Heading 1

    +

    Heading 2

    +

    Heading 3

    +

    Paragraph

    +
    Image Caption
    +

    Bold Italic Underline Strikethrough All

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

    Nested Paragraph

    +
    `; + + await parseHTMLAndCompareSnapshots(html, "parse-div-with-inline-content"); + }); +}); diff --git a/packages/core/src/api/parsers/html/parseHTML.ts b/packages/core/src/api/parsers/html/parseHTML.ts new file mode 100644 index 0000000000..cf4e983248 --- /dev/null +++ b/packages/core/src/api/parsers/html/parseHTML.ts @@ -0,0 +1,36 @@ +import { DOMParser, Schema } from "prosemirror-model"; +import { Block, BlockSchema, nodeToBlock } from "../../.."; +import { InlineContentSchema } from "../../../extensions/Blocks/api/inlineContent/types"; +import { StyleSchema } from "../../../extensions/Blocks/api/styles/types"; +import { nestedListsToBlockNoteStructure } from "./util/nestedLists"; + +export async function HTMLToBlocks< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + html: string, + blockSchema: BSchema, + icSchema: I, + styleSchema: S, + pmSchema: Schema +): Promise[]> { + const htmlNode = nestedListsToBlockNoteStructure(html); + const parser = DOMParser.fromSchema(pmSchema); + + // const doc = pmSchema.nodes["doc"].createAndFill()!; + + const parentNode = parser.parse(htmlNode, { + topNode: pmSchema.nodes["blockGroup"].create(), + // context: doc.resolve(3), + }); //, { preserveWhitespace: "full" }); + const blocks: Block[] = []; + + for (let i = 0; i < parentNode.childCount; i++) { + blocks.push( + nodeToBlock(parentNode.child(i), blockSchema, icSchema, styleSchema) + ); + } + + return blocks; +} diff --git a/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap b/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap new file mode 100644 index 0000000000..d697b8db72 --- /dev/null +++ b/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap @@ -0,0 +1,129 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Lift nested lists > Lifts multiple bullet lists 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
        +
      • Nested Bullet List Item 3
      • +
      • Nested Bullet List Item 4
      • +
      +
      +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts multiple bullet lists with content in between 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
      +
      +
      +
    • In between content
    • +
      +
        +
      • Nested Bullet List Item 3
      • +
      • Nested Bullet List Item 4
      • +
      +
      +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested bullet lists 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
      +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested bullet lists with content after nested list 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
      +
      +
    • More content in list item 1
    • +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested bullet lists without li 1`] = ` +" +
      Bullet List Item 1 +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested mixed lists 1`] = ` +" +
      +
      +
    1. Numbered List Item 1
    2. +
      +
        +
      • Bullet List Item 1
      • +
      • Bullet List Item 2
      • +
      +
      +
      +
    3. Numbered List Item 2
    4. +
    +" +`; + +exports[`Lift nested lists > Lifts nested numbered lists 1`] = ` +" +
      +
      +
    1. Numbered List Item 1
    2. +
      +
        +
      1. Nested Numbered List Item 1
      2. +
      3. Nested Numbered List Item 2
      4. +
      +
      +
      +
    3. Numbered List Item 2
    4. +
    +" +`; diff --git a/packages/core/src/api/parsers/html/util/nestedLists.test.ts b/packages/core/src/api/parsers/html/util/nestedLists.test.ts new file mode 100644 index 0000000000..96b0e1e9d2 --- /dev/null +++ b/packages/core/src/api/parsers/html/util/nestedLists.test.ts @@ -0,0 +1,176 @@ +import rehypeFormat from "rehype-format"; +import rehypeParse from "rehype-parse"; +import rehypeStringify from "rehype-stringify"; +import { unified } from "unified"; +import { describe, expect, it } from "vitest"; +import { nestedListsToBlockNoteStructure } from "./nestedLists"; + +async function testHTML(html: string) { + const htmlNode = nestedListsToBlockNoteStructure(html); + + const pretty = await unified() + .use(rehypeParse, { fragment: true }) + .use(rehypeFormat) + .use(rehypeStringify) + .process(htmlNode.innerHTML); + + expect(pretty.value).toMatchSnapshot(); +} + +describe("Lift nested lists", () => { + it("Lifts nested bullet lists", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts nested bullet lists without li", async () => { + const html = `
      + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts nested bullet lists with content after nested list", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      + More content in list item 1 +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts multiple bullet lists", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      +
        +
      • + Nested Bullet List Item 3 +
      • +
      • + Nested Bullet List Item 4 +
      • +
      +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts multiple bullet lists with content in between", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      + In between content +
        +
      • + Nested Bullet List Item 3 +
      • +
      • + Nested Bullet List Item 4 +
      • +
      +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts nested numbered lists", async () => { + const html = `
      +
    1. + Numbered List Item 1 +
        +
      1. + Nested Numbered List Item 1 +
      2. +
      3. + Nested Numbered List Item 2 +
      4. +
      +
    2. +
    3. + Numbered List Item 2 +
    4. +
    `; + await testHTML(html); + }); + + it("Lifts nested mixed lists", async () => { + const html = `
      +
    1. + Numbered List Item 1 +
        +
      • + Bullet List Item 1 +
      • +
      • + Bullet List Item 2 +
      • +
      +
    2. +
    3. + Numbered List Item 2 +
    4. +
    `; + await testHTML(html); + }); +}); diff --git a/packages/core/src/api/parsers/html/util/nestedLists.ts b/packages/core/src/api/parsers/html/util/nestedLists.ts new file mode 100644 index 0000000000..78c60b2a1a --- /dev/null +++ b/packages/core/src/api/parsers/html/util/nestedLists.ts @@ -0,0 +1,113 @@ +function getChildIndex(node: Element) { + return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); +} + +function isWhitespaceNode(node: Node) { + return node.nodeType === 3 && !/\S/.test(node.nodeValue || ""); +} + +/** + * Step 1, Turns: + * + *
      + *
    • item
    • + *
    • + *
        + *
      • ...
      • + *
      • ...
      • + *
      + *
    • + * + * Into: + *
        + *
      • item
      • + *
          + *
        • ...
        • + *
        • ...
        • + *
        + *
      + * + */ +function liftNestedListsToParent(element: HTMLElement) { + element.querySelectorAll("li > ul, li > ol").forEach((list) => { + const index = getChildIndex(list); + const parentListItem = list.parentElement!; + const siblingsAfter = Array.from(parentListItem.childNodes).slice( + index + 1 + ); + list.remove(); + siblingsAfter.forEach((sibling) => { + sibling.remove(); + }); + + parentListItem.insertAdjacentElement("afterend", list); + + siblingsAfter.reverse().forEach((sibling) => { + if (isWhitespaceNode(sibling)) { + return; + } + const siblingContainer = document.createElement("li"); + siblingContainer.append(sibling); + list.insertAdjacentElement("afterend", siblingContainer); + }); + if (parentListItem.childNodes.length === 0) { + parentListItem.remove(); + } + }); +} + +/** + * Step 2, Turns (output of liftNestedListsToParent): + * + *
    • item
    • + *
        + *
      • ...
      • + *
      • ...
      • + *
      + * + * Into: + *
      + *
    • item
    • + *
      + *
        + *
      • ...
      • + *
      • ...
      • + *
      + *
      + *
      + * + * This resulting format is parsed + */ +function createGroups(element: HTMLElement) { + element.querySelectorAll("li + ul, li + ol").forEach((list) => { + const listItem = list.previousElementSibling as HTMLElement; + const blockContainer = document.createElement("div"); + + listItem.insertAdjacentElement("afterend", blockContainer); + blockContainer.append(listItem); + + const blockGroup = document.createElement("div"); + blockGroup.setAttribute("data-node-type", "blockGroup"); + blockContainer.append(blockGroup); + + while ( + blockContainer.nextElementSibling?.nodeName === "UL" || + blockContainer.nextElementSibling?.nodeName === "OL" + ) { + blockGroup.append(blockContainer.nextElementSibling); + } + }); +} + +export function nestedListsToBlockNoteStructure( + elementOrHTML: HTMLElement | string +) { + if (typeof elementOrHTML === "string") { + const element = document.createElement("div"); + element.innerHTML = elementOrHTML; + elementOrHTML = element; + } + liftNestedListsToParent(elementOrHTML); + createGroups(elementOrHTML); + return elementOrHTML; +} diff --git a/packages/core/src/api/parsers/markdown/parseMarkdown.ts b/packages/core/src/api/parsers/markdown/parseMarkdown.ts new file mode 100644 index 0000000000..f81cb7a0b3 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/parseMarkdown.ts @@ -0,0 +1,80 @@ +import { Schema } from "prosemirror-model"; +import rehypeStringify from "rehype-stringify"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import remarkRehype, { defaultHandlers } from "remark-rehype"; +import { unified } from "unified"; +import { Block, BlockSchema, InlineContentSchema, StyleSchema } from "../../.."; +import { HTMLToBlocks } from "../html/parseHTML"; + +// modified version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js +// that outputs a data-language attribute instead of a CSS class (e.g.: language-typescript) +function code(state: any, node: any) { + const value = node.value ? node.value + "\n" : ""; + /** @type {Properties} */ + const properties: any = {}; + + if (node.lang) { + // changed line + properties["data-language"] = node.lang; + } + + // Create ``. + /** @type {Element} */ + let result: any = { + type: "element", + tagName: "code", + properties, + children: [{ type: "text", value }], + }; + + if (node.meta) { + result.data = { meta: node.meta }; + } + + state.patch(node, result); + result = state.applyData(node, result); + + // Create `
      `.
      +  result = {
      +    type: "element",
      +    tagName: "pre",
      +    properties: {},
      +    children: [result],
      +  };
      +  state.patch(node, result);
      +  return result;
      +}
      +
      +// TODO: add tests
      +export function markdownToBlocks<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  markdown: string,
      +  blockSchema: BSchema,
      +  icSchema: I,
      +  styleSchema: S,
      +  pmSchema: Schema
      +): Promise[]> {
      +  const htmlString = unified()
      +    .use(remarkParse)
      +    .use(remarkGfm)
      +    .use(remarkRehype, {
      +      handlers: {
      +        ...(defaultHandlers as any),
      +        code,
      +      },
      +    })
      +    .use(rehypeStringify)
      +    .processSync(markdown);
      +
      +  return HTMLToBlocks(
      +    htmlString.value as string,
      +    blockSchema,
      +    icSchema,
      +    styleSchema,
      +    pmSchema
      +  );
      +}
      diff --git a/packages/core/src/api/parsers/pasteExtension.ts b/packages/core/src/api/parsers/pasteExtension.ts
      new file mode 100644
      index 0000000000..f0dec4f86d
      --- /dev/null
      +++ b/packages/core/src/api/parsers/pasteExtension.ts
      @@ -0,0 +1,61 @@
      +import { Extension } from "@tiptap/core";
      +import { Plugin } from "prosemirror-state";
      +
      +import { BlockNoteEditor } from "../../BlockNoteEditor";
      +import { BlockSchema } from "../../extensions/Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../../extensions/Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../../extensions/Blocks/api/styles/types";
      +import { nestedListsToBlockNoteStructure } from "./html/util/nestedLists";
      +
      +const acceptedMIMETypes = [
      +  "blocknote/html",
      +  "text/html",
      +  "text/plain",
      +] as const;
      +
      +export const createPasteFromClipboardExtension = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  editor: BlockNoteEditor
      +) =>
      +  Extension.create<{ editor: BlockNoteEditor }, undefined>({
      +    name: "pasteFromClipboard",
      +    addProseMirrorPlugins() {
      +      return [
      +        new Plugin({
      +          props: {
      +            handleDOMEvents: {
      +              paste(_view, event) {
      +                event.preventDefault();
      +                let format: (typeof acceptedMIMETypes)[number] | null = null;
      +
      +                for (const mimeType of acceptedMIMETypes) {
      +                  if (event.clipboardData!.types.includes(mimeType)) {
      +                    format = mimeType;
      +                    break;
      +                  }
      +                }
      +
      +                if (format !== null) {
      +                  let data = event.clipboardData!.getData(format);
      +                  if (format === "text/html") {
      +                    const htmlNode = nestedListsToBlockNoteStructure(
      +                      data.trim()
      +                    );
      +
      +                    data = htmlNode.innerHTML;
      +                    console.log(data);
      +                  }
      +                  editor._tiptapEditor.view.pasteHTML(data);
      +                }
      +
      +                return true;
      +              },
      +            },
      +          },
      +        }),
      +      ];
      +    },
      +  });
      diff --git a/packages/core/src/api/serialization/html/htmlConversion.test.ts b/packages/core/src/api/serialization/html/htmlConversion.test.ts
      deleted file mode 100644
      index d5cf5f4882..0000000000
      --- a/packages/core/src/api/serialization/html/htmlConversion.test.ts
      +++ /dev/null
      @@ -1,467 +0,0 @@
      -import { Editor } from "@tiptap/core";
      -import { afterEach, beforeEach, describe, expect, it } from "vitest";
      -import { BlockNoteEditor } from "../../../BlockNoteEditor";
      -
      -import {
      -  BlockSchema,
      -  PartialBlock,
      -} from "../../../extensions/Blocks/api/blockTypes";
      -import { createBlockSpec } from "../../../extensions/Blocks/api/customBlocks";
      -import { defaultBlockSchema } from "../../../extensions/Blocks/api/defaultBlocks";
      -import { defaultProps } from "../../../extensions/Blocks/api/defaultProps";
      -import {
      -  imagePropSchema,
      -  renderImage,
      -} from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent";
      -import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      -import { createExternalHTMLExporter } from "./externalHTMLExporter";
      -import { createInternalHTMLSerializer } from "./internalHTMLSerializer";
      -
      -// This is a modified version of the default image block that does not implement
      -// a `serialize` function. It's used to test if the custom serializer by default
      -// serializes custom blocks using their `render` function.
      -const SimpleImage = createBlockSpec(
      -  {
      -    type: "simpleImage" as const,
      -    propSchema: imagePropSchema,
      -    content: "none",
      -  },
      -  { render: renderImage as any }
      -);
      -
      -const CustomParagraph = createBlockSpec(
      -  {
      -    type: "customParagraph" as const,
      -    propSchema: defaultProps,
      -    content: "inline",
      -  },
      -  {
      -    render: () => {
      -      const paragraph = document.createElement("p");
      -      paragraph.className = "custom-paragraph";
      -
      -      return {
      -        dom: paragraph,
      -        contentDOM: paragraph,
      -      };
      -    },
      -    toExternalHTML: () => {
      -      const paragraph = document.createElement("p");
      -      paragraph.className = "custom-paragraph";
      -      paragraph.innerHTML = "Hello World";
      -
      -      return {
      -        dom: paragraph,
      -      };
      -    },
      -  }
      -);
      -
      -const SimpleCustomParagraph = createBlockSpec(
      -  {
      -    type: "simpleCustomParagraph" as const,
      -    propSchema: defaultProps,
      -    content: "inline",
      -  },
      -  {
      -    render: () => {
      -      const paragraph = document.createElement("p");
      -      paragraph.className = "simple-custom-paragraph";
      -
      -      return {
      -        dom: paragraph,
      -        contentDOM: paragraph,
      -      };
      -    },
      -  }
      -);
      -
      -const customSchema = {
      -  ...defaultBlockSchema,
      -  simpleImage: SimpleImage,
      -  customParagraph: CustomParagraph,
      -  simpleCustomParagraph: SimpleCustomParagraph,
      -} satisfies BlockSchema;
      -
      -let editor: BlockNoteEditor;
      -let tt: Editor;
      -
      -beforeEach(() => {
      -  editor = new BlockNoteEditor({
      -    blockSchema: customSchema,
      -    uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      -  });
      -  tt = editor._tiptapEditor;
      -});
      -
      -afterEach(() => {
      -  tt.destroy();
      -  editor = undefined as any;
      -  tt = undefined as any;
      -
      -  delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
      -});
      -
      -function convertToHTMLAndCompareSnapshots(
      -  blocks: PartialBlock[],
      -  snapshotDirectory: string,
      -  snapshotName: string
      -) {
      -  const serializer = createInternalHTMLSerializer(tt.schema, editor);
      -  const internalHTML = serializer.serializeBlocks(blocks);
      -  const internalHTMLSnapshotPath =
      -    "./__snapshots__/" +
      -    snapshotDirectory +
      -    "/" +
      -    snapshotName +
      -    "/internal.html";
      -  expect(internalHTML).toMatchFileSnapshot(internalHTMLSnapshotPath);
      -
      -  const exporter = createExternalHTMLExporter(tt.schema, editor);
      -  const externalHTML = exporter.exportBlocks(blocks);
      -  const externalHTMLSnapshotPath =
      -    "./__snapshots__/" +
      -    snapshotDirectory +
      -    "/" +
      -    snapshotName +
      -    "/external.html";
      -  expect(externalHTML).toMatchFileSnapshot(externalHTMLSnapshotPath);
      -}
      -
      -describe("Convert paragraphs to HTML", () => {
      -  it("Convert paragraph to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "paragraph",
      -        content: "Paragraph",
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "paragraph", "basic");
      -  });
      -
      -  it("Convert styled paragraph to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "paragraph",
      -        props: {
      -          textAlignment: "center",
      -          textColor: "orange",
      -          backgroundColor: "pink",
      -        },
      -        content: [
      -          {
      -            type: "text",
      -            styles: {},
      -            text: "Plain ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -            },
      -            text: "Red Text ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              backgroundColor: "blue",
      -            },
      -            text: "Blue Background ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -              backgroundColor: "blue",
      -            },
      -            text: "Mixed Colors",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "paragraph", "styled");
      -  });
      -
      -  it("Convert nested paragraph to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "paragraph",
      -        content: "Paragraph",
      -        children: [
      -          {
      -            type: "paragraph",
      -            content: "Nested Paragraph 1",
      -          },
      -          {
      -            type: "paragraph",
      -            content: "Nested Paragraph 2",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "paragraph", "nested");
      -  });
      -});
      -
      -describe("Convert images to HTML", () => {
      -  it("Convert add image button to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "image",
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "image", "button");
      -  });
      -
      -  it("Convert image to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "image",
      -        props: {
      -          url: "exampleURL",
      -          caption: "Caption",
      -          width: 256,
      -        },
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "image", "basic");
      -  });
      -
      -  it("Convert nested image to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "image",
      -        props: {
      -          url: "exampleURL",
      -          caption: "Caption",
      -          width: 256,
      -        },
      -        children: [
      -          {
      -            type: "image",
      -            props: {
      -              url: "exampleURL",
      -              caption: "Caption",
      -              width: 256,
      -            },
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "image", "nested");
      -  });
      -});
      -
      -describe("Convert simple images to HTML", () => {
      -  it("Convert simple add image button to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleImage",
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleImage", "button");
      -  });
      -
      -  it("Convert simple image to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleImage",
      -        props: {
      -          url: "exampleURL",
      -          caption: "Caption",
      -          width: 256,
      -        },
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleImage", "basic");
      -  });
      -
      -  it("Convert nested image to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleImage",
      -        props: {
      -          url: "exampleURL",
      -          caption: "Caption",
      -          width: 256,
      -        },
      -        children: [
      -          {
      -            type: "simpleImage",
      -            props: {
      -              url: "exampleURL",
      -              caption: "Caption",
      -              width: 256,
      -            },
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleImage", "nested");
      -  });
      -});
      -
      -describe("Convert custom blocks with inline content to HTML", () => {
      -  it("Convert custom block with inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "customParagraph",
      -        content: "Custom Paragraph",
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "customParagraph", "basic");
      -  });
      -
      -  it("Convert styled custom block with inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "customParagraph",
      -        props: {
      -          textAlignment: "center",
      -          textColor: "orange",
      -          backgroundColor: "pink",
      -        },
      -        content: [
      -          {
      -            type: "text",
      -            styles: {},
      -            text: "Plain ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -            },
      -            text: "Red Text ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              backgroundColor: "blue",
      -            },
      -            text: "Blue Background ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -              backgroundColor: "blue",
      -            },
      -            text: "Mixed Colors",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "customParagraph", "styled");
      -  });
      -
      -  it("Convert nested block with inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "customParagraph",
      -        content: "Custom Paragraph",
      -        children: [
      -          {
      -            type: "customParagraph",
      -            content: "Nested Custom Paragraph 1",
      -          },
      -          {
      -            type: "customParagraph",
      -            content: "Nested Custom Paragraph 2",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "customParagraph", "nested");
      -  });
      -});
      -
      -describe("Convert custom blocks with non-exported inline content to HTML", () => {
      -  it("Convert custom block with non-exported inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleCustomParagraph",
      -        content: "Custom Paragraph",
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleCustomParagraph", "basic");
      -  });
      -
      -  it("Convert styled custom block with non-exported inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleCustomParagraph",
      -        props: {
      -          textAlignment: "center",
      -          textColor: "orange",
      -          backgroundColor: "pink",
      -        },
      -        content: [
      -          {
      -            type: "text",
      -            styles: {},
      -            text: "Plain ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -            },
      -            text: "Red Text ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              backgroundColor: "blue",
      -            },
      -            text: "Blue Background ",
      -          },
      -          {
      -            type: "text",
      -            styles: {
      -              textColor: "red",
      -              backgroundColor: "blue",
      -            },
      -            text: "Mixed Colors",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleCustomParagraph", "styled");
      -  });
      -
      -  it("Convert nested block with non-exported inline content to HTML", async () => {
      -    const blocks: PartialBlock[] = [
      -      {
      -        type: "simpleCustomParagraph",
      -        content: "Custom Paragraph",
      -        children: [
      -          {
      -            type: "simpleCustomParagraph",
      -            content: "Nested Custom Paragraph 1",
      -          },
      -          {
      -            type: "simpleCustomParagraph",
      -            content: "Nested Custom Paragraph 2",
      -          },
      -        ],
      -      },
      -    ];
      -
      -    convertToHTMLAndCompareSnapshots(blocks, "simpleCustomParagraph", "nested");
      -  });
      -});
      diff --git a/packages/core/src/api/testCases/cases/customInlineContent.ts b/packages/core/src/api/testCases/cases/customInlineContent.ts
      new file mode 100644
      index 0000000000..a1603f4a87
      --- /dev/null
      +++ b/packages/core/src/api/testCases/cases/customInlineContent.ts
      @@ -0,0 +1,114 @@
      +import { EditorTestCases } from "..";
      +
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultStyleSchema,
      +  defaultInlineContentSpecs,
      +} from "../../../extensions/Blocks/api/defaultBlocks";
      +import { createInlineContentSpec } from "../../../extensions/Blocks/api/inlineContent/createSpec";
      +import {
      +  InlineContentSchemaFromSpecs,
      +  InlineContentSpecs,
      +} from "../../../extensions/Blocks/api/inlineContent/types";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +
      +const mention = createInlineContentSpec(
      +  {
      +    type: "mention" as const,
      +    propSchema: {
      +      user: {
      +        default: "",
      +      },
      +    },
      +    content: "none",
      +  },
      +  {
      +    render: (ic) => {
      +      const dom = document.createElement("span");
      +      dom.appendChild(document.createTextNode("@" + ic.props.user));
      +
      +      return {
      +        dom,
      +      };
      +    },
      +  }
      +);
      +
      +const tag = createInlineContentSpec(
      +  {
      +    type: "tag" as const,
      +    propSchema: {},
      +    content: "styled",
      +  },
      +  {
      +    render: () => {
      +      const dom = document.createElement("span");
      +      dom.textContent = "#";
      +
      +      const contentDOM = document.createElement("span");
      +      dom.appendChild(contentDOM);
      +
      +      return {
      +        dom,
      +        contentDOM,
      +      };
      +    },
      +  }
      +);
      +
      +const customInlineContent = {
      +  ...defaultInlineContentSpecs,
      +  mention,
      +  tag,
      +} satisfies InlineContentSpecs;
      +
      +export const customInlineContentTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  InlineContentSchemaFromSpecs,
      +  DefaultStyleSchema
      +> = {
      +  name: "custom inline content schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +      inlineContentSpecs: customInlineContent,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "mention/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            "I enjoy working with",
      +            {
      +              type: "mention",
      +              props: {
      +                user: "Matthew",
      +              },
      +              content: undefined,
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "tag/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            "I love ",
      +            {
      +              type: "tag",
      +              // props: {},
      +              content: "BlockNote",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testCases/cases/customStyles.ts b/packages/core/src/api/testCases/cases/customStyles.ts
      new file mode 100644
      index 0000000000..e7a4390e63
      --- /dev/null
      +++ b/packages/core/src/api/testCases/cases/customStyles.ts
      @@ -0,0 +1,103 @@
      +import { EditorTestCases } from "..";
      +
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  defaultStyleSpecs,
      +} from "../../../extensions/Blocks/api/defaultBlocks";
      +import { createStyleSpec } from "../../../extensions/Blocks/api/styles/createSpec";
      +import {
      +  StyleSchemaFromSpecs,
      +  StyleSpecs,
      +} from "../../../extensions/Blocks/api/styles/types";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +
      +const small = createStyleSpec(
      +  {
      +    type: "small",
      +    propSchema: "boolean",
      +  },
      +  {
      +    render: () => {
      +      const dom = document.createElement("small");
      +      return {
      +        dom,
      +        contentDOM: dom,
      +      };
      +    },
      +  }
      +);
      +
      +const fontSize = createStyleSpec(
      +  {
      +    type: "fontSize",
      +    propSchema: "string",
      +  },
      +  {
      +    render: (value) => {
      +      const dom = document.createElement("span");
      +      dom.setAttribute("style", "font-size: " + value);
      +      return {
      +        dom,
      +        contentDOM: dom,
      +      };
      +    },
      +  }
      +);
      +
      +const customStyles = {
      +  ...defaultStyleSpecs,
      +  small,
      +  fontSize,
      +} satisfies StyleSpecs;
      +
      +export const customStylesTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  StyleSchemaFromSpecs
      +> = {
      +  name: "custom style schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +      styleSpecs: customStyles,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "small/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "This is a small text",
      +              styles: {
      +                small: true,
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "fontSize/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "This is text with a custom fontSize",
      +              styles: {
      +                fontSize: "18px",
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testCases/cases/defaultSchema.ts b/packages/core/src/api/testCases/cases/defaultSchema.ts
      new file mode 100644
      index 0000000000..87aa6b01b1
      --- /dev/null
      +++ b/packages/core/src/api/testCases/cases/defaultSchema.ts
      @@ -0,0 +1,399 @@
      +import { EditorTestCases } from "..";
      +
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +} from "../../../extensions/Blocks/api/defaultBlocks";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +
      +export const defaultSchemaTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema
      +> = {
      +  name: "default schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "paragraph/empty",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: "Paragraph",
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/styled",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          props: {
      +            textAlignment: "center",
      +            textColor: "orange",
      +            backgroundColor: "pink",
      +          },
      +          content: [
      +            {
      +              type: "text",
      +              styles: {},
      +              text: "Plain ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +              },
      +              text: "Red Text ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                backgroundColor: "blue",
      +              },
      +              text: "Blue Background ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +                backgroundColor: "blue",
      +              },
      +              text: "Mixed Colors",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/nested",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: "Paragraph",
      +          children: [
      +            {
      +              type: "paragraph",
      +              content: "Nested Paragraph 1",
      +            },
      +            {
      +              type: "paragraph",
      +              content: "Nested Paragraph 2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/button",
      +      blocks: [
      +        {
      +          type: "image",
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/basic",
      +      blocks: [
      +        {
      +          type: "image",
      +          props: {
      +            url: "exampleURL",
      +            caption: "Caption",
      +            width: 256,
      +          },
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/nested",
      +      blocks: [
      +        {
      +          type: "image",
      +          props: {
      +            url: "exampleURL",
      +            caption: "Caption",
      +            width: 256,
      +          },
      +          children: [
      +            {
      +              type: "image",
      +              props: {
      +                url: "exampleURL",
      +                caption: "Caption",
      +                width: 256,
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/basic",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Website",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/styled",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: [
      +                {
      +                  type: "text",
      +                  text: "Web",
      +                  styles: {
      +                    bold: true,
      +                  },
      +                },
      +                {
      +                  type: "text",
      +                  text: "site",
      +                  styles: {},
      +                },
      +              ],
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/adjacent",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Website",
      +            },
      +            {
      +              type: "link",
      +              href: "https://www.website2.com",
      +              content: "Website2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/basic",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\nText2",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/multiple",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\nText2\nText3",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/start",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "\nText1",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/end",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\n",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/only",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "\n",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/styles",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\n",
      +              styles: {},
      +            },
      +            {
      +              type: "text",
      +              text: "Text2",
      +              styles: { bold: true },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/link",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Link1\nLink1",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/between-links",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Link1\n",
      +            },
      +            {
      +              type: "link",
      +              href: "https://www.website2.com",
      +              content: "Link2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "complex/misc",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "heading",
      +          props: {
      +            backgroundColor: "blue",
      +            textColor: "yellow",
      +            textAlignment: "right",
      +            level: 2,
      +          },
      +          content: [
      +            {
      +              type: "text",
      +              text: "Heading ",
      +              styles: {
      +                bold: true,
      +                underline: true,
      +              },
      +            },
      +            {
      +              type: "text",
      +              text: "2",
      +              styles: {
      +                italic: true,
      +                strike: true,
      +              },
      +            },
      +          ],
      +          children: [
      +            {
      +              // id: UniqueID.options.generateID(),
      +              type: "paragraph",
      +              props: {
      +                backgroundColor: "red",
      +              },
      +              content: "Paragraph",
      +              children: [],
      +            },
      +            {
      +              // id: UniqueID.options.generateID(),
      +              type: "bulletListItem",
      +              props: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testCases/index.ts b/packages/core/src/api/testCases/index.ts
      new file mode 100644
      index 0000000000..90e1f06005
      --- /dev/null
      +++ b/packages/core/src/api/testCases/index.ts
      @@ -0,0 +1,20 @@
      +import { BlockNoteEditor } from "../../BlockNoteEditor";
      +import {
      +  BlockSchema,
      +  PartialBlock,
      +} from "../../extensions/Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../../extensions/Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../../extensions/Blocks/api/styles/types";
      +
      +export type EditorTestCases<
      +  B extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  name: string;
      +  createEditor: () => BlockNoteEditor;
      +  documents: Array<{
      +    name: string;
      +    blocks: PartialBlock[];
      +  }>;
      +};
      diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts
      index caa76f6416..3f24ecdfea 100644
      --- a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts
      +++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts
      @@ -1,18 +1,6 @@
       import { Extension } from "@tiptap/core";
      -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos";
       import { defaultProps } from "../Blocks/api/defaultProps";
       
      -declare module "@tiptap/core" {
      -  interface Commands {
      -    blockBackgroundColor: {
      -      setBlockBackgroundColor: (
      -        posInBlock: number,
      -        color: string
      -      ) => ReturnType;
      -    };
      -  }
      -}
      -
       export const BackgroundColorExtension = Extension.create({
         name: "blockBackgroundColor",
       
      @@ -37,27 +25,4 @@ export const BackgroundColorExtension = Extension.create({
             },
           ];
         },
      -
      -  addCommands() {
      -    return {
      -      setBlockBackgroundColor:
      -        (posInBlock, color) =>
      -        ({ state, view }) => {
      -          const blockInfo = getBlockInfoFromPos(state.doc, posInBlock);
      -          if (blockInfo === undefined) {
      -            return false;
      -          }
      -
      -          state.tr.setNodeAttribute(
      -            blockInfo.startPos - 1,
      -            "backgroundColor",
      -            color
      -          );
      -
      -          view.focus();
      -
      -          return true;
      -        },
      -    };
      -  },
       });
      diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts
      index adcdca387f..df4b257588 100644
      --- a/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts
      +++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts
      @@ -1,24 +1,16 @@
       import { Mark } from "@tiptap/core";
      -import { defaultProps } from "../Blocks/api/defaultProps";
      +import { createStyleSpecFromTipTapMark } from "../Blocks/api/styles/internal";
       
      -declare module "@tiptap/core" {
      -  interface Commands {
      -    backgroundColor: {
      -      setBackgroundColor: (color: string) => ReturnType;
      -    };
      -  }
      -}
      -
      -export const BackgroundColorMark = Mark.create({
      +const BackgroundColorMark = Mark.create({
         name: "backgroundColor",
       
         addAttributes() {
           return {
      -      color: {
      +      stringValue: {
               default: undefined,
               parseHTML: (element) => element.getAttribute("data-background-color"),
               renderHTML: (attributes) => ({
      -          "data-background-color": attributes.color,
      +          "data-background-color": attributes.stringValue,
               }),
             },
           };
      @@ -34,7 +26,9 @@ export const BackgroundColorMark = Mark.create({
                 }
       
                 if (element.hasAttribute("data-background-color")) {
      -            return { color: element.getAttribute("data-background-color") };
      +            return {
      +              stringValue: element.getAttribute("data-background-color"),
      +            };
                 }
       
                 return false;
      @@ -46,18 +40,9 @@ export const BackgroundColorMark = Mark.create({
         renderHTML({ HTMLAttributes }) {
           return ["span", HTMLAttributes, 0];
         },
      -
      -  addCommands() {
      -    return {
      -      setBackgroundColor:
      -        (color) =>
      -        ({ commands }) => {
      -          if (color !== defaultProps.backgroundColor.default) {
      -            return commands.setMark(this.name, { color: color });
      -          }
      -
      -          return commands.unsetMark(this.name);
      -        },
      -    };
      -  },
       });
      +
      +export const BackgroundColor = createStyleSpecFromTipTapMark(
      +  BackgroundColorMark,
      +  "string"
      +);
      diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts
      deleted file mode 100644
      index 9042d348e7..0000000000
      --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts
      +++ /dev/null
      @@ -1,219 +0,0 @@
      -/** Define the main block types **/
      -import { Node } from "@tiptap/core";
      -import { BlockNoteEditor, DefaultBlockSchema } from "../../..";
      -import { InlineContent, PartialInlineContent } from "./inlineContentTypes";
      -
      -export type BlockNoteDOMElement =
      -  | "editor"
      -  | "blockContainer"
      -  | "blockGroup"
      -  | "blockContent"
      -  | "inlineContent";
      -
      -export type BlockNoteDOMAttributes = Partial<{
      -  [DOMElement in BlockNoteDOMElement]: Record;
      -}>;
      -
      -// Defines a single prop spec, which includes the default value the prop should
      -// take and possible values it can take.
      -export type PropSpec = {
      -  values?: readonly PType[];
      -  default: PType;
      -};
      -
      -// Defines multiple block prop specs. The key of each prop is the name of the
      -// prop, while the value is a corresponding prop spec. This should be included
      -// in a block config or schema. From a prop schema, we can derive both the props'
      -// internal implementation (as TipTap node attributes) and the type information
      -// for the external API.
      -export type PropSchema = Record>;
      -
      -// Defines Props objects for use in Block objects in the external API. Converts
      -// each prop spec into a union type of its possible values, or a string if no
      -// values are specified.
      -export type Props = {
      -  [PName in keyof PSchema]: PSchema[PName]["default"] extends boolean
      -    ? PSchema[PName]["values"] extends readonly boolean[]
      -      ? PSchema[PName]["values"][number]
      -      : boolean
      -    : PSchema[PName]["default"] extends number
      -    ? PSchema[PName]["values"] extends readonly number[]
      -      ? PSchema[PName]["values"][number]
      -      : number
      -    : PSchema[PName]["default"] extends string
      -    ? PSchema[PName]["values"] extends readonly string[]
      -      ? PSchema[PName]["values"][number]
      -      : string
      -    : never;
      -};
      -
      -// BlockConfig contains the "schema" info about a Block
      -export type BlockConfig = {
      -  type: string;
      -  readonly propSchema: PropSchema;
      -  content: "inline" | "none" | "table";
      -};
      -
      -// Block implementation contains the "implementation" info about a Block
      -// such as the functions / Nodes required to render and / or serialize it
      -export type TiptapBlockImplementation = {
      -  requiredNodes?: Node[];
      -  node: Node;
      -  toInternalHTML: (
      -    block: Block,
      -    editor: BlockNoteEditor>
      -  ) => {
      -    dom: HTMLElement;
      -    contentDOM?: HTMLElement;
      -  };
      -  toExternalHTML: (
      -    block: Block,
      -    editor: BlockNoteEditor>
      -  ) => {
      -    dom: HTMLElement;
      -    contentDOM?: HTMLElement;
      -  };
      -};
      -
      -// Container for both the config and implementation of a block,
      -// and the type of BlockImplementation is based on that of the config
      -export type BlockSpec = {
      -  config: T;
      -  implementation: TiptapBlockImplementation;
      -};
      -
      -// Utility type. For a given object block schema, ensures that the key of each
      -// block spec matches the name of the TipTap node in it.
      -type NamesMatch>> =
      -  Blocks extends {
      -    [Type in keyof Blocks]: Type extends string
      -      ? Blocks[Type]["config"] extends { type: Type }
      -        ? Blocks[Type]
      -        : never
      -      : never;
      -  }
      -    ? Blocks
      -    : never;
      -
      -// Defines multiple block specs. Also ensures that the key of each block schema
      -// is the same as name of the TipTap node in it. This should be passed in the
      -// `blocks` option of the BlockNoteEditor. From a block schema, we can derive
      -// both the blocks' internal implementation (as TipTap nodes) and the type
      -// information for the external API.
      -export type BlockSchema = NamesMatch>>;
      -
      -export type BlockSchemaWithBlock<
      -  BType extends string,
      -  C extends BlockConfig
      -> = {
      -  [k in BType]: BlockSpec;
      -};
      -
      -export type TableContent = {
      -  type: "tableContent";
      -  rows: {
      -    cells: InlineContent[][];
      -  }[];
      -};
      -
      -export type PartialTableContent = {
      -  type: "tableContent";
      -  rows: {
      -    cells: (PartialInlineContent[] | string)[];
      -  }[];
      -};
      -
      -// A BlockConfig has all the information to get the type of a Block (which is a specific instance of the BlockConfig.
      -// i.e.: paragraphConfig: BlockConfig defines what a "paragraph" is / supports, and BlockFromBlockConfig is the shape of a specific paragraph block.
      -// (for internal use)
      -type BlockFromBlockConfig = {
      -  id: string;
      -  type: B["type"];
      -  props: Props;
      -  content: B["content"] extends "inline"
      -    ? InlineContent[]
      -    : B["content"] extends "table"
      -    ? TableContent
      -    : B["content"] extends "none"
      -    ? undefined
      -    : never;
      -};
      -
      -// Converts each block spec into a Block object without children. We later merge
      -// them into a union type and add a children property to create the Block and
      -// PartialBlock objects we use in the external API.
      -type BlocksWithoutChildren = {
      -  [BType in keyof BSchema]: BlockFromBlockConfig;
      -};
      -
      -// Converts each block spec into a Block object without children, merges them
      -// into a union type, and adds a children property
      -export type Block =
      -  (T extends BlockSchema
      -    ? BlocksWithoutChildren[keyof T]
      -    : T extends BlockConfig
      -    ? BlockFromBlockConfig
      -    : never) & {
      -    children: Block<
      -      T extends BlockSchema ? T : any // any should probably be BlockSchemaWithBlock;
      -    >[];
      -  };
      -
      -export type SpecificBlock<
      -  BSchema extends BlockSchema,
      -  BType extends keyof BSchema
      -> = BlocksWithoutChildren[BType] & {
      -  children: Block[];
      -};
      -
      -/** CODE FOR PARTIAL BLOCKS, analogous to above */
      -
      -type PartialBlockFromBlockConfig = {
      -  id?: string;
      -  type?: B["type"];
      -  props?: Partial>;
      -  content?: B["content"] extends "inline"
      -    ? PartialInlineContent[] | string
      -    : B["content"] extends "table"
      -    ? PartialTableContent
      -    : undefined;
      -};
      -
      -// Same as BlockWithoutChildren, but as a partial type with some changes to make
      -// it easier to create/update blocks in the editor.
      -type PartialBlocksWithoutChildren = {
      -  [BType in keyof BSchema]: PartialBlockFromBlockConfig<
      -    BSchema[BType]["config"]
      -  >;
      -};
      -
      -// Same as Block, but as a partial type with some changes to make it easier to
      -// create/update blocks in the editor.
      -
      -export type PartialBlock =
      -  PartialBlocksWithoutChildren[keyof PartialBlocksWithoutChildren] &
      -    Partial<{
      -      children: PartialBlock[];
      -    }>;
      -
      -// export type PartialBlock =
      -//   T extends BlockSchema
      -//     ? PartialBlocksWithoutChildren[keyof T]
      -//     : T extends BlockConfig
      -//     ? PartialBlockFromBlockConfig
      -//     : never;
      -
      -// & {
      -//   children?: PartialBlock<
      -//     T extends BlockSchema ? T : any // any should probably be BlockSchemaWithBlock;
      -//   >[];
      -// };
      -
      -export type SpecificPartialBlock<
      -  BSchema extends BlockSchema,
      -  BType extends keyof BSchema
      -> = PartialBlocksWithoutChildren[BType] & {
      -  children?: Block[];
      -};
      -
      -export type BlockIdentifier = { id: string } | string;
      diff --git a/packages/core/src/extensions/Blocks/api/customBlocks.ts b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts
      similarity index 60%
      rename from packages/core/src/extensions/Blocks/api/customBlocks.ts
      rename to packages/core/src/extensions/Blocks/api/blocks/createSpec.ts
      index fcff18a666..18b0d780f4 100644
      --- a/packages/core/src/extensions/Blocks/api/customBlocks.ts
      +++ b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts
      @@ -1,31 +1,42 @@
      -import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import { ParseRule } from "@tiptap/pm/model";
      +import { BlockNoteEditor } from "../../../../BlockNoteEditor";
      +import { InlineContentSchema } from "../inlineContent/types";
      +import { StyleSchema } from "../styles/types";
       import {
         createInternalBlockSpec,
         createStronglyTypedTiptapNode,
         getBlockFromPos,
      -  parse,
         propsToAttributes,
         wrapInBlockStructure,
      -} from "./block";
      -import { Block, BlockConfig, BlockSchemaWithBlock } from "./blockTypes";
      +} from "./internal";
      +import {
      +  BlockConfig,
      +  BlockFromConfig,
      +  BlockSchemaWithBlock,
      +  PartialBlockFromConfig,
      +} from "./types";
       
       // restrict content to "inline" and "none" only
       export type CustomBlockConfig = BlockConfig & {
         content: "inline" | "none";
       };
       
      -export type CustomBlockImplementation = {
      +export type CustomBlockImplementation<
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
         render: (
           /**
            * The custom block to render
            */
      -    block: Block,
      +    block: BlockFromConfig,
           /**
            * The BlockNote editor instance
            * This is typed generically. If you want an editor with your custom schema, you need to
            * cast it manually, e.g.: `const e = editor as BlockNoteEditor;`
            */
      -    editor: BlockNoteEditor>
      +    editor: BlockNoteEditor, I, S>
           // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations
           // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics
         ) => {
      @@ -38,20 +49,73 @@ export type CustomBlockImplementation = {
         // BlockNote.
         // TODO: Maybe can return undefined to ignore when serializing?
         toExternalHTML?: (
      -    block: Block,
      -    editor: BlockNoteEditor>
      +    block: BlockFromConfig,
      +    editor: BlockNoteEditor, I, S>
         ) => {
           dom: HTMLElement;
           contentDOM?: HTMLElement;
         };
      +
      +  parse?: (el: HTMLElement) => PartialBlockFromConfig | undefined;
       };
       
      +// Function that uses the 'parse' function of a blockConfig to create a
      +// TipTap node's `parseHTML` property. This is only used for parsing content
      +// from the clipboard.
      +export function getParseRules(
      +  config: BlockConfig,
      +  customParseFunction: CustomBlockImplementation["parse"]
      +) {
      +  const rules: ParseRule[] = [
      +    {
      +      tag: "div[data-content-type=" + config.type + "]",
      +    },
      +  ];
      +
      +  if (customParseFunction) {
      +    rules.push({
      +      tag: "*",
      +      getAttrs(node: string | HTMLElement) {
      +        if (typeof node === "string") {
      +          return false;
      +        }
      +
      +        const block = customParseFunction?.(node);
      +
      +        if (block === undefined) {
      +          return false;
      +        }
      +
      +        return block.props || {};
      +      },
      +    });
      +  }
      +  //     getContent(node, schema) {
      +  //       const block = blockConfig.parse?.(node as HTMLElement);
      +  //
      +  //       if (block !== undefined && block.content !== undefined) {
      +  //         return Fragment.from(
      +  //           typeof block.content === "string"
      +  //             ? schema.text(block.content)
      +  //             : inlineContentToNodes(block.content, schema)
      +  //         );
      +  //       }
      +  //
      +  //       return Fragment.empty;
      +  //     },
      +  //   });
      +  // }
      +
      +  return rules;
      +}
      +
       // A function to create custom block for API consumers
       // we want to hide the tiptap node from API consumers and provide a simpler API surface instead
      -export function createBlockSpec(
      -  blockConfig: T,
      -  blockImplementation: CustomBlockImplementation
      -) {
      +export function createBlockSpec<
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(blockConfig: T, blockImplementation: CustomBlockImplementation) {
         const node = createStronglyTypedTiptapNode({
           name: blockConfig.type as T["type"],
           content: (blockConfig.content === "inline"
      @@ -61,11 +125,11 @@ export function createBlockSpec(
           selectable: true,
       
           addAttributes() {
      -      return propsToAttributes(blockConfig);
      +      return propsToAttributes(blockConfig.propSchema);
           },
       
           parseHTML() {
      -      return parse(blockConfig);
      +      return getParseRules(blockConfig, blockImplementation.parse);
           },
       
           addNodeView() {
      diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/blocks/internal.ts
      similarity index 75%
      rename from packages/core/src/extensions/Blocks/api/block.ts
      rename to packages/core/src/extensions/Blocks/api/blocks/internal.ts
      index 75a045a39c..58f8b84d50 100644
      --- a/packages/core/src/extensions/Blocks/api/block.ts
      +++ b/packages/core/src/extensions/Blocks/api/blocks/internal.ts
      @@ -1,18 +1,28 @@
      -import { Attribute, Attributes, Editor, Node, NodeConfig } from "@tiptap/core";
      -import { ParseRule } from "prosemirror-model";
      -import { BlockNoteEditor } from "../../../BlockNoteEditor";
      -import { mergeCSSClasses } from "../../../shared/utils";
      -import { defaultBlockToHTML } from "../nodes/BlockContent/defaultBlockHelpers";
      +import {
      +  Attribute,
      +  Attributes,
      +  Editor,
      +  Extension,
      +  Node,
      +  NodeConfig,
      +} from "@tiptap/core";
      +import { BlockNoteEditor } from "../../../../BlockNoteEditor";
      +import { mergeCSSClasses } from "../../../../shared/utils";
      +import { defaultBlockToHTML } from "../../nodes/BlockContent/defaultBlockHelpers";
      +import { inheritedProps } from "../defaultProps";
      +import { InlineContentSchema } from "../inlineContent/types";
      +import { StyleSchema } from "../styles/types";
       import {
         BlockConfig,
      +  BlockSchemaFromSpecs,
         BlockSchemaWithBlock,
         BlockSpec,
      +  BlockSpecs,
         PropSchema,
         Props,
         SpecificBlock,
         TiptapBlockImplementation,
      -} from "./blockTypes";
      -import { inheritedProps } from "./defaultProps";
      +} from "./types";
       
       export function camelToDataKebab(str: string): string {
         return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
      @@ -20,10 +30,11 @@ export function camelToDataKebab(str: string): string {
       
       // Function that uses the 'propSchema' of a blockConfig to create a TipTap
       // node's `addAttributes` property.
      -export function propsToAttributes(blockConfig: BlockConfig): Attributes {
      +// TODO: extract function
      +export function propsToAttributes(propSchema: PropSchema): Attributes {
         const tiptapAttributes: Record = {};
       
      -  Object.entries(blockConfig.propSchema)
      +  Object.entries(propSchema)
           .filter(([name, _spec]) => !inheritedProps.includes(name))
           .forEach(([name, spec]) => {
             tiptapAttributes[name] = {
      @@ -77,60 +88,17 @@ export function propsToAttributes(blockConfig: BlockConfig): Attributes {
         return tiptapAttributes;
       }
       
      -// Function that uses the 'parse' function of a blockConfig to create a
      -// TipTap node's `parseHTML` property. This is only used for parsing content
      -// from the clipboard.
      -export function parse(blockConfig: BlockConfig) {
      -  const rules: ParseRule[] = [
      -    {
      -      tag: "div[data-content-type=" + blockConfig.type + "]",
      -    },
      -  ];
      -
      -  // if (blockConfig.parse) {
      -  //   rules.push({
      -  //     tag: "*",
      -  //     getAttrs(node: string | HTMLElement) {
      -  //       if (typeof node === "string") {
      -  //         return false;
      -  //       }
      -  //
      -  //       const block = blockConfig.parse?.(node);
      -  //
      -  //       if (block === undefined) {
      -  //         return false;
      -  //       }
      -  //
      -  //       return block.props || {};
      -  //     },
      -  //     getContent(node, schema) {
      -  //       const block = blockConfig.parse?.(node as HTMLElement);
      -  //
      -  //       if (block !== undefined && block.content !== undefined) {
      -  //         return Fragment.from(
      -  //           typeof block.content === "string"
      -  //             ? schema.text(block.content)
      -  //             : inlineContentToNodes(block.content, schema)
      -  //         );
      -  //       }
      -  //
      -  //       return Fragment.empty;
      -  //     },
      -  //   });
      -  // }
      -
      -  return rules;
      -}
      -
       // Used to figure out which block should be rendered. This block is then used to
       // create the node view.
       export function getBlockFromPos<
         BType extends string,
         Config extends BlockConfig,
      -  BSchema extends BlockSchemaWithBlock
      +  BSchema extends BlockSchemaWithBlock,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       >(
         getPos: (() => number) | boolean,
      -  editor: BlockNoteEditor,
      +  editor: BlockNoteEditor,
         tipTapEditor: Editor,
         type: BType
       ) {
      @@ -148,7 +116,9 @@ export function getBlockFromPos<
         // Gets the block
         const block = editor.getBlock(blockIdentifier)! as SpecificBlock<
           BSchema,
      -    BType
      +    BType,
      +    I,
      +    S
         >;
         if (block.type !== type) {
           throw new Error("Block type does not match");
      @@ -238,18 +208,23 @@ export function createStronglyTypedTiptapNode<
       // config and implementation that conform to the type of Config
       export function createInternalBlockSpec(
         config: T,
      -  implementation: TiptapBlockImplementation
      +  implementation: TiptapBlockImplementation<
      +    T,
      +    any,
      +    InlineContentSchema,
      +    StyleSchema
      +  >
       ) {
         return {
           config,
           implementation,
      -  } satisfies BlockSpec;
      +  } satisfies BlockSpec;
       }
       
       export function createBlockSpecFromStronglyTypedTiptapNode<
         T extends Node,
         P extends PropSchema
      ->(node: T, propSchema: P, requiredNodes?: Node[]) {
      +>(node: T, propSchema: P, requiredExtensions?: Array) {
         return createInternalBlockSpec(
           {
             type: node.name as T["name"],
      @@ -266,9 +241,16 @@ export function createBlockSpecFromStronglyTypedTiptapNode<
           },
           {
             node,
      -      requiredNodes,
      +      requiredExtensions,
             toInternalHTML: defaultBlockToHTML,
             toExternalHTML: defaultBlockToHTML,
      +      // parse: () => undefined, // parse rules are in node already
           }
         );
       }
      +
      +export function getBlockSchemaFromSpecs(specs: T) {
      +  return Object.fromEntries(
      +    Object.entries(specs).map(([key, value]) => [key, value.config])
      +  ) as BlockSchemaFromSpecs;
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/blocks/types.ts b/packages/core/src/extensions/Blocks/api/blocks/types.ts
      new file mode 100644
      index 0000000000..29b4acfe79
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/blocks/types.ts
      @@ -0,0 +1,284 @@
      +/** Define the main block types **/
      +import { Extension, Node } from "@tiptap/core";
      +
      +import { BlockNoteEditor } from "../../../../BlockNoteEditor";
      +import {
      +  InlineContent,
      +  InlineContentSchema,
      +  PartialInlineContent,
      +} from "../inlineContent/types";
      +import { StyleSchema } from "../styles/types";
      +
      +export type BlockNoteDOMElement =
      +  | "editor"
      +  | "blockContainer"
      +  | "blockGroup"
      +  | "blockContent"
      +  | "inlineContent";
      +
      +export type BlockNoteDOMAttributes = Partial<{
      +  [DOMElement in BlockNoteDOMElement]: Record;
      +}>;
      +
      +// Defines a single prop spec, which includes the default value the prop should
      +// take and possible values it can take.
      +export type PropSpec = {
      +  values?: readonly PType[];
      +  default: PType;
      +};
      +
      +// Defines multiple block prop specs. The key of each prop is the name of the
      +// prop, while the value is a corresponding prop spec. This should be included
      +// in a block config or schema. From a prop schema, we can derive both the props'
      +// internal implementation (as TipTap node attributes) and the type information
      +// for the external API.
      +export type PropSchema = Record>;
      +
      +// Defines Props objects for use in Block objects in the external API. Converts
      +// each prop spec into a union type of its possible values, or a string if no
      +// values are specified.
      +export type Props = {
      +  [PName in keyof PSchema]: PSchema[PName]["default"] extends boolean
      +    ? PSchema[PName]["values"] extends readonly boolean[]
      +      ? PSchema[PName]["values"][number]
      +      : boolean
      +    : PSchema[PName]["default"] extends number
      +    ? PSchema[PName]["values"] extends readonly number[]
      +      ? PSchema[PName]["values"][number]
      +      : number
      +    : PSchema[PName]["default"] extends string
      +    ? PSchema[PName]["values"] extends readonly string[]
      +      ? PSchema[PName]["values"][number]
      +      : string
      +    : never;
      +};
      +
      +// BlockConfig contains the "schema" info about a Block type
      +// i.e. what props it supports, what content it supports, etc.
      +export type BlockConfig = {
      +  type: string;
      +  readonly propSchema: PropSchema;
      +  content: "inline" | "none" | "table";
      +};
      +
      +// Block implementation contains the "implementation" info about a Block
      +// such as the functions / Nodes required to render and / or serialize it
      +export type TiptapBlockImplementation<
      +  T extends BlockConfig,
      +  B extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  requiredExtensions?: Array;
      +  node: Node;
      +  toInternalHTML: (
      +    block: BlockFromConfigNoChildren & {
      +      children: Block[];
      +    },
      +    editor: BlockNoteEditor
      +  ) => {
      +    dom: HTMLElement;
      +    contentDOM?: HTMLElement;
      +  };
      +  toExternalHTML: (
      +    block: BlockFromConfigNoChildren & {
      +      children: Block[];
      +    },
      +    editor: BlockNoteEditor
      +  ) => {
      +    dom: HTMLElement;
      +    contentDOM?: HTMLElement;
      +  };
      +};
      +
      +// A Spec contains both the Config and Implementation
      +export type BlockSpec<
      +  T extends BlockConfig,
      +  B extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  config: T;
      +  implementation: TiptapBlockImplementation;
      +};
      +
      +// Utility type. For a given object block schema, ensures that the key of each
      +// block spec matches the name of the TipTap node in it.
      +type NamesMatch> = Blocks extends {
      +  [Type in keyof Blocks]: Type extends string
      +    ? Blocks[Type] extends { type: Type }
      +      ? Blocks[Type]
      +      : never
      +    : never;
      +}
      +  ? Blocks
      +  : never;
      +
      +// A Schema contains all the types (Configs) supported in an editor
      +// The keys are the "type" of a block
      +export type BlockSchema = NamesMatch>;
      +
      +export type BlockSpecs = Record<
      +  string,
      +  BlockSpec
      +>;
      +
      +export type BlockImplementations = Record<
      +  string,
      +  TiptapBlockImplementation
      +>;
      +
      +export type BlockSchemaFromSpecs = {
      +  [K in keyof T]: T[K]["config"];
      +};
      +
      +export type BlockSchemaWithBlock<
      +  BType extends string,
      +  C extends BlockConfig
      +> = {
      +  [k in BType]: C;
      +};
      +
      +export type TableContent<
      +  I extends InlineContentSchema,
      +  S extends StyleSchema = StyleSchema
      +> = {
      +  type: "tableContent";
      +  rows: {
      +    cells: InlineContent[][];
      +  }[];
      +};
      +
      +// A BlockConfig has all the information to get the type of a Block (which is a specific instance of the BlockConfig.
      +// i.e.: paragraphConfig: BlockConfig defines what a "paragraph" is / supports, and BlockFromConfigNoChildren is the shape of a specific paragraph block.
      +// (for internal use)
      +export type BlockFromConfigNoChildren<
      +  B extends BlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  id: string;
      +  type: B["type"];
      +  props: Props;
      +  content: B["content"] extends "inline"
      +    ? InlineContent[]
      +    : B["content"] extends "table"
      +    ? TableContent
      +    : B["content"] extends "none"
      +    ? undefined
      +    : never;
      +};
      +
      +export type BlockFromConfig<
      +  B extends BlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = BlockFromConfigNoChildren & {
      +  children: Block[];
      +};
      +
      +// Converts each block spec into a Block object without children. We later merge
      +// them into a union type and add a children property to create the Block and
      +// PartialBlock objects we use in the external API.
      +type BlocksWithoutChildren<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  [BType in keyof BSchema]: BlockFromConfigNoChildren;
      +};
      +
      +// Converts each block spec into a Block object without children, merges them
      +// into a union type, and adds a children property
      +export type Block<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = BlocksWithoutChildren[keyof BSchema] & {
      +  children: Block[];
      +};
      +
      +export type SpecificBlock<
      +  BSchema extends BlockSchema,
      +  BType extends keyof BSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = BlocksWithoutChildren[BType] & {
      +  children: Block[];
      +};
      +
      +/** CODE FOR PARTIAL BLOCKS, analogous to above
      + *
      + * Partial blocks are convenience-wrappers to make it easier to
      + *create/update blocks in the editor.
      + *
      + */
      +
      +export type PartialTableContent<
      +  I extends InlineContentSchema,
      +  S extends StyleSchema = StyleSchema
      +> = {
      +  type: "tableContent";
      +  rows: {
      +    cells: PartialInlineContent[];
      +  }[];
      +};
      +
      +type PartialBlockFromConfigNoChildren<
      +  B extends BlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  id?: string;
      +  type?: B["type"];
      +  props?: Partial>;
      +  content?: B["content"] extends "inline"
      +    ? PartialInlineContent
      +    : B["content"] extends "table"
      +    ? PartialTableContent
      +    : undefined;
      +};
      +
      +type PartialBlocksWithoutChildren<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  [BType in keyof BSchema]: PartialBlockFromConfigNoChildren<
      +    BSchema[BType],
      +    I,
      +    S
      +  >;
      +};
      +
      +export type PartialBlock<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = PartialBlocksWithoutChildren<
      +  BSchema,
      +  I,
      +  S
      +>[keyof PartialBlocksWithoutChildren] &
      +  Partial<{
      +    children: PartialBlock[];
      +  }>;
      +
      +export type SpecificPartialBlock<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  BType extends keyof BSchema,
      +  S extends StyleSchema
      +> = PartialBlocksWithoutChildren[BType] & {
      +  children?: Block[];
      +};
      +
      +export type PartialBlockFromConfig<
      +  B extends BlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = PartialBlockFromConfigNoChildren & {
      +  children?: Block[];
      +};
      +
      +export type BlockIdentifier = { id: string } | string;
      diff --git a/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts b/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts
      index eb17e098f3..ce21cda6f4 100644
      --- a/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts
      +++ b/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts
      @@ -1,7 +1,13 @@
      -import { Block, BlockSchema } from "./blockTypes";
      +import { Block, BlockSchema } from "./blocks/types";
      +import { InlineContentSchema } from "./inlineContent/types";
      +import { StyleSchema } from "./styles/types";
       
      -export type TextCursorPosition = {
      -  block: Block;
      -  prevBlock: Block | undefined;
      -  nextBlock: Block | undefined;
      +export type TextCursorPosition<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  block: Block;
      +  prevBlock: Block | undefined;
      +  nextBlock: Block | undefined;
       };
      diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts
      index 41a8c2906e..dd15f12f74 100644
      --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts
      +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts
      @@ -1,18 +1,60 @@
      +import Bold from "@tiptap/extension-bold";
      +import Code from "@tiptap/extension-code";
      +import Italic from "@tiptap/extension-italic";
      +import Strike from "@tiptap/extension-strike";
      +import Underline from "@tiptap/extension-underline";
      +import { BackgroundColor } from "../../BackgroundColor/BackgroundColorMark";
      +import { TextColor } from "../../TextColor/TextColorMark";
       import { Heading } from "../nodes/BlockContent/HeadingBlockContent/HeadingBlockContent";
       import { Image } from "../nodes/BlockContent/ImageBlockContent/ImageBlockContent";
       import { BulletListItem } from "../nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent";
       import { NumberedListItem } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent";
       import { Paragraph } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent";
       import { Table } from "../nodes/BlockContent/TableBlockContent/TableBlockContent";
      -import { BlockSchema } from "./blockTypes";
      +import { getBlockSchemaFromSpecs } from "./blocks/internal";
      +import { BlockSpecs } from "./blocks/types";
      +import { getInlineContentSchemaFromSpecs } from "./inlineContent/internal";
      +import { InlineContentSpecs } from "./inlineContent/types";
      +import {
      +  createStyleSpecFromTipTapMark,
      +  getStyleSchemaFromSpecs,
      +} from "./styles/internal";
      +import { StyleSpecs } from "./styles/types";
       
      -export const defaultBlockSchema = {
      +export const defaultBlockSpecs = {
         paragraph: Paragraph,
         heading: Heading,
         bulletListItem: BulletListItem,
         numberedListItem: NumberedListItem,
         image: Image,
         table: Table,
      -} satisfies BlockSchema;
      +} satisfies BlockSpecs;
      +
      +export const defaultBlockSchema = getBlockSchemaFromSpecs(defaultBlockSpecs);
       
       export type DefaultBlockSchema = typeof defaultBlockSchema;
      +
      +export const defaultStyleSpecs = {
      +  bold: createStyleSpecFromTipTapMark(Bold, "boolean"),
      +  italic: createStyleSpecFromTipTapMark(Italic, "boolean"),
      +  underline: createStyleSpecFromTipTapMark(Underline, "boolean"),
      +  strike: createStyleSpecFromTipTapMark(Strike, "boolean"),
      +  code: createStyleSpecFromTipTapMark(Code, "boolean"),
      +  textColor: TextColor,
      +  backgroundColor: BackgroundColor,
      +} satisfies StyleSpecs;
      +
      +export const defaultStyleSchema = getStyleSchemaFromSpecs(defaultStyleSpecs);
      +
      +export type DefaultStyleSchema = typeof defaultStyleSchema;
      +
      +export const defaultInlineContentSpecs = {
      +  text: { config: "text", implementation: {} as any },
      +  link: { config: "link", implementation: {} as any },
      +} satisfies InlineContentSpecs;
      +
      +export const defaultInlineContentSchema = getInlineContentSchemaFromSpecs(
      +  defaultInlineContentSpecs
      +);
      +
      +export type DefaultInlineContentSchema = typeof defaultInlineContentSchema;
      diff --git a/packages/core/src/extensions/Blocks/api/defaultProps.ts b/packages/core/src/extensions/Blocks/api/defaultProps.ts
      index 17783364a1..43f36d7a6b 100644
      --- a/packages/core/src/extensions/Blocks/api/defaultProps.ts
      +++ b/packages/core/src/extensions/Blocks/api/defaultProps.ts
      @@ -1,4 +1,4 @@
      -import { Props, PropSchema } from "./blockTypes";
      +import { Props, PropSchema } from "./blocks/types";
       
       export const defaultProps = {
         backgroundColor: {
      diff --git a/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts b/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts
      new file mode 100644
      index 0000000000..220e85c6a3
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts
      @@ -0,0 +1,107 @@
      +import { Node } from "@tiptap/core";
      +import { ParseRule } from "@tiptap/pm/model";
      +import { nodeToCustomInlineContent } from "../../../../api/nodeConversions/nodeConversions";
      +import { propsToAttributes } from "../blocks/internal";
      +import { Props } from "../blocks/types";
      +import { StyleSchema } from "../styles/types";
      +import {
      +  addInlineContentAttributes,
      +  createInlineContentSpecFromTipTapNode,
      +} from "./internal";
      +import {
      +  CustomInlineContentConfig,
      +  InlineContentConfig,
      +  InlineContentFromConfig,
      +  InlineContentSpec,
      +} from "./types";
      +
      +// TODO: support serialization
      +
      +export type CustomInlineContentImplementation<
      +  T extends InlineContentConfig,
      +  // B extends BlockSchema,
      +  // I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  render: (
      +    /**
      +     * The custom inline content to render
      +     */
      +    inlineContent: InlineContentFromConfig
      +    /**
      +     * The BlockNote editor instance
      +     * This is typed generically. If you want an editor with your custom schema, you need to
      +     * cast it manually, e.g.: `const e = editor as BlockNoteEditor;`
      +     */
      +    // editor: BlockNoteEditor
      +    // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations
      +    // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics
      +  ) => {
      +    dom: HTMLElement;
      +    contentDOM?: HTMLElement;
      +    // destroy?: () => void;
      +  };
      +};
      +
      +export function getInlineContentParseRules(
      +  config: CustomInlineContentConfig
      +): ParseRule[] {
      +  return [
      +    {
      +      tag: `.bn-inline-content-section[data-inline-content-type="${config.type}"]`,
      +    },
      +  ];
      +}
      +
      +export function createInlineContentSpec<
      +  T extends CustomInlineContentConfig,
      +  S extends StyleSchema
      +>(
      +  inlineContentConfig: T,
      +  inlineContentImplementation: CustomInlineContentImplementation
      +): InlineContentSpec {
      +  const node = Node.create({
      +    name: inlineContentConfig.type,
      +    inline: true,
      +    group: "inline",
      +    content:
      +      inlineContentConfig.content === "styled"
      +        ? "inline*"
      +        : ("inline" as T["content"] extends "styled" ? "inline*" : "inline"),
      +
      +    addAttributes() {
      +      return propsToAttributes(inlineContentConfig.propSchema);
      +    },
      +
      +    parseHTML() {
      +      return getInlineContentParseRules(inlineContentConfig);
      +    },
      +
      +    renderHTML({ node }) {
      +      const editor = this.options.editor;
      +
      +      const output = inlineContentImplementation.render(
      +        nodeToCustomInlineContent(
      +          node,
      +          editor.inlineContentSchema,
      +          editor.styleSchema
      +        ) as any as InlineContentFromConfig // TODO: fix cast
      +      );
      +
      +      return {
      +        dom: addInlineContentAttributes(
      +          output.dom,
      +          inlineContentConfig.type,
      +          node.attrs as Props,
      +          inlineContentConfig.propSchema
      +        ),
      +        contentDOM: output.contentDOM,
      +      };
      +    },
      +  });
      +
      +  return createInlineContentSpecFromTipTapNode(
      +    node,
      +    inlineContentConfig.propSchema
      +  ) as InlineContentSpec; // TODO: fix cast
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts b/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts
      new file mode 100644
      index 0000000000..d081338be8
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts
      @@ -0,0 +1,78 @@
      +import { Node } from "@tiptap/core";
      +import { camelToDataKebab } from "../blocks/internal";
      +import { Props, PropSchema } from "../blocks/types";
      +import {
      +  InlineContentConfig,
      +  InlineContentImplementation,
      +  InlineContentSchemaFromSpecs,
      +  InlineContentSpec,
      +  InlineContentSpecs,
      +} from "./types";
      +import { mergeCSSClasses } from "../../../../shared/utils";
      +
      +// Function that adds necessary classes and attributes to the `dom` element
      +// returned from a custom inline content's 'render' function, to ensure no data
      +// is lost on internal copy & paste.
      +export function addInlineContentAttributes<
      +  IType extends string,
      +  PSchema extends PropSchema
      +>(
      +  element: HTMLElement,
      +  inlineContentType: IType,
      +  inlineContentProps: Props,
      +  propSchema: PSchema
      +): HTMLElement {
      +  // Sets inline content section class
      +  element.className = mergeCSSClasses(
      +    "bn-inline-content-section",
      +    element.className
      +  );
      +  // Sets content type attribute
      +  element.setAttribute("data-inline-content-type", inlineContentType);
      +  // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props
      +  // set to their default values.
      +  Object.entries(inlineContentProps)
      +    .filter(([prop, value]) => value !== propSchema[prop].default)
      +    .map(([prop, value]) => {
      +      return [camelToDataKebab(prop), value];
      +    })
      +    .forEach(([prop, value]) => element.setAttribute(prop, value));
      +
      +  return element;
      +}
      +
      +// This helper function helps to instantiate a InlineContentSpec with a
      +// config and implementation that conform to the type of Config
      +export function createInternalInlineContentSpec(
      +  config: T,
      +  implementation: InlineContentImplementation
      +) {
      +  return {
      +    config,
      +    implementation,
      +  } satisfies InlineContentSpec;
      +}
      +
      +export function createInlineContentSpecFromTipTapNode<
      +  T extends Node,
      +  P extends PropSchema
      +>(node: T, propSchema: P) {
      +  return createInternalInlineContentSpec(
      +    {
      +      type: node.name as T["name"],
      +      propSchema,
      +      content: node.config.content === "inline*" ? "styled" : "none",
      +    },
      +    {
      +      node,
      +    }
      +  );
      +}
      +
      +export function getInlineContentSchemaFromSpecs(
      +  specs: T
      +) {
      +  return Object.fromEntries(
      +    Object.entries(specs).map(([key, value]) => [key, value.config])
      +  ) as InlineContentSchemaFromSpecs;
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/inlineContent/types.ts b/packages/core/src/extensions/Blocks/api/inlineContent/types.ts
      new file mode 100644
      index 0000000000..b50622816d
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/inlineContent/types.ts
      @@ -0,0 +1,144 @@
      +import { Node } from "@tiptap/core";
      +import { PropSchema, Props } from "../blocks/types";
      +import { StyleSchema, Styles } from "../styles/types";
      +
      +export type CustomInlineContentConfig = {
      +  type: string;
      +  content: "styled" | "none"; // | "plain"
      +  readonly propSchema: PropSchema;
      +  // content: "inline" | "none" | "table";
      +};
      +// InlineContentConfig contains the "schema" info about an InlineContent type
      +// i.e. what props it supports, what content it supports, etc.
      +export type InlineContentConfig = CustomInlineContentConfig | "text" | "link";
      +
      +// InlineContentImplementation contains the "implementation" info about an InlineContent element
      +// such as the functions / Nodes required to render and / or serialize it
      +// @ts-ignore
      +export type InlineContentImplementation =
      +  T extends "link" | "text"
      +    ? undefined
      +    : {
      +        node: Node;
      +      };
      +
      +// Container for both the config and implementation of InlineContent,
      +// and the type of `implementation` is based on that of the config
      +export type InlineContentSpec = {
      +  config: T;
      +  implementation: InlineContentImplementation;
      +};
      +
      +// A Schema contains all the types (Configs) supported in an editor
      +// The keys are the "type" of InlineContent elements
      +export type InlineContentSchema = Record;
      +
      +export type InlineContentSpecs = {
      +  text: { config: "text"; implementation: undefined };
      +  link: { config: "link"; implementation: undefined };
      +} & Record>;
      +
      +export type InlineContentSchemaFromSpecs = {
      +  [K in keyof T]: T[K]["config"];
      +};
      +
      +export type CustomInlineContentFromConfig<
      +  I extends CustomInlineContentConfig,
      +  S extends StyleSchema
      +> = {
      +  type: I["type"];
      +  props: Props;
      +  content: I["content"] extends "styled"
      +    ? StyledText[]
      +    : I["content"] extends "plain"
      +    ? string
      +    : I["content"] extends "none"
      +    ? undefined
      +    : never;
      +};
      +
      +export type InlineContentFromConfig<
      +  I extends InlineContentConfig,
      +  S extends StyleSchema
      +> = I extends "text"
      +  ? StyledText
      +  : I extends "link"
      +  ? Link
      +  : I extends CustomInlineContentConfig
      +  ? CustomInlineContentFromConfig
      +  : never;
      +
      +export type PartialCustomInlineContentFromConfig<
      +  I extends CustomInlineContentConfig,
      +  S extends StyleSchema
      +> = {
      +  type: I["type"];
      +  props?: Props;
      +  content: I["content"] extends "styled"
      +    ? StyledText[] | string
      +    : I["content"] extends "plain"
      +    ? string
      +    : I["content"] extends "none"
      +    ? undefined
      +    : never;
      +};
      +
      +export type PartialInlineContentFromConfig<
      +  I extends InlineContentConfig,
      +  S extends StyleSchema
      +> = I extends "text"
      +  ? string | StyledText
      +  : I extends "link"
      +  ? PartialLink
      +  : I extends CustomInlineContentConfig
      +  ? PartialCustomInlineContentFromConfig
      +  : never;
      +
      +export type StyledText = {
      +  type: "text";
      +  text: string;
      +  styles: Styles;
      +};
      +
      +export type Link = {
      +  type: "link";
      +  href: string;
      +  content: StyledText[];
      +};
      +
      +export type PartialLink = Omit, "content"> & {
      +  content: string | Link["content"];
      +};
      +
      +export type InlineContent<
      +  I extends InlineContentSchema,
      +  T extends StyleSchema
      +> = InlineContentFromConfig;
      +
      +type PartialInlineContentElement<
      +  I extends InlineContentSchema,
      +  T extends StyleSchema
      +> = PartialInlineContentFromConfig;
      +
      +export type PartialInlineContent<
      +  I extends InlineContentSchema,
      +  T extends StyleSchema
      +> = PartialInlineContentElement[] | string;
      +
      +export function isLinkInlineContent(
      +  content: InlineContent
      +): content is Link {
      +  return content.type === "link";
      +}
      +
      +export function isPartialLinkInlineContent(
      +  content: PartialInlineContentElement
      +): content is PartialLink {
      +  return typeof content !== "string" && content.type === "link";
      +}
      +
      +export function isStyledTextInlineContent(
      +  content: PartialInlineContentElement
      +): content is StyledText {
      +  return typeof content !== "string" && content.type === "text";
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/inlineContentTypes.ts b/packages/core/src/extensions/Blocks/api/inlineContentTypes.ts
      deleted file mode 100644
      index 9d63930d95..0000000000
      --- a/packages/core/src/extensions/Blocks/api/inlineContentTypes.ts
      +++ /dev/null
      @@ -1,36 +0,0 @@
      -export type Styles = {
      -  bold?: true;
      -  italic?: true;
      -  underline?: true;
      -  strike?: true;
      -  code?: true;
      -  textColor?: string;
      -  backgroundColor?: string;
      -};
      -
      -export type ToggledStyle = {
      -  [K in keyof Styles]-?: Required[K] extends true ? K : never;
      -}[keyof Styles];
      -
      -export type ColorStyle = {
      -  [K in keyof Styles]-?: Required[K] extends string ? K : never;
      -}[keyof Styles];
      -
      -export type StyledText = {
      -  type: "text";
      -  text: string;
      -  styles: Styles;
      -};
      -
      -export type Link = {
      -  type: "link";
      -  href: string;
      -  content: StyledText[];
      -};
      -
      -export type PartialLink = Omit & {
      -  content: string | Link["content"];
      -};
      -
      -export type InlineContent = StyledText | Link;
      -export type PartialInlineContent = StyledText | PartialLink;
      diff --git a/packages/core/src/extensions/Blocks/api/selectionTypes.ts b/packages/core/src/extensions/Blocks/api/selectionTypes.ts
      index 8a23f48094..61d8086ed4 100644
      --- a/packages/core/src/extensions/Blocks/api/selectionTypes.ts
      +++ b/packages/core/src/extensions/Blocks/api/selectionTypes.ts
      @@ -1,5 +1,11 @@
      -import { Block, BlockSchema } from "./blockTypes";
      +import { Block, BlockSchema } from "./blocks/types";
      +import { InlineContentSchema } from "./inlineContent/types";
      +import { StyleSchema } from "./styles/types";
       
      -export type Selection = {
      -  blocks: Block[];
      +export type Selection<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  blocks: Block[];
       };
      diff --git a/packages/core/src/extensions/Blocks/api/styles/createSpec.ts b/packages/core/src/extensions/Blocks/api/styles/createSpec.ts
      new file mode 100644
      index 0000000000..14c1c2274f
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/styles/createSpec.ts
      @@ -0,0 +1,79 @@
      +import { Mark } from "@tiptap/core";
      +import { ParseRule } from "@tiptap/pm/model";
      +import { UnreachableCaseError } from "../../../../shared/utils";
      +import {
      +  addStyleAttributes,
      +  createInternalStyleSpec,
      +  stylePropsToAttributes,
      +} from "./internal";
      +import { StyleConfig, StyleSpec } from "./types";
      +
      +export type CustomStyleImplementation = {
      +  render: T["propSchema"] extends "boolean"
      +    ? () => {
      +        dom: HTMLElement;
      +        contentDOM?: HTMLElement;
      +      }
      +    : (value: string) => {
      +        dom: HTMLElement;
      +        contentDOM?: HTMLElement;
      +      };
      +};
      +
      +// TODO: support serialization
      +
      +export function getStyleParseRules(config: StyleConfig): ParseRule[] {
      +  return [
      +    {
      +      tag: `.bn-style[data-style-type="${config.type}"]`,
      +    },
      +  ];
      +}
      +
      +export function createStyleSpec(
      +  styleConfig: T,
      +  styleImplementation: CustomStyleImplementation
      +): StyleSpec {
      +  const mark = Mark.create({
      +    name: styleConfig.type,
      +
      +    addAttributes() {
      +      return stylePropsToAttributes(styleConfig.propSchema);
      +    },
      +
      +    parseHTML() {
      +      return getStyleParseRules(styleConfig);
      +    },
      +
      +    renderHTML({ mark }) {
      +      let renderResult: {
      +        dom: HTMLElement;
      +        contentDOM?: HTMLElement;
      +      };
      +
      +      if (styleConfig.propSchema === "boolean") {
      +        // @ts-ignore not sure why this is complaining
      +        renderResult = styleImplementation.render();
      +      } else if (styleConfig.propSchema === "string") {
      +        renderResult = styleImplementation.render(mark.attrs.stringValue);
      +      } else {
      +        throw new UnreachableCaseError(styleConfig.propSchema);
      +      }
      +
      +      // const renderResult = styleImplementation.render();
      +      return {
      +        dom: addStyleAttributes(
      +          renderResult.dom,
      +          styleConfig.type,
      +          mark.attrs.stringValue,
      +          styleConfig.propSchema
      +        ),
      +        contentDOM: renderResult.contentDOM,
      +      };
      +    },
      +  });
      +
      +  return createInternalStyleSpec(styleConfig, {
      +    mark,
      +  });
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/styles/internal.ts b/packages/core/src/extensions/Blocks/api/styles/internal.ts
      new file mode 100644
      index 0000000000..27b32a3f7a
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/styles/internal.ts
      @@ -0,0 +1,89 @@
      +import { Attributes, Mark } from "@tiptap/core";
      +import {
      +  StyleConfig,
      +  StyleImplementation,
      +  StylePropSchema,
      +  StyleSchemaFromSpecs,
      +  StyleSpec,
      +  StyleSpecs,
      +} from "./types";
      +import { mergeCSSClasses } from "../../../../shared/utils";
      +
      +export function stylePropsToAttributes(
      +  propSchema: StylePropSchema
      +): Attributes {
      +  if (propSchema === "boolean") {
      +    return {};
      +  }
      +  return {
      +    stringValue: {
      +      default: undefined,
      +      keepOnSplit: true,
      +      parseHTML: (element) => element.getAttribute("data-value"),
      +      renderHTML: (attributes) =>
      +        attributes.stringValue !== undefined
      +          ? {
      +              "data-value": attributes.stringValue,
      +            }
      +          : {},
      +    },
      +  };
      +}
      +
      +// Function that adds necessary classes and attributes to the `dom` element
      +// returned from a custom style's 'render' function, to ensure no data is lost
      +// on internal copy & paste.
      +export function addStyleAttributes<
      +  SType extends string,
      +  PSchema extends StylePropSchema
      +>(
      +  element: HTMLElement,
      +  styleType: SType,
      +  styleValue: PSchema extends "boolean" ? undefined : string,
      +  propSchema: PSchema
      +): HTMLElement {
      +  // Sets inline content section class
      +  element.className = mergeCSSClasses("bn-style", element.className);
      +  // Sets content type attribute
      +  element.setAttribute("data-style-type", styleType);
      +  // Adds style value as an HTML attribute in kebab-case with "data-" prefix, if
      +  // the style takes a string value.
      +  if (propSchema === "string") {
      +    element.setAttribute("data-value", styleValue as string);
      +  }
      +
      +  return element;
      +}
      +
      +// This helper function helps to instantiate a stylespec with a
      +// config and implementation that conform to the type of Config
      +export function createInternalStyleSpec(
      +  config: T,
      +  implementation: StyleImplementation
      +) {
      +  return {
      +    config,
      +    implementation,
      +  } satisfies StyleSpec;
      +}
      +
      +export function createStyleSpecFromTipTapMark<
      +  T extends Mark,
      +  P extends StylePropSchema
      +>(mark: T, propSchema: P) {
      +  return createInternalStyleSpec(
      +    {
      +      type: mark.name as T["name"],
      +      propSchema,
      +    },
      +    {
      +      mark,
      +    }
      +  );
      +}
      +
      +export function getStyleSchemaFromSpecs(specs: T) {
      +  return Object.fromEntries(
      +    Object.entries(specs).map(([key, value]) => [key, value.config])
      +  ) as StyleSchemaFromSpecs;
      +}
      diff --git a/packages/core/src/extensions/Blocks/api/styles/types.ts b/packages/core/src/extensions/Blocks/api/styles/types.ts
      new file mode 100644
      index 0000000000..69caf021c9
      --- /dev/null
      +++ b/packages/core/src/extensions/Blocks/api/styles/types.ts
      @@ -0,0 +1,42 @@
      +import { Mark } from "@tiptap/core";
      +
      +export type StylePropSchema = "boolean" | "string"; // TODO: use PropSchema as name? Use objects as type similar to blocks?
      +
      +// StyleConfig contains the "schema" info about a Style type
      +// i.e. what props it supports, what content it supports, etc.
      +export type StyleConfig = {
      +  type: string;
      +  readonly propSchema: StylePropSchema;
      +  // content: "inline" | "none" | "table";
      +};
      +
      +// StyleImplementation contains the "implementation" info about a Style element.
      +// Currently, the implementation is always a TipTap Mark
      +export type StyleImplementation = {
      +  mark: Mark;
      +};
      +
      +// Container for both the config and implementation of a Style,
      +// and the type of `implementation` is based on that of the config
      +export type StyleSpec = {
      +  config: T;
      +  implementation: StyleImplementation;
      +};
      +
      +// A Schema contains all the types (Configs) supported in an editor
      +// The keys are the "type" of Styles supported
      +export type StyleSchema = Record;
      +
      +export type StyleSpecs = Record>;
      +
      +export type StyleSchemaFromSpecs = {
      +  [K in keyof T]: T[K]["config"];
      +};
      +
      +export type Styles = {
      +  [K in keyof T]?: T[K]["propSchema"] extends "boolean"
      +    ? boolean
      +    : T[K]["propSchema"] extends "string"
      +    ? string
      +    : never;
      +};
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts
      index 499f81c160..bba83b4308 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts
      @@ -2,6 +2,7 @@ import { Node } from "@tiptap/core";
       import { Fragment, Node as PMNode, Slice } from "prosemirror-model";
       import { NodeSelection, TextSelection } from "prosemirror-state";
       
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
       import {
         blockToNode,
         inlineContentToNodes,
      @@ -14,7 +15,9 @@ import {
         BlockNoteDOMAttributes,
         BlockSchema,
         PartialBlock,
      -} from "../api/blockTypes";
      +} from "../api/blocks/types";
      +import { InlineContentSchema } from "../api/inlineContent/types";
      +import { StyleSchema } from "../api/styles/types";
       import { getBlockInfoFromPos } from "../helpers/getBlockInfoFromPos";
       import BlockAttributes from "./BlockAttributes";
       
      @@ -25,13 +28,21 @@ declare module "@tiptap/core" {
             BNDeleteBlock: (posInBlock: number) => ReturnType;
             BNMergeBlocks: (posBetweenBlocks: number) => ReturnType;
             BNSplitBlock: (posInBlock: number, keepType: boolean) => ReturnType;
      -      BNUpdateBlock: (
      +      BNUpdateBlock: <
      +        BSchema extends BlockSchema,
      +        I extends InlineContentSchema,
      +        S extends StyleSchema
      +      >(
               posInBlock: number,
      -        block: PartialBlock
      +        block: PartialBlock
             ) => ReturnType;
      -      BNCreateOrUpdateBlock: (
      +      BNCreateOrUpdateBlock: <
      +        BSchema extends BlockSchema,
      +        I extends InlineContentSchema,
      +        S extends StyleSchema
      +      >(
               posInBlock: number,
      -        block: PartialBlock
      +        block: PartialBlock
             ) => ReturnType;
           };
         }
      @@ -42,6 +53,7 @@ declare module "@tiptap/core" {
        */
       export const BlockContainer = Node.create<{
         domAttributes?: BlockNoteDOMAttributes;
      +  editor: BlockNoteEditor;
       }>({
         name: "blockContainer",
         group: "blockContainer",
      @@ -158,7 +170,13 @@ export const BlockContainer = Node.create<{
       
                     // Creates ProseMirror nodes for each child block, including their descendants.
                     for (const child of block.children) {
      -                childNodes.push(blockToNode(child, state.schema));
      +                childNodes.push(
      +                  blockToNode(
      +                    child,
      +                    state.schema,
      +                    this.options.editor.styleSchema
      +                  )
      +                );
                     }
       
                     // Checks if a blockGroup node already exists.
      @@ -193,9 +211,17 @@ export const BlockContainer = Node.create<{
                     } else if (Array.isArray(block.content)) {
                       // Adds a text node with the provided styles converted into marks to the content,
                       // for each InlineContent object.
      -                content = inlineContentToNodes(block.content, state.schema);
      +                content = inlineContentToNodes(
      +                  block.content,
      +                  state.schema,
      +                  this.options.editor.styleSchema
      +                );
                     } else if (block.content.type === "tableContent") {
      -                content = tableContentToNodes(block.content, state.schema);
      +                content = tableContentToNodes(
      +                  block.content,
      +                  state.schema,
      +                  this.options.editor.styleSchema
      +                );
                     } else {
                       throw new UnreachableCaseError(block.content.type);
                     }
      @@ -457,13 +483,12 @@ export const BlockContainer = Node.create<{
               // Reverts block content type to a paragraph if the selection is at the start of the block.
               () =>
                 commands.command(({ state }) => {
      -            const { contentType } = getBlockInfoFromPos(
      +            const { contentType, startPos } = getBlockInfoFromPos(
                     state.doc,
                     state.selection.from
                   )!;
       
      -            const selectionAtBlockStart =
      -              state.selection.$anchor.parentOffset === 0;
      +            const selectionAtBlockStart = state.selection.from === startPos + 1;
                   const isParagraph = contentType.name === "paragraph";
       
                   if (selectionAtBlockStart && !isParagraph) {
      @@ -478,8 +503,12 @@ export const BlockContainer = Node.create<{
               // Removes a level of nesting if the block is indented if the selection is at the start of the block.
               () =>
                 commands.command(({ state }) => {
      -            const selectionAtBlockStart =
      -              state.selection.$anchor.parentOffset === 0;
      +            const { startPos } = getBlockInfoFromPos(
      +              state.doc,
      +              state.selection.from
      +            )!;
      +
      +            const selectionAtBlockStart = state.selection.from === startPos + 1;
       
                   if (selectionAtBlockStart) {
                     return commands.liftListItem("blockContainer");
      @@ -496,10 +525,8 @@ export const BlockContainer = Node.create<{
                     state.selection.from
                   )!;
       
      -            const selectionAtBlockStart =
      -              state.selection.$anchor.parentOffset === 0;
      -            const selectionEmpty =
      -              state.selection.anchor === state.selection.head;
      +            const selectionAtBlockStart = state.selection.from === startPos + 1;
      +            const selectionEmpty = state.selection.empty;
                   const blockAtDocStart = startPos === 2;
       
                   const posBetweenBlocks = startPos - 1;
      @@ -526,17 +553,14 @@ export const BlockContainer = Node.create<{
               // end of the block.
               () =>
                 commands.command(({ state }) => {
      -            const { node, contentNode, depth, endPos } = getBlockInfoFromPos(
      +            const { node, depth, endPos } = getBlockInfoFromPos(
                     state.doc,
                     state.selection.from
                   )!;
       
                   const blockAtDocEnd = false;
      -            const selectionAtBlockEnd =
      -              state.selection.$anchor.parentOffset ===
      -              contentNode.firstChild!.nodeSize;
      -            const selectionEmpty =
      -              state.selection.anchor === state.selection.head;
      +            const selectionAtBlockEnd = state.selection.from === endPos - 1;
      +            const selectionEmpty = state.selection.empty;
                   const hasChildBlocks = node.childCount === 2;
       
                   if (
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts
      index dcb82fa5b6..50a0b74197 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts
      @@ -2,8 +2,8 @@ import { InputRule } from "@tiptap/core";
       import {
         createBlockSpecFromStronglyTypedTiptapNode,
         createStronglyTypedTiptapNode,
      -} from "../../../api/block";
      -import { PropSchema } from "../../../api/blockTypes";
      +} from "../../../api/blocks/internal";
      +import { PropSchema } from "../../../api/blocks/types";
       import { defaultProps } from "../../../api/defaultProps";
       import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
       
      @@ -21,7 +21,14 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({
             level: {
               default: 1,
               // instead of "level" attributes, use "data-level"
      -        parseHTML: (element) => element.getAttribute("data-level")!,
      +        parseHTML: (element) => {
      +          const attr = element.getAttribute("data-level")!;
      +          const parsed = parseInt(attr);
      +          if (isFinite(parsed)) {
      +            return parsed;
      +          }
      +          return undefined;
      +        },
               renderHTML: (attributes) => {
                 return {
                   "data-level": (attributes.level as number).toString(),
      @@ -78,9 +85,20 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({
               }),
           };
         },
      -
         parseHTML() {
           return [
      +      {
      +        tag: "div[data-content-type=" + this.name + "]",
      +        getAttrs: (element) => {
      +          if (typeof element === "string") {
      +            return false;
      +          }
      +
      +          return {
      +            level: element.getAttribute("data-level"),
      +          };
      +        },
      +      },
             {
               tag: "h1",
               attrs: { level: 1 },
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts
      index 85f1d3592e..4a373c03e5 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts
      @@ -2,12 +2,17 @@ import { BlockNoteEditor } from "../../../../../BlockNoteEditor";
       import { imageToolbarPluginKey } from "../../../../ImageToolbar/ImageToolbarPlugin";
       
       import {
      -  Block,
      +  CustomBlockConfig,
      +  createBlockSpec,
      +} from "../../../api/blocks/createSpec";
      +import {
      +  BlockFromConfig,
         BlockSchemaWithBlock,
         PropSchema,
      -} from "../../../api/blockTypes";
      -import { CustomBlockConfig, createBlockSpec } from "../../../api/customBlocks";
      +} from "../../../api/blocks/types";
       import { defaultProps } from "../../../api/defaultProps";
      +import { InlineContentSchema } from "../../../api/inlineContent/types";
      +import { StyleSchema } from "../../../api/styles/types";
       
       export const imagePropSchema = {
         textAlignment: defaultProps.textAlignment,
      @@ -52,7 +57,7 @@ const blockConfig = {
       } satisfies CustomBlockConfig;
       
       export const renderImage = (
      -  block: Block,
      +  block: BlockFromConfig,
         editor: BlockNoteEditor>
       ) => {
         // Wrapper element to set the image alignment, contains both image/image
      @@ -366,17 +371,29 @@ export const Image = createBlockSpec(
               dom: figure,
             };
           },
      +    parse: (element: HTMLElement) => {
      +      if (element.tagName === "FIGURE") {
      +        const img = element.querySelector("img");
      +        const caption = element.querySelector("figcaption");
      +        return {
      +          type: "image",
      +          props: {
      +            url: img?.getAttribute("src") || "",
      +            caption:
      +              caption?.textContent || img?.getAttribute("alt") || undefined,
      +          },
      +        };
      +      } else if (element.tagName === "IMG") {
      +        return {
      +          type: "image",
      +          props: {
      +            url: element.getAttribute("src") || "",
      +            caption: element.getAttribute("alt") || undefined,
      +          },
      +        };
      +      }
      +
      +      return undefined;
      +    },
         }
      -  // parse: (element) => {
      -  //   if (element.tagName === "IMG") {
      -  //     return {
      -  //       type: "image",
      -  //       props: {
      -  //         url: element.getAttribute("src") || "",
      -  //       },
      -  //     };
      -  //   }
      -  //
      -  //   return;
      -  // },
       );
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
      index 46777fa1a6..602510ade1 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
      @@ -2,8 +2,8 @@ import { InputRule } from "@tiptap/core";
       import {
         createBlockSpecFromStronglyTypedTiptapNode,
         createStronglyTypedTiptapNode,
      -} from "../../../../api/block";
      -import { PropSchema } from "../../../../api/blockTypes";
      +} from "../../../../api/blocks/internal";
      +import { PropSchema } from "../../../../api/blocks/types";
       import { defaultProps } from "../../../../api/defaultProps";
       import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers";
       import { handleEnter } from "../ListItemKeyboardShortcuts";
      @@ -48,6 +48,9 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({
         parseHTML() {
           return [
             // Case for regular HTML list structure.
      +      {
      +        tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this
      +      },
             {
               tag: "li",
               getAttrs: (element) => {
      @@ -61,7 +64,10 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({
                   return false;
                 }
       
      -          if (parent.tagName === "UL") {
      +          if (
      +            parent.tagName === "UL" ||
      +            (parent.tagName === "DIV" && parent.parentElement!.tagName === "UL")
      +          ) {
                   return {};
                 }
       
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
      index 1ce586fd4b..e8db16998f 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
      @@ -2,8 +2,8 @@ import { InputRule } from "@tiptap/core";
       import {
         createBlockSpecFromStronglyTypedTiptapNode,
         createStronglyTypedTiptapNode,
      -} from "../../../../api/block";
      -import { PropSchema } from "../../../../api/blockTypes";
      +} from "../../../../api/blocks/internal";
      +import { PropSchema } from "../../../../api/blocks/types";
       import { defaultProps } from "../../../../api/defaultProps";
       import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers";
       import { handleEnter } from "../ListItemKeyboardShortcuts";
      @@ -66,6 +66,9 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
       
         parseHTML() {
           return [
      +      {
      +        tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this
      +      },
             // Case for regular HTML list structure.
             // (e.g.: when pasting from other apps)
             {
      @@ -81,7 +84,10 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
                   return false;
                 }
       
      -          if (parent.tagName === "OL") {
      +          if (
      +            parent.tagName === "OL" ||
      +            (parent.tagName === "DIV" && parent.parentElement!.tagName === "OL")
      +          ) {
                   return {};
                 }
       
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts
      index d0d4c5d34e..8c826f413e 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts
      @@ -1,7 +1,7 @@
       import {
         createBlockSpecFromStronglyTypedTiptapNode,
         createStronglyTypedTiptapNode,
      -} from "../../../api/block";
      +} from "../../../api/blocks/internal";
       import { defaultProps } from "../../../api/defaultProps";
       import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
       
      @@ -15,6 +15,7 @@ export const ParagraphBlockContent = createStronglyTypedTiptapNode({
         group: "blockContent",
         parseHTML() {
           return [
      +      { tag: "div[data-content-type=" + this.name + "]" },
             {
               tag: "p",
               priority: 200,
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts
      index f24ff7ce4d..8807586fdc 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts
      @@ -1,13 +1,14 @@
      -import { Paragraph } from "@tiptap/extension-paragraph";
      +import { Node, mergeAttributes } from "@tiptap/core";
       import { TableCell } from "@tiptap/extension-table-cell";
       import { TableHeader } from "@tiptap/extension-table-header";
       import { TableRow } from "@tiptap/extension-table-row";
       import {
         createBlockSpecFromStronglyTypedTiptapNode,
         createStronglyTypedTiptapNode,
      -} from "../../../api/block";
      +} from "../../../api/blocks/internal";
       import { defaultProps } from "../../../api/defaultProps";
       import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
      +import { TableExtension } from "./TableExtension";
       
       export const tablePropSchema = {
         ...defaultProps,
      @@ -38,15 +39,28 @@ export const TableBlockContent = createStronglyTypedTiptapNode({
         },
       });
       
      -const TableParagraph = Paragraph.extend({
      +const TableParagraph = Node.create({
         name: "tableParagraph",
         group: "tableContent",
      +
      +  parseHTML() {
      +    return [{ tag: "p" }];
      +  },
      +
      +  renderHTML({ HTMLAttributes }) {
      +    return [
      +      "p",
      +      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
      +      0,
      +    ];
      +  },
       });
       
       export const Table = createBlockSpecFromStronglyTypedTiptapNode(
         TableBlockContent,
         tablePropSchema,
         [
      +    TableExtension,
           TableParagraph,
           TableHeader.extend({
             content: "tableContent",
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts
      index e6e828c08f..b5ade2c570 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts
      @@ -1,7 +1,9 @@
      -import { Block } from "../../../..";
       import { BlockNoteEditor } from "../../../../BlockNoteEditor";
       import { blockToNode } from "../../../../api/nodeConversions/nodeConversions";
       import { mergeCSSClasses } from "../../../../shared/utils";
      +import { Block, BlockSchema } from "../../api/blocks/types";
      +import { InlineContentSchema } from "../../api/inlineContent/types";
      +import { StyleSchema } from "../../api/styles/types";
       
       // Function that creates a ProseMirror `DOMOutputSpec` for a default block.
       // Since all default blocks have the same structure (`blockContent` div with a
      @@ -51,14 +53,22 @@ export function createDefaultBlockDOMOutputSpec(
       // Function used to convert default blocks to HTML. It uses the corresponding
       // node's `renderHTML` method to do the conversion by using a default
       // `DOMSerializer`.
      -export const defaultBlockToHTML = (
      -  block: Block,
      -  editor: BlockNoteEditor
      +export const defaultBlockToHTML = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  block: Block,
      +  editor: BlockNoteEditor
       ): {
         dom: HTMLElement;
         contentDOM?: HTMLElement;
       } => {
      -  const node = blockToNode(block, editor._tiptapEditor.schema).firstChild!;
      +  const node = blockToNode(
      +    block,
      +    editor._tiptapEditor.schema,
      +    editor.styleSchema
      +  ).firstChild!;
         const toDOM = editor._tiptapEditor.schema.nodes[node.type.name].spec.toDOM;
       
         if (toDOM === undefined) {
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts b/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts
      index 31d9af1516..88f4a3025c 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts
      +++ b/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts
      @@ -1,6 +1,6 @@
       import { Node } from "@tiptap/core";
      -import { BlockNoteDOMAttributes } from "../api/blockTypes";
       import { mergeCSSClasses } from "../../../shared/utils";
      +import { BlockNoteDOMAttributes } from "../api/blocks/types";
       
       export const BlockGroup = Node.create<{
         domAttributes?: BlockNoteDOMAttributes;
      diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts
      index 29d893fccb..1af1cc2328 100644
      --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts
      +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts
      @@ -1,19 +1,22 @@
       import { isNodeSelection, isTextSelection, posToDOMRect } from "@tiptap/core";
       import { EditorState, Plugin, PluginKey } from "prosemirror-state";
       import { EditorView } from "prosemirror-view";
      +
      +import { BlockNoteEditor } from "../../BlockNoteEditor";
       import {
         BaseUiElementCallbacks,
         BaseUiElementState,
      -  BlockNoteEditor,
      -  BlockSchema,
      -} from "../..";
      +} from "../../shared/BaseUiElementTypes";
       import { EventEmitter } from "../../shared/EventEmitter";
      +import { BlockSchema } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       
       export type FormattingToolbarCallbacks = BaseUiElementCallbacks;
       
       export type FormattingToolbarState = BaseUiElementState;
       
      -export class FormattingToolbarView {
      +export class FormattingToolbarView {
         private formattingToolbarState?: FormattingToolbarState;
         public updateFormattingToolbar: () => void;
       
      @@ -26,21 +29,22 @@ export class FormattingToolbarView {
           state: EditorState;
           from: number;
           to: number;
      -  }) => boolean = ({ view, state, from, to }) => {
      -    const { doc, selection } = state;
      +  }) => boolean = ({ state }) => {
      +    const { selection } = state;
           const { empty } = selection;
       
      -    // Sometime check for `empty` is not enough.
      -    // Doubleclick an empty paragraph returns a node size of 2.
      -    // So we check also for an empty text size.
      -    const isEmptyTextBlock =
      -      !doc.textBetween(from, to).length && isTextSelection(state.selection);
      -
      -    return !(!view.hasFocus() || empty || isEmptyTextBlock);
      +    if (!isTextSelection(selection)) {
      +      return false;
      +    }
      +    return !empty;
         };
       
         constructor(
      -    private readonly editor: BlockNoteEditor,
      +    private readonly editor: BlockNoteEditor<
      +      BlockSchema,
      +      InlineContentSchema,
      +      StyleSchema
      +    >,
           private readonly pmView: EditorView,
           updateFormattingToolbar: (
             formattingToolbarState: FormattingToolbarState
      @@ -216,13 +220,11 @@ export const formattingToolbarPluginKey = new PluginKey(
         "FormattingToolbarPlugin"
       );
       
      -export class FormattingToolbarProsemirrorPlugin<
      -  BSchema extends BlockSchema
      -> extends EventEmitter {
      -  private view: FormattingToolbarView | undefined;
      +export class FormattingToolbarProsemirrorPlugin extends EventEmitter {
      +  private view: FormattingToolbarView | undefined;
         public readonly plugin: Plugin;
       
      -  constructor(editor: BlockNoteEditor) {
      +  constructor(editor: BlockNoteEditor) {
           super();
           this.plugin = new Plugin({
             key: formattingToolbarPluginKey,
      diff --git a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts
      index 65ae274279..abdd1f4cab 100644
      --- a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts
      +++ b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts
      @@ -5,7 +5,9 @@ import { Plugin, PluginKey } from "prosemirror-state";
       import { BlockNoteEditor } from "../../BlockNoteEditor";
       import { BaseUiElementState } from "../../shared/BaseUiElementTypes";
       import { EventEmitter } from "../../shared/EventEmitter";
      -import { BlockSchema } from "../Blocks/api/blockTypes";
      +import { BlockSchema } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       
       export type HyperlinkToolbarState = BaseUiElementState & {
         // The hovered hyperlink's URL, and the text it's displayed with in the
      @@ -14,11 +16,11 @@ export type HyperlinkToolbarState = BaseUiElementState & {
         text: string;
       };
       
      -class HyperlinkToolbarView {
      +class HyperlinkToolbarView {
         private hyperlinkToolbarState?: HyperlinkToolbarState;
         public updateHyperlinkToolbar: () => void;
       
      -  menuUpdateTimer: NodeJS.Timeout | undefined;
      +  menuUpdateTimer: ReturnType | undefined;
         startMenuUpdateTimer: () => void;
         stopMenuUpdateTimer: () => void;
       
      @@ -32,7 +34,7 @@ class HyperlinkToolbarView {
         hyperlinkMarkRange: Range | undefined;
       
         constructor(
      -    private readonly editor: BlockNoteEditor,
      +    private readonly editor: BlockNoteEditor,
           private readonly pmView: EditorView,
           updateHyperlinkToolbar: (
             hyperlinkToolbarState: HyperlinkToolbarState
      @@ -275,12 +277,14 @@ export const hyperlinkToolbarPluginKey = new PluginKey(
       );
       
       export class HyperlinkToolbarProsemirrorPlugin<
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > extends EventEmitter {
      -  private view: HyperlinkToolbarView | undefined;
      +  private view: HyperlinkToolbarView | undefined;
         public readonly plugin: Plugin;
       
      -  constructor(editor: BlockNoteEditor) {
      +  constructor(editor: BlockNoteEditor) {
           super();
           this.plugin = new Plugin({
             key: hyperlinkToolbarPluginKey,
      diff --git a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts
      index acf7f015f8..9ecb162d3a 100644
      --- a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts
      +++ b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts
      @@ -1,22 +1,31 @@
       import { EditorState, Plugin, PluginKey } from "prosemirror-state";
       import { EditorView } from "prosemirror-view";
      +
      +import { BlockNoteEditor } from "../../BlockNoteEditor";
       import {
         BaseUiElementCallbacks,
         BaseUiElementState,
      -  BlockNoteEditor,
      -  BlockSchema,
      -} from "../..";
      -import { Block } from "../../extensions/Blocks/api/blockTypes";
      +} from "../../shared/BaseUiElementTypes";
       import { EventEmitter } from "../../shared/EventEmitter";
      -import { Image } from "../Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent";
      +import { BlockSchema, SpecificBlock } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       export type ImageToolbarCallbacks = BaseUiElementCallbacks;
       
      -export type ImageToolbarState = BaseUiElementState & {
      -  block: Block<(typeof Image)["config"]>;
      +export type ImageToolbarState<
      +  B extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema = StyleSchema
      +> = BaseUiElementState & {
      +  block: SpecificBlock;
       };
       
      -export class ImageToolbarView {
      -  private imageToolbarState?: ImageToolbarState;
      +export class ImageToolbarView<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> {
      +  private imageToolbarState?: ImageToolbarState;
         public updateImageToolbar: () => void;
       
         public prevWasEditable: boolean | null = null;
      @@ -24,7 +33,9 @@ export class ImageToolbarView {
         constructor(
           private readonly pluginKey: PluginKey,
           private readonly pmView: EditorView,
      -    updateImageToolbar: (imageToolbarState: ImageToolbarState) => void
      +    updateImageToolbar: (
      +      imageToolbarState: ImageToolbarState
      +    ) => void
         ) {
           this.updateImageToolbar = () => {
             if (!this.imageToolbarState) {
      @@ -94,7 +105,7 @@ export class ImageToolbarView {
       
         update(view: EditorView, prevState: EditorState) {
           const pluginState: {
      -      block: Block<(typeof Image)["config"]>;
      +      block: SpecificBlock;
           } = this.pluginKey.getState(view.state);
       
           if (!this.imageToolbarState?.show && pluginState.block) {
      @@ -139,15 +150,17 @@ export class ImageToolbarView {
       export const imageToolbarPluginKey = new PluginKey("ImageToolbarPlugin");
       
       export class ImageToolbarProsemirrorPlugin<
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > extends EventEmitter {
      -  private view: ImageToolbarView | undefined;
      +  private view: ImageToolbarView | undefined;
         public readonly plugin: Plugin;
       
      -  constructor(_editor: BlockNoteEditor) {
      +  constructor(_editor: BlockNoteEditor) {
           super();
           this.plugin = new Plugin<{
      -      block: Block<(typeof Image)["config"]> | undefined;
      +      block: SpecificBlock | undefined;
           }>({
             key: imageToolbarPluginKey,
             view: (editorView) => {
      @@ -168,7 +181,7 @@ export class ImageToolbarProsemirrorPlugin<
                 };
               },
               apply: (transaction) => {
      -          const block: Block<(typeof Image)["config"]> | undefined =
      +          const block: SpecificBlock | undefined =
                   transaction.getMeta(imageToolbarPluginKey)?.block;
       
                 return {
      @@ -179,7 +192,7 @@ export class ImageToolbarProsemirrorPlugin<
           });
         }
       
      -  public onUpdate(callback: (state: ImageToolbarState) => void) {
      +  public onUpdate(callback: (state: ImageToolbarState) => void) {
           return this.on("update", callback);
         }
       }
      diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
      index c4e5823426..ed87b5df07 100644
      --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
      +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
      @@ -3,21 +3,27 @@ import { Node } from "prosemirror-model";
       import { NodeSelection, Plugin, PluginKey, Selection } from "prosemirror-state";
       import { EditorView } from "prosemirror-view";
       import { BlockNoteEditor } from "../../BlockNoteEditor";
      -import { markdown } from "../../api/formatConversions/formatConversions";
      -import { createExternalHTMLExporter } from "../../api/serialization/html/externalHTMLExporter";
      -import { createInternalHTMLSerializer } from "../../api/serialization/html/internalHTMLSerializer";
      +import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter";
      +import { createInternalHTMLSerializer } from "../../api/exporters/html/internalHTMLSerializer";
      +import { cleanHTMLToMarkdown } from "../../api/exporters/markdown/markdownExporter";
       import { BaseUiElementState } from "../../shared/BaseUiElementTypes";
       import { EventEmitter } from "../../shared/EventEmitter";
      -import { Block, BlockSchema } from "../Blocks/api/blockTypes";
      +import { Block, BlockSchema } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos";
       import { slashMenuPluginKey } from "../SlashMenu/SlashMenuPlugin";
       import { MultipleNodeSelection } from "./MultipleNodeSelection";
       
       let dragImageElement: Element | undefined;
       
      -export type SideMenuState = BaseUiElementState & {
      +export type SideMenuState<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = BaseUiElementState & {
         // The block that the side menu is attached to.
      -  block: Block;
      +  block: Block;
       };
       
       export function getDraggableBlockFromCoords(
      @@ -170,9 +176,13 @@ function unsetDragImage() {
         }
       }
       
      -function dragStart(
      +function dragStart<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
         e: { dataTransfer: DataTransfer | null; clientY: number },
      -  editor: BlockNoteEditor
      +  editor: BlockNoteEditor
       ) {
         if (!e.dataTransfer) {
           return;
      @@ -224,7 +234,7 @@ function dragStart(
             selectedSlice.content
           );
       
      -    const plainText = markdown(externalHTML);
      +    const plainText = cleanHTMLToMarkdown(externalHTML);
       
           e.dataTransfer.clearData();
           e.dataTransfer.setData("blocknote/html", internalHTML);
      @@ -236,8 +246,13 @@ function dragStart(
         }
       }
       
      -export class SideMenuView implements PluginView {
      -  private sideMenuState?: SideMenuState;
      +export class SideMenuView<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> implements PluginView
      +{
      +  private sideMenuState?: SideMenuState;
       
         // When true, the drag handle with be anchored at the same level as root elements
         // When false, the drag handle with be just to the left of the element
      @@ -253,10 +268,10 @@ export class SideMenuView implements PluginView {
         public menuFrozen = false;
       
         constructor(
      -    private readonly editor: BlockNoteEditor,
      +    private readonly editor: BlockNoteEditor,
           private readonly pmView: EditorView,
           private readonly updateSideMenu: (
      -      sideMenuState: SideMenuState
      +      sideMenuState: SideMenuState
           ) => void
         ) {
           this.horizontalPosAnchoredAtRoot = true;
      @@ -561,12 +576,14 @@ export class SideMenuView implements PluginView {
       export const sideMenuPluginKey = new PluginKey("SideMenuPlugin");
       
       export class SideMenuProsemirrorPlugin<
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > extends EventEmitter {
      -  private sideMenuView: SideMenuView | undefined;
      +  private sideMenuView: SideMenuView | undefined;
         public readonly plugin: Plugin;
       
      -  constructor(private readonly editor: BlockNoteEditor) {
      +  constructor(private readonly editor: BlockNoteEditor) {
           super();
           this.plugin = new Plugin({
             key: sideMenuPluginKey,
      @@ -583,7 +600,7 @@ export class SideMenuProsemirrorPlugin<
           });
         }
       
      -  public onUpdate(callback: (state: SideMenuState) => void) {
      +  public onUpdate(callback: (state: SideMenuState) => void) {
           return this.on("update", callback);
         }
       
      diff --git a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts
      index 41fc78917c..6bcfd8c361 100644
      --- a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts
      +++ b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts
      @@ -1,11 +1,14 @@
      -import { SuggestionItem } from "../../shared/plugins/suggestion/SuggestionItem";
       import { BlockNoteEditor } from "../../BlockNoteEditor";
      -import { BlockSchema } from "../Blocks/api/blockTypes";
      -import { DefaultBlockSchema } from "../Blocks/api/defaultBlocks";
      +import { SuggestionItem } from "../../shared/plugins/suggestion/SuggestionItem";
      +import { BlockSchema } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       
       export type BaseSlashMenuItem<
      -  BSchema extends BlockSchema = DefaultBlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > = SuggestionItem & {
      -  execute: (editor: BlockNoteEditor) => void;
      +  execute: (editor: BlockNoteEditor) => void;
         aliases?: string[];
       };
      diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts
      index a190cc3209..67aec3cdb0 100644
      --- a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts
      +++ b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts
      @@ -6,21 +6,25 @@ import {
         SuggestionsMenuState,
         setupSuggestionsMenu,
       } from "../../shared/plugins/suggestion/SuggestionPlugin";
      -import { BlockSchema } from "../Blocks/api/blockTypes";
      +import { BlockSchema } from "../Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       import { BaseSlashMenuItem } from "./BaseSlashMenuItem";
       
       export const slashMenuPluginKey = new PluginKey("SlashMenuPlugin");
       
       export class SlashMenuProsemirrorPlugin<
         BSchema extends BlockSchema,
      -  SlashMenuItem extends BaseSlashMenuItem
      +  I extends InlineContentSchema,
      +  S extends StyleSchema,
      +  SlashMenuItem extends BaseSlashMenuItem
       > extends EventEmitter {
         public readonly plugin: Plugin;
         public readonly itemCallback: (item: SlashMenuItem) => void;
       
      -  constructor(editor: BlockNoteEditor, items: SlashMenuItem[]) {
      +  constructor(editor: BlockNoteEditor, items: SlashMenuItem[]) {
           super();
      -    const suggestions = setupSuggestionsMenu(
      +    const suggestions = setupSuggestionsMenu(
             editor,
             (state) => {
               this.emit("update", state);
      diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts
      index d4f1c14824..52a9bc9337 100644
      --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts
      +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts
      @@ -1,6 +1,11 @@
       import { BlockNoteEditor } from "../../BlockNoteEditor";
      -import { Block, BlockSchema, PartialBlock } from "../Blocks/api/blockTypes";
      +import { Block, BlockSchema, PartialBlock } from "../Blocks/api/blocks/types";
       import { defaultBlockSchema } from "../Blocks/api/defaultBlocks";
      +import {
      +  InlineContentSchema,
      +  isStyledTextInlineContent,
      +} from "../Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../Blocks/api/styles/types";
       import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin";
       import { BaseSlashMenuItem } from "./BaseSlashMenuItem";
       
      @@ -8,11 +13,13 @@ import { BaseSlashMenuItem } from "./BaseSlashMenuItem";
       // so either a block with inline content or a table. The last block is always a
       // paragraph, so this function won't try to set the cursor position past the
       // last block.
      -function setSelectionToNextContentEditableBlock(
      -  editor: BlockNoteEditor
      -) {
      +function setSelectionToNextContentEditableBlock<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(editor: BlockNoteEditor) {
         let block = editor.getTextCursorPosition().block;
      -  let contentType = editor.schema[block.type].config.content as
      +  let contentType = editor.blockSchema[block.type].content as
           | "inline"
           | "table"
           | "none";
      @@ -20,7 +27,7 @@ function setSelectionToNextContentEditableBlock(
         while (contentType === "none") {
           editor.setTextCursorPosition(block, "end");
           block = editor.getTextCursorPosition().nextBlock!;
      -    contentType = editor.schema[block.type].config.content as
      +    contentType = editor.blockSchema[block.type].content as
             | "inline"
             | "table"
             | "none";
      @@ -31,10 +38,14 @@ function setSelectionToNextContentEditableBlock(
       // updates the current block instead of inserting a new one below. If the new
       // block doesn't contain editable content, the cursor is moved to the next block
       // that does.
      -function insertOrUpdateBlock(
      -  editor: BlockNoteEditor,
      -  block: PartialBlock
      -): Block {
      +function insertOrUpdateBlock<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  editor: BlockNoteEditor,
      +  block: PartialBlock
      +): Block {
         const currentBlock = editor.getTextCursorPosition().block;
       
         if (currentBlock.content === undefined) {
      @@ -44,6 +55,7 @@ function insertOrUpdateBlock(
         if (
           Array.isArray(currentBlock.content) &&
           ((currentBlock.content.length === 1 &&
      +      isStyledTextInlineContent(currentBlock.content[0]) &&
             currentBlock.content[0].type === "text" &&
             currentBlock.content[0].text === "/") ||
             currentBlock.content.length === 0)
      @@ -63,18 +75,18 @@ function insertOrUpdateBlock(
         return insertedBlock;
       }
       
      -export const getDefaultSlashMenuItems = (
      -  // This type casting is weird, but it's the best way of doing it, as it allows
      -  // the schema type to be automatically inferred if it is defined, or be
      -  // inferred as any if it is not defined. I don't think it's possible to make it
      -  // infer to DefaultBlockSchema if it is not defined.
      +export const getDefaultSlashMenuItems = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
         schema: BSchema = defaultBlockSchema as unknown as BSchema
       ) => {
      -  const slashMenuItems: BaseSlashMenuItem[] = [];
      +  const slashMenuItems: BaseSlashMenuItem[] = [];
       
      -  if ("heading" in schema && "level" in schema.heading.config.propSchema) {
      +  if ("heading" in schema && "level" in schema.heading.propSchema) {
           // Command for creating a level 1 heading
      -    if (schema.heading.config.propSchema.level.values?.includes(1)) {
      +    if (schema.heading.propSchema.level.values?.includes(1)) {
             slashMenuItems.push({
               name: "Heading",
               aliases: ["h", "heading1", "h1"],
      @@ -82,12 +94,12 @@ export const getDefaultSlashMenuItems = (
                 insertOrUpdateBlock(editor, {
                   type: "heading",
                   props: { level: 1 },
      -          } as PartialBlock),
      +          } as PartialBlock),
             });
           }
       
           // Command for creating a level 2 heading
      -    if (schema.heading.config.propSchema.level.values?.includes(2)) {
      +    if (schema.heading.propSchema.level.values?.includes(2)) {
             slashMenuItems.push({
               name: "Heading 2",
               aliases: ["h2", "heading2", "subheading"],
      @@ -95,12 +107,12 @@ export const getDefaultSlashMenuItems = (
                 insertOrUpdateBlock(editor, {
                   type: "heading",
                   props: { level: 2 },
      -          } as PartialBlock),
      +          } as PartialBlock),
             });
           }
       
           // Command for creating a level 3 heading
      -    if (schema.heading.config.propSchema.level.values?.includes(3)) {
      +    if (schema.heading.propSchema.level.values?.includes(3)) {
             slashMenuItems.push({
               name: "Heading 3",
               aliases: ["h3", "heading3", "subheading"],
      @@ -108,7 +120,7 @@ export const getDefaultSlashMenuItems = (
                 insertOrUpdateBlock(editor, {
                   type: "heading",
                   props: { level: 3 },
      -          } as PartialBlock),
      +          } as PartialBlock),
             });
           }
         }
      @@ -120,7 +132,7 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) =>
               insertOrUpdateBlock(editor, {
                 type: "bulletListItem",
      -        } as PartialBlock),
      +        }),
           });
         }
       
      @@ -131,7 +143,7 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) =>
               insertOrUpdateBlock(editor, {
                 type: "numberedListItem",
      -        } as PartialBlock),
      +        }),
           });
         }
       
      @@ -142,7 +154,7 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) =>
               insertOrUpdateBlock(editor, {
                 type: "paragraph",
      -        } as PartialBlock),
      +        }),
           });
         }
       
      @@ -169,7 +181,7 @@ export const getDefaultSlashMenuItems = (
                     },
                   ],
                 },
      -        } as PartialBlock);
      +        } as PartialBlock);
             },
           });
         }
      @@ -191,7 +203,7 @@ export const getDefaultSlashMenuItems = (
             execute: (editor) => {
               const insertedBlock = insertOrUpdateBlock(editor, {
                 type: "image",
      -        } as PartialBlock);
      +        });
       
               // Immediately open the image toolbar
               editor._tiptapEditor.view.dispatch(
      diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
      index 18e5bd42f5..f600bbf4d4 100644
      --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
      +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
      @@ -1,16 +1,19 @@
      -import { Plugin, PluginKey } from "prosemirror-state";
      +import { Plugin, PluginKey, PluginView } from "prosemirror-state";
       import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
       import {
      +  Block,
      +  BlockFromConfigNoChildren,
         BlockNoteEditor,
      -  BlockSchema,
      -  getDraggableBlockFromCoords,
      +  BlockSchemaWithBlock,
      +  DefaultBlockSchema,
      +  InlineContentSchema,
         PartialBlock,
      +  SpecificBlock,
      +  StyleSchema,
      +  getDraggableBlockFromCoords,
      +  nodeToBlock,
       } from "../..";
       import { EventEmitter } from "../../shared/EventEmitter";
      -import { Block } from "../Blocks/api/blockTypes";
      -import { Table } from "../Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent";
      -import { nodeToBlock } from "../../api/nodeConversions/nodeConversions";
      -import { PluginView } from "@tiptap/pm/state";
       
       let dragImageElement: HTMLElement | undefined;
       
      @@ -32,12 +35,15 @@ function unsetHiddenDragImage() {
         }
       }
       
      -export type TableHandlesState = {
      +export type TableHandlesState<
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
         show: boolean;
         referencePosCell: DOMRect;
         referencePosTable: DOMRect;
       
      -  block: Block<(typeof Table)["config"]>;
      +  block: BlockFromConfigNoChildren;
         colIndex: number;
         rowIndex: number;
       
      @@ -76,10 +82,13 @@ function hideElementsWithClassNames(classNames: string[]) {
         });
       }
       
      -export class TableHandlesView
      -  implements PluginView
      +export class TableHandlesView<
      +  BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> implements PluginView
       {
      -  public state?: TableHandlesState;
      +  public state?: TableHandlesState;
         public updateState: () => void;
       
         public tableId: string | undefined;
      @@ -90,9 +99,9 @@ export class TableHandlesView
         public prevWasEditable: boolean | null = null;
       
         constructor(
      -    private readonly editor: BlockNoteEditor,
      +    private readonly editor: BlockNoteEditor,
           private readonly pmView: EditorView,
      -    updateState: (state: TableHandlesState) => void
      +    updateState: (state: TableHandlesState) => void
         ) {
           this.updateState = () => {
             if (!this.state) {
      @@ -149,7 +158,7 @@ export class TableHandlesView
             return;
           }
       
      -    let block: Block | undefined = undefined;
      +    let block: Block | undefined = undefined;
       
           // Copied from `getBlock`. We don't use `getBlock` since we also need the PM
           // node for the table, so we would effectively be doing the same work twice.
      @@ -162,7 +171,13 @@ export class TableHandlesView
               return true;
             }
       
      -      block = nodeToBlock(node, this.editor.schema, this.editor.blockCache);
      +      block = nodeToBlock(
      +        node,
      +        this.editor.blockSchema,
      +        this.editor.inlineContentSchema,
      +        this.editor.styleSchema,
      +        this.editor.blockCache
      +      );
             this.tablePos = pos + 1;
       
             return false;
      @@ -173,7 +188,7 @@ export class TableHandlesView
             referencePosCell: cellRect,
             referencePosTable: tableRect,
       
      -      block: block! as Block<(typeof Table)["config"]>,
      +      block: block! as SpecificBlock,
             colIndex: colIndex,
             rowIndex: rowIndex,
       
      @@ -310,7 +325,7 @@ export class TableHandlesView
               type: "tableContent",
               rows: rows,
             },
      -    } as PartialBlock);
      +    } as PartialBlock);
         };
       
         scrollHandler = () => {
      @@ -343,12 +358,14 @@ export class TableHandlesView
       export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin");
       
       export class TableHandlesProsemirrorPlugin<
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > extends EventEmitter {
      -  private view: TableHandlesView | undefined;
      +  private view: TableHandlesView | undefined;
         public readonly plugin: Plugin;
       
      -  constructor(private readonly editor: BlockNoteEditor) {
      +  constructor(private readonly editor: BlockNoteEditor) {
           super();
           this.plugin = new Plugin({
             key: tableHandlesPluginKey,
      @@ -489,7 +506,7 @@ export class TableHandlesProsemirrorPlugin<
           });
         }
       
      -  public onUpdate(callback: (state: TableHandlesState) => void) {
      +  public onUpdate(callback: (state: TableHandlesState) => void) {
           return this.on("update", callback);
         }
       
      diff --git a/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts b/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts
      index 6a99548918..7f9fb505ea 100644
      --- a/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts
      +++ b/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts
      @@ -1,15 +1,4 @@
       import { Extension } from "@tiptap/core";
      -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos";
      -
      -declare module "@tiptap/core" {
      -  interface Commands {
      -    textAlignment: {
      -      setTextAlignment: (
      -        textAlignment: "left" | "center" | "right" | "justify"
      -      ) => ReturnType;
      -    };
      -  }
      -}
       
       export const TextAlignmentExtension = Extension.create({
         name: "textAlignment",
      @@ -23,7 +12,9 @@ export const TextAlignmentExtension = Extension.create({
               attributes: {
                 textAlignment: {
                   default: "left",
      -            parseHTML: (element) => element.getAttribute("data-text-alignment"),
      +            parseHTML: (element) => {
      +              return element.getAttribute("data-text-alignment");
      +            },
                   renderHTML: (attributes) =>
                     attributes.textAlignment !== "left" && {
                       "data-text-alignment": attributes.textAlignment,
      @@ -33,43 +24,4 @@ export const TextAlignmentExtension = Extension.create({
             },
           ];
         },
      -
      -  addCommands() {
      -    return {
      -      setTextAlignment:
      -        (textAlignment) =>
      -        ({ state }) => {
      -          const positionsBeforeSelectedContent = [];
      -
      -          const blockInfo = getBlockInfoFromPos(
      -            state.doc,
      -            state.selection.from
      -          );
      -          if (blockInfo === undefined) {
      -            return false;
      -          }
      -
      -          // Finds all blockContent nodes that the current selection is in.
      -          let pos = blockInfo.startPos;
      -          while (pos < state.selection.to) {
      -            if (
      -              state.doc.resolve(pos).node().type.spec.group === "blockContent"
      -            ) {
      -              positionsBeforeSelectedContent.push(pos - 1);
      -
      -              pos += state.doc.resolve(pos).node().nodeSize - 1;
      -            } else {
      -              pos += 1;
      -            }
      -          }
      -
      -          // Sets text alignment for all blockContent nodes that the current selection is in.
      -          for (const pos of positionsBeforeSelectedContent) {
      -            state.tr.setNodeAttribute(pos, "textAlignment", textAlignment);
      -          }
      -
      -          return true;
      -        },
      -    };
      -  },
       });
      diff --git a/packages/core/src/extensions/TextColor/TextColorExtension.ts b/packages/core/src/extensions/TextColor/TextColorExtension.ts
      index a3ab7b8db8..09a5d894f4 100644
      --- a/packages/core/src/extensions/TextColor/TextColorExtension.ts
      +++ b/packages/core/src/extensions/TextColor/TextColorExtension.ts
      @@ -1,15 +1,6 @@
       import { Extension } from "@tiptap/core";
      -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos";
       import { defaultProps } from "../Blocks/api/defaultProps";
       
      -declare module "@tiptap/core" {
      -  interface Commands {
      -    blockTextColor: {
      -      setBlockTextColor: (posInBlock: number, color: string) => ReturnType;
      -    };
      -  }
      -}
      -
       export const TextColorExtension = Extension.create({
         name: "blockTextColor",
       
      @@ -33,23 +24,4 @@ export const TextColorExtension = Extension.create({
             },
           ];
         },
      -
      -  addCommands() {
      -    return {
      -      setBlockTextColor:
      -        (posInBlock, color) =>
      -        ({ state, view }) => {
      -          const blockInfo = getBlockInfoFromPos(state.doc, posInBlock);
      -          if (blockInfo === undefined) {
      -            return false;
      -          }
      -
      -          state.tr.setNodeAttribute(blockInfo.startPos - 1, "textColor", color);
      -
      -          view.focus();
      -
      -          return true;
      -        },
      -    };
      -  },
       });
      diff --git a/packages/core/src/extensions/TextColor/TextColorMark.ts b/packages/core/src/extensions/TextColor/TextColorMark.ts
      index ce8a0cb4ca..c18ab0b374 100644
      --- a/packages/core/src/extensions/TextColor/TextColorMark.ts
      +++ b/packages/core/src/extensions/TextColor/TextColorMark.ts
      @@ -1,24 +1,16 @@
       import { Mark } from "@tiptap/core";
      -import { defaultProps } from "../Blocks/api/defaultProps";
      +import { createStyleSpecFromTipTapMark } from "../Blocks/api/styles/internal";
       
      -declare module "@tiptap/core" {
      -  interface Commands {
      -    textColor: {
      -      setTextColor: (color: string) => ReturnType;
      -    };
      -  }
      -}
      -
      -export const TextColorMark = Mark.create({
      +const TextColorMark = Mark.create({
         name: "textColor",
       
         addAttributes() {
           return {
      -      color: {
      +      stringValue: {
               default: undefined,
               parseHTML: (element) => element.getAttribute("data-text-color"),
               renderHTML: (attributes) => ({
      -          "data-text-color": attributes.color,
      +          "data-text-color": attributes.stringValue,
               }),
             },
           };
      @@ -34,7 +26,7 @@ export const TextColorMark = Mark.create({
                 }
       
                 if (element.hasAttribute("data-text-color")) {
      -            return { color: element.getAttribute("data-text-color") };
      +            return { stringValue: element.getAttribute("data-text-color") };
                 }
       
                 return false;
      @@ -46,18 +38,6 @@ export const TextColorMark = Mark.create({
         renderHTML({ HTMLAttributes }) {
           return ["span", HTMLAttributes, 0];
         },
      -
      -  addCommands() {
      -    return {
      -      setTextColor:
      -        (color) =>
      -        ({ commands }) => {
      -          if (color !== defaultProps.textColor.default) {
      -            return commands.setMark(this.name, { color: color });
      -          }
      -
      -          return commands.unsetMark(this.name);
      -        },
      -    };
      -  },
       });
      +
      +export const TextColor = createStyleSpecFromTipTapMark(TextColorMark, "string");
      diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
      index e7849ebd06..41637442bb 100644
      --- a/packages/core/src/index.ts
      +++ b/packages/core/src/index.ts
      @@ -1,14 +1,20 @@
       export * from "./BlockNoteEditor";
       export * from "./BlockNoteExtensions";
      -export * from "./api/serialization/html/externalHTMLExporter";
      -export * from "./api/serialization/html/internalHTMLSerializer";
      -export * from "./extensions/Blocks/api/block";
      -export * from "./extensions/Blocks/api/blockTypes";
      -export * from "./extensions/Blocks/api/customBlocks";
      +export * from "./api/exporters/html/externalHTMLExporter";
      +export * from "./api/exporters/html/internalHTMLSerializer";
      +export * from "./api/testCases/index";
      +export * from "./extensions/Blocks/api/blocks/createSpec";
      +export * from "./extensions/Blocks/api/blocks/internal";
      +export * from "./extensions/Blocks/api/blocks/types";
       export * from "./extensions/Blocks/api/defaultBlocks";
       export * from "./extensions/Blocks/api/defaultProps";
      -export * from "./extensions/Blocks/api/inlineContentTypes";
      +export * from "./extensions/Blocks/api/inlineContent/createSpec";
      +export * from "./extensions/Blocks/api/inlineContent/internal";
      +export * from "./extensions/Blocks/api/inlineContent/types";
       export * from "./extensions/Blocks/api/selectionTypes";
      +export * from "./extensions/Blocks/api/styles/createSpec";
      +export * from "./extensions/Blocks/api/styles/internal";
      +export * from "./extensions/Blocks/api/styles/types";
       export * as blockStyles from "./extensions/Blocks/nodes/Block.css";
       export * from "./extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
       export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin";
      @@ -23,3 +29,7 @@ export * from "./shared/BaseUiElementTypes";
       export type { SuggestionItem } from "./shared/plugins/suggestion/SuggestionItem";
       export * from "./shared/plugins/suggestion/SuggestionPlugin";
       export * from "./shared/utils";
      +// for testing from react (TODO: move):
      +export * from "./api/nodeConversions/nodeConversions";
      +export * from "./api/nodeConversions/testUtil";
      +export * from "./extensions/UniqueID/UniqueID";
      diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts
      index 8cfdbfc841..480d935db3 100644
      --- a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts
      +++ b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts
      @@ -1,7 +1,9 @@
       import { EditorState, Plugin, PluginKey } from "prosemirror-state";
       import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
       import { BlockNoteEditor } from "../../../BlockNoteEditor";
      -import { BlockSchema } from "../../../extensions/Blocks/api/blockTypes";
      +import { BlockSchema } from "../../../extensions/Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../../../extensions/Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../../../extensions/Blocks/api/styles/types";
       import { findBlock } from "../../../extensions/Blocks/helpers/findBlock";
       import { BaseUiElementState } from "../../BaseUiElementTypes";
       import { SuggestionItem } from "./SuggestionItem";
      @@ -16,7 +18,9 @@ export type SuggestionsMenuState =
       
       class SuggestionsMenuView<
         T extends SuggestionItem,
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       > {
         private suggestionsMenuState?: SuggestionsMenuState;
         public updateSuggestionsMenu: () => void;
      @@ -24,7 +28,7 @@ class SuggestionsMenuView<
         pluginState: SuggestionPluginState;
       
         constructor(
      -    private readonly editor: BlockNoteEditor,
      +    private readonly editor: BlockNoteEditor,
           private readonly pluginKey: PluginKey,
           updateSuggestionsMenu: (
             suggestionsMenuState: SuggestionsMenuState
      @@ -147,9 +151,11 @@ function getDefaultPluginState<
        */
       export const setupSuggestionsMenu = <
         T extends SuggestionItem,
      -  BSchema extends BlockSchema
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
       >(
      -  editor: BlockNoteEditor,
      +  editor: BlockNoteEditor,
         updateSuggestionsMenu: (
           suggestionsMenuState: SuggestionsMenuState
         ) => void,
      @@ -159,7 +165,7 @@ export const setupSuggestionsMenu = <
         items: (query: string) => T[] = () => [],
         onSelectItem: (props: {
           item: T;
      -    editor: BlockNoteEditor;
      +    editor: BlockNoteEditor;
         }) => void = () => {
           // noop
         }
      @@ -169,7 +175,7 @@ export const setupSuggestionsMenu = <
           throw new Error("'char' should be a single character");
         }
       
      -  let suggestionsPluginView: SuggestionsMenuView;
      +  let suggestionsPluginView: SuggestionsMenuView;
       
         const deactivate = (view: EditorView) => {
           view.dispatch(view.state.tr.setMeta(pluginKey, { deactivate: true }));
      @@ -180,7 +186,7 @@ export const setupSuggestionsMenu = <
             key: pluginKey,
       
             view: () => {
      -        suggestionsPluginView = new SuggestionsMenuView(
      +        suggestionsPluginView = new SuggestionsMenuView(
                 editor,
                 pluginKey,
       
      diff --git a/packages/react/package.json b/packages/react/package.json
      index 2405e5568c..c2a2ee4fca 100644
      --- a/packages/react/package.json
      +++ b/packages/react/package.json
      @@ -45,7 +45,8 @@
           "build-bundled": "tsc && vite build --config vite.config.bundled.ts && git checkout tmp-releases && rm -rf ../../release && mv ../../release-tmp ../../release",
           "preview": "vite preview",
           "lint": "eslint src --max-warnings 0",
      -    "test": "vitest --run"
      +    "test": "vitest --run",
      +    "test:watch": "vitest --watch"
         },
         "dependencies": {
           "@blocknote/core": "^0.9.6",
      diff --git a/packages/react/src/BlockNoteView.tsx b/packages/react/src/BlockNoteView.tsx
      index 1dddab9f30..c65cbc9b26 100644
      --- a/packages/react/src/BlockNoteView.tsx
      +++ b/packages/react/src/BlockNoteView.tsx
      @@ -1,4 +1,10 @@
      -import { BlockNoteEditor, BlockSchema, mergeCSSClasses } from "@blocknote/core";
      +import {
      +  BlockNoteEditor,
      +  BlockSchema,
      +  InlineContentSchema,
      +  StyleSchema,
      +  mergeCSSClasses,
      +} from "@blocknote/core";
       import { MantineProvider, createStyles } from "@mantine/core";
       import { EditorContent } from "@tiptap/react";
       import { HTMLAttributes, ReactNode, useMemo } from "react";
      @@ -13,9 +19,13 @@ import { TableHandlesPositioner } from "./TableHandles/components/TableHandlePos
       import { darkDefaultTheme, lightDefaultTheme } from "./defaultThemes";
       
       // Renders the editor as well as all menus & toolbars using default styles.
      -function BaseBlockNoteView(
      +function BaseBlockNoteView<
      +  BSchema extends BlockSchema,
      +  ISchema extends InlineContentSchema,
      +  SSchema extends StyleSchema
      +>(
         props: {
      -    editor: BlockNoteEditor;
      +    editor: BlockNoteEditor;
           children?: ReactNode;
         } & HTMLAttributes
       ) {
      @@ -37,16 +47,22 @@ function BaseBlockNoteView(
                 
                 
                 
      -          
      +          {props.editor.blockSchema.table && (
      +            
      +          )}
               
             )}
           
         );
       }
       
      -export function BlockNoteView(
      +export function BlockNoteView<
      +  BSchema extends BlockSchema,
      +  ISchema extends InlineContentSchema,
      +  SSchema extends StyleSchema
      +>(
         props: {
      -    editor: BlockNoteEditor;
      +    editor: BlockNoteEditor;
           theme?:
             | "light"
             | "dark"
      diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx
      index 1af9e4de01..65c5c068a8 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx
      @@ -1,16 +1,25 @@
      -import { useCallback, useMemo, useState } from "react";
      +import {
      +  BlockNoteEditor,
      +  BlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +} from "@blocknote/core";
       import { Menu } from "@mantine/core";
      -import { BlockNoteEditor, BlockSchema } from "@blocknote/core";
      +import { useCallback, useMemo, useState } from "react";
       
      -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton";
       import { ColorIcon } from "../../../SharedComponents/ColorPicker/components/ColorIcon";
       import { ColorPicker } from "../../../SharedComponents/ColorPicker/components/ColorPicker";
      -import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks";
      +import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton";
       import { useEditorChange } from "../../../hooks/useEditorChange";
       import { usePreventMenuOverflow } from "../../../hooks/usePreventMenuOverflow";
      +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks";
       
       export const ColorStyleButton = (props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor<
      +    BSchema,
      +    DefaultInlineContentSchema,
      +    DefaultStyleSchema
      +  >;
       }) => {
         const selectedBlocks = useSelectedBlocks(props.editor);
       
      diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx
      index aabe94e609..cf2dd29e2b 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx
      @@ -16,7 +16,7 @@ import { ToolbarInputDropdownItem } from "../../../SharedComponents/Toolbar/comp
       import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks";
       
       export const ImageCaptionButton = (props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
       }) => {
         const selectedBlocks = useSelectedBlocks(props.editor);
       
      @@ -28,19 +28,19 @@ export const ImageCaptionButton = (props: {
             selectedBlocks[0].type === "image" &&
             // Checks if the block has a `caption` prop which can take any string
             // value.
      -      "caption" in props.editor.schema["image"].config.propSchema &&
      -      typeof props.editor.schema["image"].config.propSchema.caption.default ===
      +      "caption" in props.editor.blockSchema["image"].propSchema &&
      +      typeof props.editor.blockSchema["image"].propSchema.caption.default ===
               "string" &&
      -      props.editor.schema["image"].config.propSchema.caption.values ===
      +      props.editor.blockSchema["image"].propSchema.caption.values ===
               undefined &&
             // Checks if the block has a `url` prop which can take any string value.
      -      "url" in props.editor.schema["image"].config.propSchema &&
      -      typeof props.editor.schema["image"].config.propSchema.url.default ===
      +      "url" in props.editor.blockSchema["image"].propSchema &&
      +      typeof props.editor.blockSchema["image"].propSchema.url.default ===
               "string" &&
      -      props.editor.schema["image"].config.propSchema.url.values === undefined &&
      +      props.editor.blockSchema["image"].propSchema.url.values === undefined &&
             // Checks if the `url` prop is not set to an empty string.
             selectedBlocks[0].props.url !== "",
      -    [props.editor.schema, selectedBlocks]
      +    [props.editor.blockSchema, selectedBlocks]
         );
       
         const [currentCaption, setCurrentCaption] = useState(
      @@ -64,7 +64,7 @@ export const ImageCaptionButton = (props: {
                 props: {
                   caption: currentCaption,
                 },
      -        } as PartialBlock);
      +        } as PartialBlock);
             }
           },
           [currentCaption, props.editor, selectedBlocks]
      diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx
      index cf89ecef97..c56c8ecc30 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx
      @@ -26,7 +26,7 @@ const icons: Record = {
       };
       
       export const TextAlignButton = (props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
         textAlignment: TextAlignment;
       }) => {
         const selectedBlocks = useSelectedBlocks(props.editor);
      @@ -48,7 +48,7 @@ export const TextAlignButton = (props: {
             for (const block of selectedBlocks) {
               props.editor.updateBlock(block, {
                 props: { textAlignment: textAlignment },
      -        } as PartialBlock);
      +        } as PartialBlock);
             }
           },
           [props.editor, selectedBlocks]
      diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx
      index c71824e873..95895d32db 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx
      @@ -1,6 +1,9 @@
      -import { BlockNoteEditor, BlockSchema, ToggledStyle } from "@blocknote/core";
      +import {
      +  BlockNoteEditor,
      +  BlockSchema,
      +  InlineContentSchema,
      +} from "@blocknote/core";
       import { useMemo, useState } from "react";
      -import { IconType } from "react-icons";
       import {
         RiBold,
         RiCodeFill,
      @@ -9,12 +12,13 @@ import {
         RiUnderline,
       } from "react-icons/ri";
       
      +import { StyleSchema } from "@blocknote/core";
       import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton";
       import { useEditorChange } from "../../../hooks/useEditorChange";
       import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks";
       import { formatKeyboardShortcut } from "../../../utils";
       
      -const shortcuts: Record = {
      +const shortcuts = {
         bold: "Mod+B",
         italic: "Mod+I",
         underline: "Mod+U",
      @@ -22,7 +26,7 @@ const shortcuts: Record = {
         code: "",
       };
       
      -const icons: Record = {
      +const icons = {
         bold: RiBold,
         italic: RiItalic,
         underline: RiUnderline,
      @@ -30,9 +34,13 @@ const icons: Record = {
         code: RiCodeFill,
       };
       
      -export const ToggledStyleButton = (props: {
      -  editor: BlockNoteEditor;
      -  toggledStyle: ToggledStyle;
      +export const ToggledStyleButton = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(props: {
      +  editor: BlockNoteEditor;
      +  toggledStyle: keyof typeof shortcuts;
       }) => {
         const selectedBlocks = useSelectedBlocks(props.editor);
       
      @@ -44,9 +52,12 @@ export const ToggledStyleButton = (props: {
           setActive(props.toggledStyle in props.editor.getActiveStyles());
         });
       
      -  const toggleStyle = (style: ToggledStyle) => {
      +  const toggleStyle = (style: typeof props.toggledStyle) => {
           props.editor.focus();
      -    props.editor.toggleStyles({ [style]: true });
      +    if (props.editor.styleSchema[style].propSchema !== "boolean") {
      +      throw new Error("can only toggle boolean styles");
      +    }
      +    props.editor.toggleStyles({ [style]: true } as any);
         };
       
         const show = useMemo(() => {
      diff --git a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx
      index b3f1f621f6..4d15de5be6 100644
      --- a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx
      +++ b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx
      @@ -20,7 +20,7 @@ export type BlockTypeDropdownItem = {
         type: string;
         props?: Record;
         icon: IconType;
      -  isSelected: (block: Block) => boolean;
      +  isSelected: (block: Block) => boolean;
       };
       
       export const defaultBlockTypeDropdownItems: BlockTypeDropdownItem[] = [
      @@ -87,13 +87,13 @@ export const BlockTypeDropdown = (props: {
         const filteredItems: BlockTypeDropdownItem[] = useMemo(() => {
           return (props.items || defaultBlockTypeDropdownItems).filter((item) => {
             // Checks if block type exists in the schema
      -      if (!(item.type in props.editor.schema)) {
      +      if (!(item.type in props.editor.blockSchema)) {
               return false;
             }
       
             // Checks if props for the block type are valid
             for (const [prop, value] of Object.entries(item.props || {})) {
      -        const propSchema = props.editor.schema[item.type].config.propSchema;
      +        const propSchema = props.editor.blockSchema[item.type].propSchema;
       
               // Checks if the prop exists for the block type
               if (!(prop in propSchema)) {
      @@ -134,7 +134,7 @@ export const BlockTypeDropdown = (props: {
             text: item.name,
             icon: item.icon,
             onClick: () => onClick(item),
      -      isSelected: item.isSelected(block as Block),
      +      isSelected: item.isSelected(block as Block),
           }));
         }, [block, filteredItems, props.editor, selectedBlocks]);
       
      diff --git a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx
      index 891c5fc0d5..9441f5e5fa 100644
      --- a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx
      +++ b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx
      @@ -8,8 +8,8 @@ import Tippy, { tippy } from "@tippyjs/react";
       import { FC, useEffect, useMemo, useRef, useState } from "react";
       import { sticky } from "tippy.js";
       
      -import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar";
       import { useEditorChange } from "../../hooks/useEditorChange";
      +import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar";
       
       const textAlignmentToPlacement = (
         textAlignment: DefaultProps["textAlignment"]
      @@ -29,13 +29,13 @@ const textAlignmentToPlacement = (
       export type FormattingToolbarProps<
         BSchema extends BlockSchema = DefaultBlockSchema
       > = {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
       };
       
       export const FormattingToolbarPositioner = <
         BSchema extends BlockSchema = DefaultBlockSchema
       >(props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
         formattingToolbar?: FC>;
       }) => {
         const [show, setShow] = useState(false);
      diff --git a/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx b/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx
      index dc4a1a5f5f..269de59419 100644
      --- a/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx
      +++ b/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx
      @@ -1,14 +1,19 @@
      +import { BlockSchema, InlineContentSchema } from "@blocknote/core";
       import { useRef, useState } from "react";
       import { RiExternalLinkFill, RiLinkUnlink } from "react-icons/ri";
      -import { BlockSchema } from "@blocknote/core";
       
      -import { HyperlinkToolbarProps } from "./HyperlinkToolbarPositioner";
      +import { StyleSchema } from "@blocknote/core";
       import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar";
       import { ToolbarButton } from "../../SharedComponents/Toolbar/components/ToolbarButton";
       import { EditHyperlinkMenu } from "./EditHyperlinkMenu/components/EditHyperlinkMenu";
      +import { HyperlinkToolbarProps } from "./HyperlinkToolbarPositioner";
       
      -export const DefaultHyperlinkToolbar = (
      -  props: HyperlinkToolbarProps
      +export const DefaultHyperlinkToolbar = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  props: HyperlinkToolbarProps
       ) => {
         const [isEditing, setIsEditing] = useState(false);
         const editMenuRef = useRef(null);
      diff --git a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx
      index 66b76706ce..6890e5df61 100644
      --- a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx
      +++ b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx
      @@ -3,25 +3,35 @@ import {
         BlockNoteEditor,
         BlockSchema,
         DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
         HyperlinkToolbarProsemirrorPlugin,
         HyperlinkToolbarState,
      +  InlineContentSchema,
       } from "@blocknote/core";
       import Tippy from "@tippyjs/react";
       import { FC, useEffect, useMemo, useRef, useState } from "react";
       
      +import { StyleSchema } from "@blocknote/core";
       import { DefaultHyperlinkToolbar } from "./DefaultHyperlinkToolbar";
       
      -export type HyperlinkToolbarProps = Pick<
      -  HyperlinkToolbarProsemirrorPlugin,
      +export type HyperlinkToolbarProps<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = Pick<
      +  HyperlinkToolbarProsemirrorPlugin,
         "editHyperlink" | "deleteHyperlink" | "startHideTimer" | "stopHideTimer"
       > &
         Omit;
       
       export const HyperlinkToolbarPositioner = <
      -  BSchema extends BlockSchema = DefaultBlockSchema
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema,
      +  S extends StyleSchema = DefaultStyleSchema
       >(props: {
      -  editor: BlockNoteEditor;
      -  hyperlinkToolbar?: FC>;
      +  editor: BlockNoteEditor;
      +  hyperlinkToolbar?: FC>;
       }) => {
         const [show, setShow] = useState(false);
         const [url, setUrl] = useState();
      diff --git a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx b/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx
      index d04226f116..49260e289a 100644
      --- a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx
      +++ b/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx
      @@ -1,14 +1,5 @@
       import { BlockSchema, PartialBlock } from "@blocknote/core";
       
      -import { ImageToolbarProps } from "./ImageToolbarPositioner";
      -import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar";
      -import {
      -  ChangeEvent,
      -  KeyboardEvent,
      -  useCallback,
      -  useEffect,
      -  useState,
      -} from "react";
       import {
         Button,
         FileInput,
      @@ -17,9 +8,18 @@ import {
         Text,
         TextInput,
       } from "@mantine/core";
      +import {
      +  ChangeEvent,
      +  KeyboardEvent,
      +  useCallback,
      +  useEffect,
      +  useState,
      +} from "react";
      +import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar";
      +import { ImageToolbarProps } from "./ImageToolbarPositioner";
       
       export const DefaultImageToolbar = (
      -  props: ImageToolbarProps
      +  props: ImageToolbarProps
       ) => {
         const [openTab, setOpenTab] = useState<"upload" | "embed">(
           props.editor.uploadFile !== undefined ? "upload" : "embed"
      @@ -46,7 +46,7 @@ export const DefaultImageToolbar = (
                   props: {
                     url: uploaded,
                   },
      -          } as PartialBlock);
      +          } as PartialBlock);
               } catch (e) {
                 setUploadFailed(true);
               } finally {
      @@ -75,7 +75,7 @@ export const DefaultImageToolbar = (
                 props: {
                   url: currentURL,
                 },
      -        } as PartialBlock);
      +        } as PartialBlock);
             }
           },
           [currentURL, props.block, props.editor]
      @@ -87,7 +87,7 @@ export const DefaultImageToolbar = (
             props: {
               url: currentURL,
             },
      -    } as PartialBlock);
      +    } as PartialBlock);
         }, [currentURL, props.block, props.editor]);
       
         return (
      diff --git a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx
      index ccc677759f..7bcfdd8615 100644
      --- a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx
      +++ b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx
      @@ -1,10 +1,12 @@
       import {
         BaseUiElementState,
      -  Block,
         BlockNoteEditor,
         BlockSchema,
         DefaultBlockSchema,
      +  DefaultInlineContentSchema,
         ImageToolbarState,
      +  InlineContentSchema,
      +  SpecificBlock,
       } from "@blocknote/core";
       import Tippy, { tippy } from "@tippyjs/react";
       import { FC, useEffect, useMemo, useRef, useState } from "react";
      @@ -12,20 +14,21 @@ import { FC, useEffect, useMemo, useRef, useState } from "react";
       import { DefaultImageToolbar } from "./DefaultImageToolbar";
       
       export type ImageToolbarProps<
      -  BSchema extends BlockSchema = DefaultBlockSchema
      -> = Omit & {
      -  editor: BlockNoteEditor;
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema
      +> = Omit, keyof BaseUiElementState> & {
      +  editor: BlockNoteEditor;
       };
       
       export const ImageToolbarPositioner = <
      -  BSchema extends BlockSchema = DefaultBlockSchema
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema
       >(props: {
      -  editor: BlockNoteEditor;
      -  imageToolbar?: FC>;
      +  editor: BlockNoteEditor;
      +  imageToolbar?: FC>;
       }) => {
         const [show, setShow] = useState(false);
      -  const [block, setBlock] =
      -    useState>();
      +  const [block, setBlock] = useState>();
       
         const referencePos = useRef();
       
      diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx
      index ea992828af..b5f48dc2df 100644
      --- a/packages/react/src/ReactBlockSpec.tsx
      +++ b/packages/react/src/ReactBlockSpec.tsx
      @@ -1,6 +1,5 @@
       import {
      -  Block,
      -  BlockNoteDOMAttributes,
      +  BlockFromConfig,
         BlockNoteEditor,
         BlockSchemaWithBlock,
         camelToDataKebab,
      @@ -8,12 +7,15 @@ import {
         createStronglyTypedTiptapNode,
         CustomBlockConfig,
         getBlockFromPos,
      +  getParseRules,
         inheritedProps,
      +  InlineContentSchema,
         mergeCSSClasses,
      -  parse,
      +  PartialBlockFromConfig,
         Props,
         PropSchema,
         propsToAttributes,
      +  StyleSchema,
       } from "@blocknote/core";
       import {
         NodeViewContent,
      @@ -21,48 +23,28 @@ import {
         NodeViewWrapper,
         ReactNodeViewRenderer,
       } from "@tiptap/react";
      -import { createContext, ElementType, FC, HTMLProps, useContext } from "react";
      -import { renderToString } from "react-dom/server";
      +import { FC } from "react";
      +import { renderToDOMSpec } from "./ReactRenderUtil";
       
       // this file is mostly analogoues to `customBlocks.ts`, but for React blocks
       
       // extend BlockConfig but use a React render function
      -export type ReactCustomBlockImplementation = {
      +export type ReactCustomBlockImplementation<
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
         render: FC<{
      -    block: Block;
      -    editor: BlockNoteEditor>;
      +    block: BlockFromConfig;
      +    editor: BlockNoteEditor, I, S>;
      +    contentRef: (node: HTMLElement | null) => void;
         }>;
         toExternalHTML?: FC<{
      -    block: Block;
      -    editor: BlockNoteEditor>;
      +    block: BlockFromConfig;
      +    editor: BlockNoteEditor, I, S>;
      +    contentRef: (node: HTMLElement | null) => void;
         }>;
      -};
      -
      -const BlockNoteDOMAttributesContext = createContext({});
      -
      -export const InlineContent = (
      -  props: { as?: Tag } & HTMLProps
      -) => {
      -  const inlineContentDOMAttributes =
      -    useContext(BlockNoteDOMAttributesContext).inlineContent || {};
      -
      -  const classNames = mergeCSSClasses(
      -    props.className || "",
      -    "bn-inline-content",
      -    inlineContentDOMAttributes.class
      -  );
      -
      -  return (
      -     key !== "class"
      -        )
      -      )}
      -      {...props}
      -      className={classNames}
      -    />
      -  );
      +  parse?: (el: HTMLElement) => PartialBlockFromConfig | undefined;
       };
       
       // Function that wraps the React component returned from 'blockConfig.render' in
      @@ -114,9 +96,13 @@ export function reactWrapInBlockStructure<
       
       // A function to create custom block for API consumers
       // we want to hide the tiptap node from API consumers and provide a simpler API surface instead
      -export function createReactBlockSpec(
      +export function createReactBlockSpec<
      +  T extends CustomBlockConfig,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
         blockConfig: T,
      -  blockImplementation: ReactCustomBlockImplementation
      +  blockImplementation: ReactCustomBlockImplementation
       ) {
         const node = createStronglyTypedTiptapNode({
           name: blockConfig.type as T["type"],
      @@ -127,11 +113,11 @@ export function createReactBlockSpec(
           selectable: true,
       
           addAttributes() {
      -      return propsToAttributes(blockConfig);
      +      return propsToAttributes(blockConfig.propSchema);
           },
       
           parseHTML() {
      -      return parse(blockConfig);
      +      return getParseRules(blockConfig, blockImplementation.parse);
           },
       
           addNodeView() {
      @@ -151,9 +137,12 @@ export function createReactBlockSpec(
                   const blockContentDOMAttributes =
                     this.options.domAttributes?.blockContent || {};
       
      +            // hacky, should export `useReactNodeView` from tiptap to get access to ref
      +            const ref = (NodeViewContent({}) as any).ref;
      +
                   const Content = blockImplementation.render;
                   const BlockContent = reactWrapInBlockStructure(
      -              ,
      +              ,
                     block.type,
                     block.props,
                     blockConfig.propSchema,
      @@ -176,47 +165,43 @@ export function createReactBlockSpec(
               node.options.domAttributes?.blockContent || {};
       
             const Content = blockImplementation.render;
      -      const BlockContent = reactWrapInBlockStructure(
      -        ,
      -        block.type,
      -        block.props,
      -        blockConfig.propSchema,
      -        blockContentDOMAttributes
      -      );
       
      -      const parent = document.createElement("div");
      -      parent.innerHTML = renderToString();
      -
      -      return {
      -        dom: parent.firstElementChild! as HTMLElement,
      -        contentDOM: (parent.querySelector(".bn-inline-content") ||
      -          undefined) as HTMLElement | undefined,
      -      };
      +      return renderToDOMSpec((refCB) => {
      +        const BlockContent = reactWrapInBlockStructure(
      +          ,
      +          block.type,
      +          block.props,
      +          blockConfig.propSchema,
      +          blockContentDOMAttributes
      +        );
      +        return ;
      +      });
           },
           toExternalHTML: (block, editor) => {
             const blockContentDOMAttributes =
               node.options.domAttributes?.blockContent || {};
       
      -      let Content = blockImplementation.toExternalHTML;
      -      if (Content === undefined) {
      -        Content = blockImplementation.render;
      -      }
      -      const BlockContent = reactWrapInBlockStructure(
      -        ,
      -        block.type,
      -        block.props,
      -        blockConfig.propSchema,
      -        blockContentDOMAttributes
      -      );
      -
      -      const parent = document.createElement("div");
      -      parent.innerHTML = renderToString();
      -
      -      return {
      -        dom: parent.firstElementChild! as HTMLElement,
      -        contentDOM: (parent.querySelector(".bn-inline-content") ||
      -          undefined) as HTMLElement | undefined,
      -      };
      +      const Content =
      +        blockImplementation.toExternalHTML || blockImplementation.render;
      +
      +      return renderToDOMSpec((refCB) => {
      +        const BlockContent = reactWrapInBlockStructure(
      +          ,
      +          block.type,
      +          block.props,
      +          blockConfig.propSchema,
      +          blockContentDOMAttributes
      +        );
      +        return ;
      +      });
           },
         });
       }
      diff --git a/packages/react/src/ReactInlineContentSpec.tsx b/packages/react/src/ReactInlineContentSpec.tsx
      new file mode 100644
      index 0000000000..6d598990e0
      --- /dev/null
      +++ b/packages/react/src/ReactInlineContentSpec.tsx
      @@ -0,0 +1,171 @@
      +import {
      +  CustomInlineContentConfig,
      +  InlineContentConfig,
      +  InlineContentFromConfig,
      +  PropSchema,
      +  Props,
      +  StyleSchema,
      +  addInlineContentAttributes,
      +  camelToDataKebab,
      +  createInternalInlineContentSpec,
      +  createStronglyTypedTiptapNode,
      +  getInlineContentParseRules,
      +  nodeToCustomInlineContent,
      +  propsToAttributes,
      +} from "@blocknote/core";
      +import {
      +  NodeViewContent,
      +  NodeViewProps,
      +  NodeViewWrapper,
      +  ReactNodeViewRenderer,
      +} from "@tiptap/react";
      +// import { useReactNodeView } from "@tiptap/react/dist/packages/react/src/useReactNodeView";
      +import { FC } from "react";
      +import { renderToDOMSpec } from "./ReactRenderUtil";
      +
      +// this file is mostly analogoues to `customBlocks.ts`, but for React blocks
      +
      +// extend BlockConfig but use a React render function
      +export type ReactInlineContentImplementation<
      +  T extends InlineContentConfig,
      +  // I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  render: FC<{
      +    inlineContent: InlineContentFromConfig;
      +    contentRef: (node: HTMLElement | null) => void;
      +  }>;
      +  // TODO?
      +  // toExternalHTML?: FC<{
      +  //   block: BlockFromConfig;
      +  //   editor: BlockNoteEditor, I, S>;
      +  // }>;
      +};
      +
      +// Function that adds a wrapper with necessary classes and attributes to the
      +// component returned from a custom inline content's 'render' function, to
      +// ensure no data is lost on internal copy & paste.
      +export function reactWrapInInlineContentStructure<
      +  IType extends string,
      +  PSchema extends PropSchema
      +>(
      +  element: JSX.Element,
      +  inlineContentType: IType,
      +  inlineContentProps: Props,
      +  propSchema: PSchema
      +) {
      +  return () => (
      +    // Creates inline content section element
      +     value !== propSchema[prop].default)
      +          .map(([prop, value]) => {
      +            return [camelToDataKebab(prop), value];
      +          })
      +      )}>
      +      {element}
      +    
      +  );
      +}
      +
      +// A function to create custom block for API consumers
      +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
      +export function createReactInlineContentSpec<
      +  T extends CustomInlineContentConfig,
      +  // I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  inlineContentConfig: T,
      +  inlineContentImplementation: ReactInlineContentImplementation
      +) {
      +  const node = createStronglyTypedTiptapNode({
      +    name: inlineContentConfig.type as T["type"],
      +    inline: true,
      +    group: "inline",
      +    selectable: inlineContentConfig.content === "styled",
      +    atom: inlineContentConfig.content === "none",
      +    content: (inlineContentConfig.content === "styled"
      +      ? "inline*"
      +      : "") as T["content"] extends "styled" ? "inline*" : "",
      +
      +    addAttributes() {
      +      return propsToAttributes(inlineContentConfig.propSchema);
      +    },
      +
      +    parseHTML() {
      +      return getInlineContentParseRules(inlineContentConfig);
      +    },
      +
      +    renderHTML({ node }) {
      +      const editor = this.options.editor;
      +
      +      const ic = nodeToCustomInlineContent(
      +        node,
      +        editor.inlineContentSchema,
      +        editor.styleSchema
      +      ) as any as InlineContentFromConfig; // TODO: fix cast
      +      const Content = inlineContentImplementation.render;
      +      const output = renderToDOMSpec((refCB) => (
      +        
      +      ));
      +
      +      return {
      +        dom: addInlineContentAttributes(
      +          output.dom,
      +          inlineContentConfig.type,
      +          node.attrs as Props,
      +          inlineContentConfig.propSchema
      +        ),
      +        contentDOM: output.contentDOM,
      +      };
      +    },
      +
      +    // TODO: needed?
      +    addNodeView() {
      +      const editor = this.options.editor;
      +
      +      return (props) =>
      +        ReactNodeViewRenderer(
      +          (props: NodeViewProps) => {
      +            // hacky, should export `useReactNodeView` from tiptap to get access to ref
      +            const ref = (NodeViewContent({}) as any).ref;
      +
      +            const Content = inlineContentImplementation.render;
      +            const FullContent = reactWrapInInlineContentStructure(
      +               // TODO: fix cast
      +                }
      +              />,
      +              inlineContentConfig.type,
      +              props.node.attrs as Props,
      +              inlineContentConfig.propSchema
      +            );
      +            return ;
      +          },
      +          {
      +            className: "bn-ic-react-node-view-renderer",
      +            as: "span",
      +            // contentDOMElementTag: "span", (requires tt upgrade)
      +          }
      +        )(props);
      +    },
      +  });
      +
      +  return createInternalInlineContentSpec(inlineContentConfig, {
      +    node: node,
      +  } as any);
      +}
      diff --git a/packages/react/src/ReactRenderUtil.ts b/packages/react/src/ReactRenderUtil.ts
      new file mode 100644
      index 0000000000..36262e9392
      --- /dev/null
      +++ b/packages/react/src/ReactRenderUtil.ts
      @@ -0,0 +1,37 @@
      +import { flushSync } from "react-dom";
      +import { createRoot } from "react-dom/client";
      +
      +export function renderToDOMSpec(
      +  fc: (refCB: (ref: HTMLElement | null) => void) => React.ReactNode
      +) {
      +  let contentDOM: HTMLElement | undefined;
      +  const div = document.createElement("div");
      +  const root = createRoot(div);
      +  flushSync(() => {
      +    root.render(fc((el) => (contentDOM = el || undefined)));
      +  });
      +
      +  if (!div.childElementCount) {
      +    // TODO
      +    console.warn("ReactInlineContentSpec: renderHTML() failed");
      +    return {
      +      dom: document.createElement("span"),
      +    };
      +  }
      +
      +  // clone so we can unmount the react root
      +  contentDOM?.setAttribute("data-tmp-find", "true");
      +  const cloneRoot = div.cloneNode(true) as HTMLElement;
      +  const dom = cloneRoot.firstElementChild! as HTMLElement;
      +  const contentDOMClone = cloneRoot.querySelector(
      +    "[data-tmp-find]"
      +  ) as HTMLElement | null;
      +  contentDOMClone?.removeAttribute("data-tmp-find");
      +
      +  root.unmount();
      +
      +  return {
      +    dom,
      +    contentDOM: contentDOMClone || undefined,
      +  };
      +}
      diff --git a/packages/react/src/ReactStyleSpec.tsx b/packages/react/src/ReactStyleSpec.tsx
      new file mode 100644
      index 0000000000..cb401850b7
      --- /dev/null
      +++ b/packages/react/src/ReactStyleSpec.tsx
      @@ -0,0 +1,65 @@
      +import {
      +  addStyleAttributes,
      +  createInternalStyleSpec,
      +  getStyleParseRules,
      +  StyleConfig,
      +  stylePropsToAttributes,
      +} from "@blocknote/core";
      +import { Mark } from "@tiptap/react";
      +import { FC } from "react";
      +import { renderToDOMSpec } from "./ReactRenderUtil";
      +
      +// this file is mostly analogoues to `customBlocks.ts`, but for React blocks
      +
      +// extend BlockConfig but use a React render function
      +export type ReactCustomStyleImplementation = {
      +  render: T["propSchema"] extends "boolean"
      +    ? FC<{ contentRef: (el: HTMLElement | null) => void }>
      +    : FC<{ contentRef: (el: HTMLElement | null) => void; value: string }>;
      +};
      +
      +// A function to create custom block for API consumers
      +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
      +export function createReactStyleSpec(
      +  styleConfig: T,
      +  styleImplementation: ReactCustomStyleImplementation
      +) {
      +  const mark = Mark.create({
      +    name: styleConfig.type,
      +
      +    addAttributes() {
      +      return stylePropsToAttributes(styleConfig.propSchema);
      +    },
      +
      +    parseHTML() {
      +      return getStyleParseRules(styleConfig);
      +    },
      +
      +    renderHTML({ mark }) {
      +      const props: any = {};
      +
      +      if (styleConfig.propSchema === "string") {
      +        props.value = mark.attrs.stringValue;
      +      }
      +
      +      const Content = styleImplementation.render;
      +      const renderResult = renderToDOMSpec((refCB) => (
      +        
      +      ));
      +
      +      return {
      +        dom: addStyleAttributes(
      +          renderResult.dom,
      +          styleConfig.type,
      +          mark.attrs.stringValue,
      +          styleConfig.propSchema
      +        ),
      +        contentDOM: renderResult.contentDOM,
      +      };
      +    },
      +  });
      +
      +  return createInternalStyleSpec(styleConfig, {
      +    mark,
      +  });
      +}
      diff --git a/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx b/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx
      index aa4985d700..0dadb3aac2 100644
      --- a/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx
      +++ b/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx
      @@ -1,10 +1,10 @@
      +import { BlockSchema } from "@blocknote/core";
       import { AiOutlinePlus } from "react-icons/ai";
       import { SideMenuButton } from "../SideMenuButton";
       import { SideMenuProps } from "../SideMenuPositioner";
      -import { BlockSchema } from "@blocknote/core";
       
       export const AddBlockButton = (
      -  props: SideMenuProps
      +  props: SideMenuProps
       ) => (
         
           (
      -  props: SideMenuProps
      +  props: SideMenuProps
       ) => {
         const DragHandleMenu = props.dragHandleMenu || DefaultDragHandleMenu;
       
      diff --git a/packages/react/src/SideMenu/components/DefaultSideMenu.tsx b/packages/react/src/SideMenu/components/DefaultSideMenu.tsx
      index 46623a9c12..1ae3fcb99e 100644
      --- a/packages/react/src/SideMenu/components/DefaultSideMenu.tsx
      +++ b/packages/react/src/SideMenu/components/DefaultSideMenu.tsx
      @@ -1,12 +1,17 @@
      -import { BlockSchema } from "@blocknote/core";
      +import { BlockSchema, InlineContentSchema } from "@blocknote/core";
       
      -import { SideMenuProps } from "./SideMenuPositioner";
      -import { SideMenu } from "./SideMenu";
      +import { StyleSchema } from "@blocknote/core";
       import { AddBlockButton } from "./DefaultButtons/AddBlockButton";
       import { DragHandle } from "./DefaultButtons/DragHandle";
      +import { SideMenu } from "./SideMenu";
      +import { SideMenuProps } from "./SideMenuPositioner";
       
      -export const DefaultSideMenu = (
      -  props: SideMenuProps
      +export const DefaultSideMenu = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  props: SideMenuProps
       ) => (
         
           
      diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx
      index f8ea41fa56..e198ebf46e 100644
      --- a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx
      +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx
      @@ -1,21 +1,21 @@
      -import { ReactNode, useCallback, useRef, useState } from "react";
      +import { BlockSchema, PartialBlock } from "@blocknote/core";
       import { Box, Menu } from "@mantine/core";
      +import { ReactNode, useCallback, useRef, useState } from "react";
       import { HiChevronRight } from "react-icons/hi";
      -import { BlockSchema, PartialBlock } from "@blocknote/core";
       
      -import { DragHandleMenuProps } from "../DragHandleMenu";
      -import { DragHandleMenuItem } from "../DragHandleMenuItem";
       import { ColorPicker } from "../../../../SharedComponents/ColorPicker/components/ColorPicker";
       import { usePreventMenuOverflow } from "../../../../hooks/usePreventMenuOverflow";
      +import { DragHandleMenuProps } from "../DragHandleMenu";
      +import { DragHandleMenuItem } from "../DragHandleMenuItem";
       
       export const BlockColorsButton = (
      -  props: DragHandleMenuProps & { children: ReactNode }
      +  props: DragHandleMenuProps & { children: ReactNode }
       ) => {
         const [opened, setOpened] = useState(false);
       
         const { ref, updateMaxHeight } = usePreventMenuOverflow();
       
      -  const menuCloseTimer = useRef();
      +  const menuCloseTimer = useRef | undefined>();
       
         const startMenuCloseTimer = useCallback(() => {
           if (menuCloseTimer.current) {
      @@ -73,7 +73,7 @@ export const BlockColorsButton = (
                             setColor: (color) =>
                               props.editor.updateBlock(props.block, {
                                 props: { textColor: color },
      -                        } as PartialBlock),
      +                        } as PartialBlock),
                           }
                         : undefined
                     }
      @@ -85,7 +85,7 @@ export const BlockColorsButton = (
                             setColor: (color) =>
                               props.editor.updateBlock(props.block, {
                                 props: { backgroundColor: color },
      -                        } as PartialBlock),
      +                        } as PartialBlock),
                           }
                         : undefined
                     }
      diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx
      index bbd5e2331c..1b05fff510 100644
      --- a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx
      +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx
      @@ -1,11 +1,11 @@
      -import { ReactNode } from "react";
       import { BlockSchema } from "@blocknote/core";
      +import { ReactNode } from "react";
       
       import { DragHandleMenuProps } from "../DragHandleMenu";
       import { DragHandleMenuItem } from "../DragHandleMenuItem";
       
       export const RemoveBlockButton = (
      -  props: DragHandleMenuProps & { children: ReactNode }
      +  props: DragHandleMenuProps & { children: ReactNode }
       ) => {
         return (
           (
      -  props: DragHandleMenuProps
      +  props: DragHandleMenuProps
       ) => (
         
           Delete
      diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx
      index b67dd98836..be806fd5a1 100644
      --- a/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx
      +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx
      @@ -1,10 +1,20 @@
      +import {
      +  Block,
      +  BlockNoteEditor,
      +  BlockSchema,
      +  InlineContentSchema,
      +  StyleSchema,
      +} from "@blocknote/core";
      +import { Menu, createStyles } from "@mantine/core";
       import { ReactNode } from "react";
      -import { createStyles, Menu } from "@mantine/core";
      -import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core";
       
      -export type DragHandleMenuProps = {
      -  editor: BlockNoteEditor;
      -  block: Block;
      +export type DragHandleMenuProps<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  editor: BlockNoteEditor;
      +  block: Block;
       };
       
       export const DragHandleMenu = (props: { children: ReactNode }) => {
      diff --git a/packages/react/src/SideMenu/components/SideMenuPositioner.tsx b/packages/react/src/SideMenu/components/SideMenuPositioner.tsx
      index c77caa6cea..b0c94d7b71 100644
      --- a/packages/react/src/SideMenu/components/SideMenuPositioner.tsx
      +++ b/packages/react/src/SideMenu/components/SideMenuPositioner.tsx
      @@ -3,36 +3,41 @@ import {
         BlockNoteEditor,
         BlockSchema,
         DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +  InlineContentSchema,
         SideMenuProsemirrorPlugin,
       } from "@blocknote/core";
       import Tippy from "@tippyjs/react";
       import { FC, useEffect, useMemo, useRef, useState } from "react";
       
      +import { StyleSchema } from "@blocknote/core";
       import { DefaultSideMenu } from "./DefaultSideMenu";
       import { DragHandleMenuProps } from "./DragHandleMenu/DragHandleMenu";
       
      -export type SideMenuProps =
      -  Pick<
      -    SideMenuProsemirrorPlugin,
      -    | "blockDragStart"
      -    | "blockDragEnd"
      -    | "addBlock"
      -    | "freezeMenu"
      -    | "unfreezeMenu"
      -  > & {
      -    block: Block;
      -    editor: BlockNoteEditor;
      -    dragHandleMenu?: FC>;
      -  };
      +export type SideMenuProps<
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema,
      +  S extends StyleSchema = DefaultStyleSchema
      +> = Pick<
      +  SideMenuProsemirrorPlugin,
      +  "blockDragStart" | "blockDragEnd" | "addBlock" | "freezeMenu" | "unfreezeMenu"
      +> & {
      +  block: Block;
      +  editor: BlockNoteEditor;
      +  dragHandleMenu?: FC>;
      +};
       
       export const SideMenuPositioner = <
      -  BSchema extends BlockSchema = DefaultBlockSchema
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema,
      +  S extends StyleSchema = DefaultStyleSchema
       >(props: {
      -  editor: BlockNoteEditor;
      -  sideMenu?: FC>;
      +  editor: BlockNoteEditor;
      +  sideMenu?: FC>;
       }) => {
         const [show, setShow] = useState(false);
      -  const [block, setBlock] = useState>();
      +  const [block, setBlock] = useState>();
       
         const referencePos = useRef();
       
      diff --git a/packages/react/src/SlashMenu/ReactSlashMenuItem.ts b/packages/react/src/SlashMenu/ReactSlashMenuItem.ts
      index b5e6f24091..65ceb044f7 100644
      --- a/packages/react/src/SlashMenu/ReactSlashMenuItem.ts
      +++ b/packages/react/src/SlashMenu/ReactSlashMenuItem.ts
      @@ -2,11 +2,17 @@ import {
         BaseSlashMenuItem,
         BlockSchema,
         DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +  InlineContentSchema,
      +  StyleSchema,
       } from "@blocknote/core";
       
       export type ReactSlashMenuItem<
      -  BSchema extends BlockSchema = DefaultBlockSchema
      -> = BaseSlashMenuItem & {
      +  BSchema extends BlockSchema = DefaultBlockSchema,
      +  I extends InlineContentSchema = DefaultInlineContentSchema,
      +  S extends StyleSchema = DefaultStyleSchema
      +> = BaseSlashMenuItem & {
         group: string;
         icon: JSX.Element;
         hint?: string;
      diff --git a/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx b/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx
      index e3e005b68e..6c084ad245 100644
      --- a/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx
      +++ b/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx
      @@ -8,12 +8,12 @@ import {
       import Tippy from "@tippyjs/react";
       import { FC, useEffect, useMemo, useRef, useState } from "react";
       
      +import { usePreventMenuOverflow } from "../../hooks/usePreventMenuOverflow";
       import { ReactSlashMenuItem } from "../ReactSlashMenuItem";
       import { DefaultSlashMenu } from "./DefaultSlashMenu";
      -import { usePreventMenuOverflow } from "../../hooks/usePreventMenuOverflow";
       
       export type SlashMenuProps =
      -  Pick, "itemCallback"> &
      +  Pick, "itemCallback"> &
           Pick<
             SuggestionsMenuState>,
             "filteredItems" | "keyboardHoveredItemIndex"
      @@ -22,7 +22,7 @@ export type SlashMenuProps =
       export const SlashMenuPositioner = <
         BSchema extends BlockSchema = DefaultBlockSchema
       >(props: {
      -  editor: BlockNoteEditor;
      +  editor: BlockNoteEditor;
         slashMenu?: FC>;
       }) => {
         const [show, setShow] = useState(false);
      diff --git a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx b/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx
      index 73b9628607..66411dda62 100644
      --- a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx
      +++ b/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx
      @@ -4,6 +4,8 @@ import {
         defaultBlockSchema,
         DefaultBlockSchema,
         getDefaultSlashMenuItems,
      +  InlineContentSchema,
      +  StyleSchema,
       } from "@blocknote/core";
       import {
         RiH1,
      @@ -22,7 +24,7 @@ const extraFields: Record<
         string,
         Omit<
           ReactSlashMenuItem,
      -    keyof BaseSlashMenuItem
      +    keyof BaseSlashMenuItem
         >
       > = {
         Heading: {
      @@ -74,14 +76,18 @@ const extraFields: Record<
         },
       };
       
      -export function getDefaultReactSlashMenuItems(
      +export function getDefaultReactSlashMenuItems<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
         // This type casting is weird, but it's the best way of doing it, as it allows
         // the schema type to be automatically inferred if it is defined, or be
         // inferred as any if it is not defined. I don't think it's possible to make it
         // infer to DefaultBlockSchema if it is not defined.
      -  schema: BSchema = defaultBlockSchema as unknown as BSchema
      -): ReactSlashMenuItem[] {
      -  const slashMenuItems: BaseSlashMenuItem[] =
      +  schema: BSchema = defaultBlockSchema as any as BSchema
      +): ReactSlashMenuItem[] {
      +  const slashMenuItems: BaseSlashMenuItem[] =
           getDefaultSlashMenuItems(schema);
       
         return slashMenuItems.map((item) => ({
      diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx
      index ea62c15cd7..e7e98d4f1b 100644
      --- a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx
      +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx
      @@ -1,10 +1,12 @@
      -import { BlockSchema } from "@blocknote/core";
      +import { BlockSchemaWithBlock, DefaultBlockSchema } from "@blocknote/core";
       import { MdDragIndicator } from "react-icons/md";
      -import { TableHandleProps } from "./TableHandlePositioner";
       import { TableHandle } from "./TableHandle";
      +import { TableHandleProps } from "./TableHandlePositioner";
       
      -export const DefaultTableHandle = (
      -  props: TableHandleProps
      +export const DefaultTableHandle = <
      +  BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>
      +>(
      +  props: TableHandleProps
       ) => (
         
           
      ( - props: TableHandleProps & { children: ReactNode } +export const TableHandle = < + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> +>( + props: TableHandleProps & { children: ReactNode } ) => { const { classes } = createStyles({ root: {} })(undefined, { name: "TableHandle", diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx index c24350d73e..46d587e0cb 100644 --- a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx @@ -3,8 +3,8 @@ import { PartialBlock, TableContent, } from "@blocknote/core"; -import { TableHandleMenuItem } from "../TableHandleMenuItem"; import { TableHandleMenuProps } from "../TableHandleMenu"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; export const AddRowButton = < BSchema extends { table: DefaultBlockSchema["table"] } @@ -26,7 +26,7 @@ export const AddRowButton = < content: { rows, }, - } as PartialBlock); + } as PartialBlock); }}> {`Add row ${props.side}`} @@ -39,7 +39,7 @@ export const AddColumnButton = < ) => ( { - const content: TableContent = { + const content: TableContent = { type: "tableContent", rows: props.block.content.rows.map((row) => { const cells = [...row.cells]; @@ -51,7 +51,7 @@ export const AddColumnButton = < props.editor.updateBlock(props.block, { type: "table", content: content, - } as PartialBlock); + } as PartialBlock); }}> {`Add column ${props.side}`} diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx index e314a617b3..2a8af93c37 100644 --- a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx @@ -3,8 +3,8 @@ import { PartialBlock, TableContent, } from "@blocknote/core"; -import { TableHandleMenuItem } from "../TableHandleMenuItem"; import { TableHandleMenuProps } from "../TableHandleMenu"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; export const DeleteRowButton = < BSchema extends { table: DefaultBlockSchema["table"] } @@ -13,7 +13,7 @@ export const DeleteRowButton = < ) => ( { - const content: TableContent = { + const content: TableContent = { type: "tableContent", rows: props.block.content.rows.filter( (_, index) => index !== props.index @@ -23,7 +23,7 @@ export const DeleteRowButton = < props.editor.updateBlock(props.block, { type: "table", content, - } as PartialBlock); + } as PartialBlock); }}> Delete row @@ -36,7 +36,7 @@ export const DeleteColumnButton = < ) => ( { - const content: TableContent = { + const content: TableContent = { type: "tableContent", rows: props.block.content.rows.map((row) => ({ cells: row.cells.filter((_, index) => index !== props.index), @@ -46,7 +46,7 @@ export const DeleteColumnButton = < props.editor.updateBlock(props.block, { type: "table", content, - } as PartialBlock); + } as PartialBlock); }}> Delete column diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx index 682ad515b2..24e649aa5c 100644 --- a/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx +++ b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx @@ -1,13 +1,22 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + SpecificBlock, +} from "@blocknote/core"; +import { Menu, createStyles } from "@mantine/core"; import { ReactNode } from "react"; -import { Block, BlockNoteEditor, DefaultBlockSchema } from "@blocknote/core"; -import { createStyles, Menu } from "@mantine/core"; export type TableHandleMenuProps< BSchema extends { table: DefaultBlockSchema["table"] } > = { orientation: "row" | "column"; - editor: BlockNoteEditor; - block: Block; + editor: BlockNoteEditor; + block: SpecificBlock< + { table: DefaultBlockSchema["table"] }, + "table", + any, + any + >; index: number; }; diff --git a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx index ab8c2e2136..78f6102a36 100644 --- a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx +++ b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx @@ -1,56 +1,60 @@ -import { DragEvent, FC, useEffect, useMemo, useRef, useState } from "react"; import { - Block, + BlockFromConfigNoChildren, BlockNoteEditor, - BlockSchema, BlockSchemaWithBlock, DefaultBlockSchema, + InlineContentSchema, + StyleSchema, TableHandlesProsemirrorPlugin, TableHandlesState, } from "@blocknote/core"; import Tippy, { tippy } from "@tippyjs/react"; +import { DragEvent, FC, useEffect, useMemo, useRef, useState } from "react"; import { DragHandleMenuProps } from "../../SideMenu/components/DragHandleMenu/DragHandleMenu"; import { DefaultTableHandle } from "./DefaultTableHandle"; -export type TableHandleProps = - Pick< - TableHandlesProsemirrorPlugin, - "dragEnd" | "freezeHandles" | "unfreezeHandles" - > & - Omit< - TableHandlesState, - | "rowIndex" - | "colIndex" - | "referencePosCell" - | "referencePosTable" - | "show" - | "draggingState" - > & { - orientation: "row" | "column"; - editor: BlockNoteEditor< - BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]["config"]> - >; - tableHandleMenu?: FC>; - dragStart: (e: DragEvent) => void; - index: number; - showOtherSide: () => void; - hideOtherSide: () => void; - }; +export type TableHandleProps< + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema +> = Pick< + TableHandlesProsemirrorPlugin, + "dragEnd" | "freezeHandles" | "unfreezeHandles" +> & + Omit< + TableHandlesState, + | "rowIndex" + | "colIndex" + | "referencePosCell" + | "referencePosTable" + | "show" + | "draggingState" + > & { + orientation: "row" | "column"; + editor: BlockNoteEditor< + BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> + >; + tableHandleMenu?: FC>; + dragStart: (e: DragEvent) => void; + index: number; + showOtherSide: () => void; + hideOtherSide: () => void; + }; export const TableHandlesPositioner = < - BSchema extends BlockSchemaWithBlock< - "table", - DefaultBlockSchema["table"]["config"] - > + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema >(props: { - editor: BlockNoteEditor; - tableHandle?: FC>; + editor: BlockNoteEditor; + tableHandle?: FC>; }) => { const [show, setShow] = useState(false); const [hideRow, setHideRow] = useState(false); const [hideCol, setHideCol] = useState(false); const [block, setBlock] = - useState>(); + useState>(); + const [rowIndex, setRowIndex] = useState(); const [colIndex, setColIndex] = useState(); @@ -67,7 +71,7 @@ export const TableHandlesPositioner = < useEffect(() => { tippy.setDefaultProps({ maxWidth: "" }); - return props.editor.tableHandles.onUpdate((state) => { + return props.editor.tableHandles!.onUpdate((state) => { // console.log("update", state.draggingState); setShow(state.show); setBlock(state.block); @@ -153,10 +157,10 @@ export const TableHandlesPositioner = < editor={props.editor as any} index={colIndex!} block={block!} - dragStart={props.editor.tableHandles.colDragStart} - dragEnd={props.editor.tableHandles.dragEnd} - freezeHandles={props.editor.tableHandles.freezeHandles} - unfreezeHandles={props.editor.tableHandles.unfreezeHandles} + dragStart={props.editor.tableHandles!.colDragStart} + dragEnd={props.editor.tableHandles!.dragEnd} + freezeHandles={props.editor.tableHandles!.freezeHandles} + unfreezeHandles={props.editor.tableHandles!.unfreezeHandles} showOtherSide={() => setHideRow(false)} hideOtherSide={() => setHideRow(true)} /> @@ -172,10 +176,10 @@ export const TableHandlesPositioner = < editor={props.editor as any} index={rowIndex!} block={block!} - dragStart={props.editor.tableHandles.rowDragStart} - dragEnd={props.editor.tableHandles.dragEnd} - freezeHandles={props.editor.tableHandles.freezeHandles} - unfreezeHandles={props.editor.tableHandles.unfreezeHandles} + dragStart={props.editor.tableHandles!.rowDragStart} + dragEnd={props.editor.tableHandles!.dragEnd} + freezeHandles={props.editor.tableHandles!.freezeHandles} + unfreezeHandles={props.editor.tableHandles!.unfreezeHandles} showOtherSide={() => setHideCol(false)} hideOtherSide={() => setHideCol(true)} /> diff --git a/packages/react/src/__snapshots__/reactCustomParagraph/nested/internal.html b/packages/react/src/__snapshots__/reactCustomParagraph/nested/internal.html deleted file mode 100644 index 22dd233fa1..0000000000 --- a/packages/react/src/__snapshots__/reactCustomParagraph/nested/internal.html +++ /dev/null @@ -1 +0,0 @@ -

      React Custom Paragraph

      Nested React Custom Paragraph 1

      Nested React Custom Paragraph 2

      \ No newline at end of file diff --git a/packages/react/src/__snapshots__/reactCustomParagraph/styled/internal.html b/packages/react/src/__snapshots__/reactCustomParagraph/styled/internal.html deleted file mode 100644 index ec4f7f99a2..0000000000 --- a/packages/react/src/__snapshots__/reactCustomParagraph/styled/internal.html +++ /dev/null @@ -1 +0,0 @@ -

      Plain Red Text Blue Background Mixed Colors

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

      React Custom Paragraph

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

      Custom React Paragraph

      Nested React Custom Paragraph 1

      Nested React Custom Paragraph 2

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

      Custom React Paragraph

      Nested React Custom Paragraph 1

      Nested React Custom Paragraph 2

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

      Plain Red Text Blue Background Mixed Colors

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

      Hello World

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

      This is text with a custom fontSize

      \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/fontSize/basic/internal.html b/packages/react/src/test/__snapshots__/fontSize/basic/internal.html new file mode 100644 index 0000000000..998d9bcf8b --- /dev/null +++ b/packages/react/src/test/__snapshots__/fontSize/basic/internal.html @@ -0,0 +1 @@ +

      This is text with a custom fontSize

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

      I enjoy working with@Matthew

      \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/mention/basic/internal.html b/packages/react/src/test/__snapshots__/mention/basic/internal.html new file mode 100644 index 0000000000..6ca7d81c2c --- /dev/null +++ b/packages/react/src/test/__snapshots__/mention/basic/internal.html @@ -0,0 +1 @@ +

      I enjoy working with@Matthew

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

      React Custom Paragraph

      \ No newline at end of file +

      React Custom Paragraph

      \ No newline at end of file diff --git a/packages/react/src/__snapshots__/reactCustomParagraph/nested/external.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/external.html similarity index 100% rename from packages/react/src/__snapshots__/reactCustomParagraph/nested/external.html rename to packages/react/src/test/__snapshots__/reactCustomParagraph/nested/external.html diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html new file mode 100644 index 0000000000..faec73f053 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html @@ -0,0 +1 @@ +

      React Custom Paragraph

      Nested React Custom Paragraph 1

      Nested React Custom Paragraph 2

      \ No newline at end of file diff --git a/packages/react/src/__snapshots__/reactCustomParagraph/styled/external.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/external.html similarity index 100% rename from packages/react/src/__snapshots__/reactCustomParagraph/styled/external.html rename to packages/react/src/test/__snapshots__/reactCustomParagraph/styled/external.html diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html new file mode 100644 index 0000000000..dd2e249332 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html @@ -0,0 +1 @@ +

      Plain Red Text Blue Background Mixed Colors

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

      React Custom Paragraph

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

      React Custom Paragraph

      \ No newline at end of file +

      React Custom Paragraph

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

      Custom React Paragraph

      Nested React Custom Paragraph 1

      Nested React Custom Paragraph 2

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

      Custom React Paragraph

      Nested React Custom Paragraph 1

      Nested React Custom Paragraph 2

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

      Plain Red Text Blue Background Mixed Colors

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

      Plain Red Text Blue Background Mixed Colors

      \ No newline at end of file +

      Plain Red Text Blue Background Mixed Colors

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

      This is a small text

      \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/small/basic/internal.html b/packages/react/src/test/__snapshots__/small/basic/internal.html new file mode 100644 index 0000000000..73836f647d --- /dev/null +++ b/packages/react/src/test/__snapshots__/small/basic/internal.html @@ -0,0 +1 @@ +

      This is a small text

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

      I love #BlockNote

      \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/tag/basic/internal.html b/packages/react/src/test/__snapshots__/tag/basic/internal.html new file mode 100644 index 0000000000..bac28633b0 --- /dev/null +++ b/packages/react/src/test/__snapshots__/tag/basic/internal.html @@ -0,0 +1 @@ +

      I love #BlockNote

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

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

      Hello World

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

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