diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 256ce38cd5..f73c4bdf5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,9 +5,6 @@ on: - main pull_request: types: [opened, synchronize, reopened, edited] - branches: - - main - - "project/**" jobs: build: diff --git a/examples/editor/src/App.tsx b/examples/editor/examples/Basic.tsx similarity index 69% rename from examples/editor/src/App.tsx rename to examples/editor/examples/Basic.tsx index 128cdee58a..6c3213b0dd 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/examples/Basic.tsx @@ -1,19 +1,15 @@ -// import logo from './logo.svg' import "@blocknote/core/style.css"; import { BlockNoteView, useBlockNote } from "@blocknote/react"; -import styles from "./App.module.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: styles.editor, + class: "editor", "data-test": "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.module.css b/examples/editor/src/App.css similarity index 50% rename from examples/editor/src/App.module.css rename to examples/editor/src/App.css index 8a90b5cd3f..8918687e58 100644 --- a/examples/editor/src/App.module.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 e09ee19456..0d2f29eefe 100644 --- a/examples/editor/src/main.tsx +++ b/examples/editor/src/main.tsx @@ -1,13 +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 eb2008b14a..a14117e164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "eslint-plugin-import": "^2.28.0", "lerna": "^5.4.0", "patch-package": "^6.4.7", - "typescript": "^5.0.4" + "typescript": "^5.2.2" } }, "examples/editor": { @@ -28,8 +28,12 @@ "dependencies": { "@blocknote/core": "^0.9.6", "@blocknote/react": "^0.9.6", + "@mantine/core": "^5.6.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "y-partykit": "^0.0.0-4c022c1", + "yjs": "^13.6.10" }, "devDependencies": { "@types/react": "^18.0.25", @@ -603,6 +607,23 @@ } } }, + "examples/playground": { + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "next": "14.0.3", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.0.3", + "typescript": "^5" + } + }, "examples/vanilla": { "name": "@blocknote/example-vanilla", "version": "0.9.6", @@ -6170,6 +6191,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@remix-run/router": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", + "integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@resvg/resvg-wasm": { "version": "2.4.1", "license": "MPL-2.0", @@ -6190,9 +6219,10 @@ } }, "node_modules/@rushstack/eslint-patch": { - "version": "1.3.0", - "dev": true, - "license": "MIT" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz", + "integrity": "sha512-2/U3GXA6YiPYQDLGwtGlnNgKYBSwCFIHf8Y9LUY5VATHdtbLlU0Y1R3QoBnT0aB4qv/BEiVVsj7LJXoQCgJ2vA==", + "dev": true }, "node_modules/@shuding/opentype.js": { "version": "1.4.0-beta.0", @@ -6424,6 +6454,42 @@ "@tiptap/core": "^2.0.0" } }, + "node_modules/@tiptap/extension-table-cell": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.1.12.tgz", + "integrity": "sha512-hextcfVTdwX8G7s8Q/V6LW2aUhGvPgu1dfV+kVVO42AFHxG+6PIkDOUuHphGajG3Nrs129bjMDWb8jphj38dUg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.1.12.tgz", + "integrity": "sha512-a4WZ5Z7gqQ/QlK8cK2d1ONYdma/J5+yH/0SNtQhkfELoS45GsLJh89OyKO0W0FnY6Mg0RoH1FsoBD+cqm0yazA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.1.12.tgz", + "integrity": "sha512-0kPr+zngQC1YQRcU6+Fl3CpIW/SdJhVJ5qOLpQleXrLPdjmZQd3Z1DXvOSDphYjXCowGPCxeUa++6bo7IoEMJw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, "node_modules/@tiptap/extension-text": { "version": "2.0.3", "license": "MIT", @@ -7691,14 +7757,15 @@ "license": "MIT" }, "node_modules/array-includes": { - "version": "3.1.6", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", "is-string": "^1.0.7" }, "engines": { @@ -7717,16 +7784,16 @@ } }, "node_modules/array.prototype.findlastindex": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.2.tgz", - "integrity": "sha512-tb5thFFlUcp7NdNF6/MpDk/1r/4awWG1FIz3YqDf+/zJSTezBb+/5WViH41obXULHVpDzoiCLpJ/ZO9YbJMsdw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" + "get-intrinsic": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -7736,13 +7803,14 @@ } }, "node_modules/array.prototype.flat": { - "version": "1.3.1", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -7753,13 +7821,14 @@ } }, "node_modules/array.prototype.flatmap": { - "version": "1.3.1", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -7781,6 +7850,27 @@ "get-intrinsic": "^1.1.3" } }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/arrify": { "version": "1.0.1", "dev": true, @@ -7812,6 +7902,15 @@ "dev": true, "license": "MIT" }, + "node_modules/asynciterator.prototype": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", + "integrity": "sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + } + }, "node_modules/asynckit": { "version": "0.4.0", "dev": true, @@ -8225,12 +8324,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dev": true, - "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9098,6 +9199,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "dev": true, @@ -9107,10 +9222,12 @@ } }, "node_modules/define-properties": { - "version": "1.2.0", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, - "license": "MIT", "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -9367,24 +9484,26 @@ } }, "node_modules/es-abstract": { - "version": "1.21.2", + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", "dev": true, - "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.5", "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.0", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", "get-symbol-description": "^1.0.0", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", "has-property-descriptors": "^1.0.0", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", + "hasown": "^2.0.0", "internal-slot": "^1.0.5", "is-array-buffer": "^3.0.2", "is-callable": "^1.2.7", @@ -9392,19 +9511,23 @@ "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", + "is-typed-array": "^1.1.12", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", "typed-array-length": "^1.0.4", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.9" + "which-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -9432,6 +9555,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-iterator-helpers": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", + "integrity": "sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==", + "dev": true, + "dependencies": { + "asynciterator.prototype": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.1", + "es-set-tostringtag": "^2.0.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.0.1" + } + }, "node_modules/es-set-tostringtag": { "version": "2.0.1", "dev": true, @@ -10017,13 +10162,14 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.7", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, - "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.11.0", - "resolve": "^1.22.1" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -10076,27 +10222,26 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.0.tgz", - "integrity": "sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", + "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", "dev": true, "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.findlastindex": "^1.2.2", - "array.prototype.flat": "^1.3.1", - "array.prototype.flatmap": "^1.3.1", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.7", + "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.8.0", - "has": "^1.0.3", - "is-core-module": "^2.12.1", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.fromentries": "^2.0.6", - "object.groupby": "^1.0.0", - "object.values": "^1.1.6", - "resolve": "^1.22.3", + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", "semver": "^6.3.1", "tsconfig-paths": "^3.14.2" }, @@ -10179,14 +10324,16 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.32.2", + "version": "7.33.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", + "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", "dev": true, - "license": "MIT", "dependencies": { "array-includes": "^3.1.6", "array.prototype.flatmap": "^1.3.1", "array.prototype.tosorted": "^1.1.1", "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.12", "estraverse": "^5.3.0", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", @@ -10196,7 +10343,7 @@ "object.values": "^1.1.6", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.4", - "semver": "^6.3.0", + "semver": "^6.3.1", "string.prototype.matchall": "^4.0.8" }, "engines": { @@ -10505,9 +10652,10 @@ "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.2.12", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -10771,18 +10919,23 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "license": "MIT" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { - "version": "1.1.5", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { "node": ">= 0.4" @@ -10841,14 +10994,15 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.1", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dev": true, - "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11183,6 +11337,7 @@ }, "node_modules/has": { "version": "1.0.3", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.1" @@ -11258,6 +11413,17 @@ "dev": true, "license": "ISC" }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-embedded": { "version": "2.0.1", "license": "MIT", @@ -11544,6 +11710,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.0.tgz", + "integrity": "sha512-KlClZ3/Qy5UgvpvVvDomGhnQhNWH5INE8GwvSIQ9CWt1K0zbbXrl7eN5bWaafOZgtmO3jMPwUqmrmEwinhPq1w==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "dev": true, @@ -11954,6 +12129,21 @@ "version": "0.2.1", "license": "MIT" }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-bigint": { "version": "1.0.4", "dev": true, @@ -12035,10 +12225,11 @@ } }, "node_modules/is-core-module": { - "version": "2.12.1", - "license": "MIT", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12100,6 +12291,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "dev": true, @@ -12108,6 +12311,21 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "dev": true, @@ -12303,15 +12521,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.10", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", "dev": true, - "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "which-typed-array": "^1.1.11" }, "engines": { "node": ">= 0.4" @@ -12416,6 +12631,19 @@ "node": ">=0.12" } }, + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + } + }, "node_modules/jake": { "version": "10.8.7", "dev": true, @@ -12797,8 +13025,9 @@ } }, "node_modules/lib0": { - "version": "0.2.78", - "license": "MIT", + "version": "0.2.88", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.88.tgz", + "integrity": "sha512-KyroiEvCeZcZEMx5Ys+b4u4eEBbA1ch7XUaBhYpwa/nPMrzTjUhI4RfcytmQfYoTBPcdyx+FX6WFNIoNuJzJfQ==", "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -15346,9 +15575,10 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -15407,13 +15637,14 @@ } }, "node_modules/object.fromentries": { - "version": "2.0.6", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -15423,14 +15654,14 @@ } }, "node_modules/object.groupby": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.0.tgz", - "integrity": "sha512-70MWG6NfRH9GnbZOikuhPPYzpUpof9iW2J9E4dW7FXTqPNb6rllE6u39SKwwiNh8lCwX3DDb5OgcKGiEBrTTyw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", - "es-abstract": "^1.21.2", + "es-abstract": "^1.22.1", "get-intrinsic": "^1.2.1" } }, @@ -15467,13 +15698,14 @@ } }, "node_modules/object.values": { - "version": "1.1.6", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -16371,7 +16603,9 @@ } }, "node_modules/postcss": { - "version": "8.4.24", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { @@ -16387,7 +16621,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -16777,7 +17010,8 @@ }, "node_modules/react-dom": { "version": "18.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -16805,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", @@ -17146,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, @@ -17175,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" @@ -17224,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", @@ -17353,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" }, @@ -17528,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, @@ -17737,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, @@ -18027,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" @@ -18043,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" @@ -18503,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, @@ -18530,15 +19066,16 @@ } }, "node_modules/typescript": { - "version": "5.0.4", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=14.17" } }, "node_modules/uc.micro": { @@ -19339,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, @@ -19354,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" @@ -19644,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", @@ -19663,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": { @@ -19718,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", @@ -19777,6 +20361,9 @@ "@tiptap/extension-link": "^2.0.3", "@tiptap/extension-paragraph": "^2.0.3", "@tiptap/extension-strike": "^2.0.3", + "@tiptap/extension-table-cell": "^2.0.3", + "@tiptap/extension-table-header": "^2.0.3", + "@tiptap/extension-table-row": "^2.0.3", "@tiptap/extension-text": "^2.0.3", "@tiptap/extension-underline": "^2.0.3", "@tiptap/pm": "^2.0.3", @@ -19784,8 +20371,10 @@ "lodash": "^4.17.21", "prosemirror-model": "^1.18.3", "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.3.4", "prosemirror-transform": "^1.7.2", "prosemirror-view": "^1.31.4", + "rehype-format": "^5.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", @@ -19893,6 +20482,18 @@ "node": "^10 || ^12 || >=14" } }, + "packages/core/node_modules/prosemirror-tables": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.3.4.tgz", + "integrity": "sha512-z6uLSQ1BLC3rgbGwZmpfb+xkdvD7W/UOsURDfognZFYaTtc0gsk7u/t71Yijp2eLflVpffMk6X0u0+u+MMDvIw==", + "dependencies": { + "prosemirror-keymap": "^1.1.2", + "prosemirror-model": "^1.8.1", + "prosemirror-state": "^1.3.1", + "prosemirror-transform": "^1.2.1", + "prosemirror-view": "^1.13.3" + } + }, "packages/core/node_modules/rollup": { "version": "3.27.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.27.0.tgz", @@ -19978,7 +20579,8 @@ "@tiptap/core": "^2.0.3", "@tiptap/react": "^2.0.3", "lodash": "^4.17.21", - "react": "^18.2.0", + "react": "^18", + "react-dom": "^18.2.0", "react-icons": "^4.3.1", "tippy.js": "^6.3.7", "use-prefers-color-scheme": "^1.1.3" @@ -19992,7 +20594,8 @@ "typescript": "^5.0.4", "vite": "^4.4.8", "vite-plugin-eslint": "^1.8.1", - "vite-plugin-externalize-deps": "^0.7.0" + "vite-plugin-externalize-deps": "^0.7.0", + "vitest": "^0.34.1" }, "peerDependencies": { "react": "^18", diff --git a/package.json b/package.json index 74b0a5febe..170c4a0c2a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "eslint-config-react-app": "^7.0.0", "lerna": "^5.4.0", "patch-package": "^6.4.7", - "typescript": "^5.0.4", + "typescript": "^5.2.2", "@typescript-eslint/parser": "^5.5.0", "@typescript-eslint/eslint-plugin": "^5.5.0" }, diff --git a/packages/core/package.json b/packages/core/package.json index dfceb58816..2984e7b9d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -66,6 +66,9 @@ "@tiptap/extension-link": "^2.0.3", "@tiptap/extension-paragraph": "^2.0.3", "@tiptap/extension-strike": "^2.0.3", + "@tiptap/extension-table-cell": "^2.0.3", + "@tiptap/extension-table-header": "^2.0.3", + "@tiptap/extension-table-row": "^2.0.3", "@tiptap/extension-text": "^2.0.3", "@tiptap/extension-underline": "^2.0.3", "@tiptap/pm": "^2.0.3", @@ -75,9 +78,11 @@ "prosemirror-state": "^1.4.3", "prosemirror-transform": "^1.7.2", "prosemirror-view": "^1.31.4", + "prosemirror-tables": "^1.3.4", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", + "rehype-format":"^5.0.0", "remark-gfm": "^3.0.1", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", diff --git a/packages/core/src/BlockNoteEditor.test.ts b/packages/core/src/BlockNoteEditor.test.ts index 9c1b60fb12..f295c76fab 100644 --- a/packages/core/src/BlockNoteEditor.test.ts +++ b/packages/core/src/BlockNoteEditor.test.ts @@ -6,7 +6,7 @@ import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFro * @vitest-environment jsdom */ it("creates an editor", () => { - const editor = new BlockNoteEditor({}); + const editor = BlockNoteEditor.create(); const blockInfo = getBlockInfoFromPos(editor._tiptapEditor.state.doc, 2); expect(blockInfo?.contentNode.type.name).toEqual("paragraph"); }); diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 58491b687c..5a64ebb942 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"; @@ -10,38 +10,55 @@ import { replaceBlocks, updateBlock, } from "./api/blockManipulation/blockManipulation"; -import { - HTMLToBlocks, - blocksToHTML, - blocksToMarkdown, - markdownToBlocks, -} from "./api/formatConversions/formatConversions"; import { blockToNode, nodeToBlock, } from "./api/nodeConversions/nodeConversions"; import { getNodeById } from "./api/util/nodeUtil"; -import styles from "./editor.module.css"; import { Block, BlockIdentifier, BlockNoteDOMAttributes, BlockSchema, + BlockSchemaFromSpecs, + BlockSchemaWithBlock, + BlockSpecs, PartialBlock, -} from "./extensions/Blocks/api/blockTypes"; +} 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"; @@ -49,10 +66,15 @@ import { SideMenuProsemirrorPlugin } from "./extensions/SideMenu/SideMenuPlugin" import { BaseSlashMenuItem } from "./extensions/SlashMenu/BaseSlashMenuItem"; import { SlashMenuProsemirrorPlugin } from "./extensions/SlashMenu/SlashMenuPlugin"; import { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; +import { TableHandlesProsemirrorPlugin } from "./extensions/TableHandles/TableHandlesPlugin"; import { UniqueID } from "./extensions/UniqueID/UniqueID"; -import { mergeCSSClasses } from "./shared/utils"; +import { 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; /** @@ -61,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. @@ -78,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`. */ @@ -94,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. * @@ -105,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. @@ -149,52 +201,115 @@ const blockNoteTipTapOptions = { enableCoreExtensions: false, }; -export class BlockNoteEditor { +export class BlockNoteEditor< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema +> { public readonly _tiptapEditor: TiptapEditor & { contentComponent: any }; - public blockCache = new WeakMap>(); - public readonly schema: BSchema; + public blockCache = new WeakMap>(); + public readonly blockSchema: BSchema; + public readonly inlineContentSchema: ISchema; + public readonly styleSchema: SSchema; + + public readonly blockImplementations: BlockSpecs; + public readonly inlineContentImplementations: InlineContentSpecs; + public readonly styleImplementations: StyleSpecs; + public ready = false; - public readonly sideMenu: SideMenuProsemirrorPlugin; - public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; - public readonly slashMenu: SlashMenuProsemirrorPlugin; - public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin; - public readonly imageToolbar: ImageToolbarProsemirrorPlugin; + public readonly sideMenu: SideMenuProsemirrorPlugin< + BSchema, + ISchema, + SSchema + >; + public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; + public readonly slashMenu: SlashMenuProsemirrorPlugin< + BSchema, + ISchema, + SSchema, + any + >; + public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin< + BSchema, + ISchema, + SSchema + >; + public readonly imageToolbar: ImageToolbarProsemirrorPlugin< + BSchema, + ISchema, + SSchema + >; + public readonly tableHandles: + | TableHandlesProsemirrorPlugin< + BSchema extends BlockSchemaWithBlock< + "table", + DefaultBlockSchema["table"] + > + ? BSchema + : any, + ISchema, + SSchema + > + | undefined; public readonly uploadFile: ((file: File) => Promise) | undefined; - constructor( - private readonly options: Partial> = {} + public static create< + BSpecs extends BlockSpecs = typeof defaultBlockSpecs, + ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs, + SSpecs extends StyleSpecs = typeof defaultStyleSpecs + >(options: Partial> = {}) { + return new BlockNoteEditor(options) as BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + >; + } + + private constructor( + private readonly options: Partial> ) { // apply defaults - const newOptions: Omit & { - defaultStyles: boolean; - blockSchema: BSchema; - } = { + const newOptions = { defaultStyles: true, - // TODO: There's a lot of annoying typing stuff to deal with here. If - // BSchema is specified, then options.blockSchema should also be required. - // If BSchema is not specified, then options.blockSchema should also not - // be defined. Unfortunately, trying to implement these constraints seems - // to be a huge pain, hence the `as any` casts. - blockSchema: options.blockSchema || (defaultBlockSchema as any), + blockSpecs: options.blockSpecs || defaultBlockSpecs, + styleSpecs: options.styleSpecs || defaultStyleSpecs, + inlineContentSpecs: + options.inlineContentSpecs || defaultInlineContentSpecs, ...options, }; + this.blockSchema = getBlockSchemaFromSpecs(newOptions.blockSpecs); + this.inlineContentSchema = getInlineContentSchemaFromSpecs( + newOptions.inlineContentSpecs + ); + this.styleSchema = getStyleSchemaFromSpecs(newOptions.styleSpecs); + this.blockImplementations = newOptions.blockSpecs; + this.inlineContentImplementations = newOptions.inlineContentSpecs; + this.styleImplementations = newOptions.styleSpecs; + this.sideMenu = new SideMenuProsemirrorPlugin(this); this.formattingToolbar = new FormattingToolbarProsemirrorPlugin(this); this.slashMenu = new SlashMenuProsemirrorPlugin( this, newOptions.slashMenuItems || - getDefaultSlashMenuItems(newOptions.blockSchema) + (getDefaultSlashMenuItems(this.blockSchema) as any) ); this.hyperlinkToolbar = new HyperlinkToolbarProsemirrorPlugin(this); this.imageToolbar = new ImageToolbarProsemirrorPlugin(this); - const extensions = getBlockNoteExtensions({ + if (this.blockSchema.table === defaultBlockSchema.table) { + this.tableHandles = new TableHandlesProsemirrorPlugin(this as any); + } + + const extensions = getBlockNoteExtensions({ editor: this, domAttributes: newOptions.domAttributes || {}, - blockSchema: newOptions.blockSchema, + blockSchema: this.blockSchema, + blockSpecs: newOptions.blockSpecs, + styleSpecs: newOptions.styleSpecs, + inlineContentSpecs: newOptions.inlineContentSpecs, collaboration: newOptions.collaboration, }); @@ -208,13 +323,12 @@ export class BlockNoteEditor { this.slashMenu.plugin, this.hyperlinkToolbar.plugin, this.imageToolbar.plugin, + ...(this.tableHandles ? [this.tableHandles.plugin] : []), ]; }, }); extensions.push(blockNoteUIExtension); - this.schema = newOptions.blockSchema; - this.uploadFile = newOptions.uploadFile; if (newOptions.collaboration && newOptions.initialContent) { @@ -233,6 +347,7 @@ export class BlockNoteEditor { id: UniqueID.options.generateID(), }, ]); + const styleSchema = this.styleSchema; const tiptapOptions: Partial = { ...blockNoteTipTapOptions, @@ -267,7 +382,11 @@ export class BlockNoteEditor { "doc", undefined, schema.node("blockGroup", undefined, [ - blockToNode({ id: "initialBlockId", type: "paragraph" }, schema), + blockToNode( + { id: "initialBlockId", type: "paragraph" }, + schema, + styleSchema + ), ]) ); editor.editor.options.content = root.toJSON(); @@ -278,7 +397,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); @@ -320,13 +439,56 @@ export class BlockNoteEditor { ...newOptions._tiptapOptions?.editorProps?.attributes, ...newOptions.domAttributes?.editor, class: mergeCSSClasses( - styles.bnEditor, - styles.bnRoot, - newOptions.domAttributes?.editor?.class || "", - newOptions.defaultStyles ? styles.defaultStyles : "", + "bn-root", + "bn-editor", + newOptions.defaultStyles ? "bn-default-styles" : "", 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); + }, }, }; @@ -359,11 +521,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; }); @@ -378,12 +548,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") { @@ -394,7 +564,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; }); @@ -408,7 +584,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(); @@ -417,7 +593,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; @@ -458,7 +636,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 @@ -486,15 +668,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 + ), }; } @@ -516,25 +716,42 @@ export class BlockNoteEditor { posBeforeNode + 2 )!; - // For blocks without inline content - if (contentNode.type.spec.content === "") { + const contentType: "none" | "inline" | "table" = + this.blockSchema[contentNode.type.name]!.content; + + if (contentType === "none") { this._tiptapEditor.commands.setNodeSelection(startPos); return; } - if (placement === "start") { - this._tiptapEditor.commands.setTextSelection(startPos + 1); + if (contentType === "inline") { + if (placement === "start") { + this._tiptapEditor.commands.setTextSelection(startPos + 1); + } else { + this._tiptapEditor.commands.setTextSelection( + startPos + contentNode.nodeSize - 1 + ); + } + } else if (contentType === "table") { + if (placement === "start") { + // Need to offset the position as we have to get through the `tableRow` + // and `tableCell` nodes to get to the `tableParagraph` node we want to + // set the selection in. + this._tiptapEditor.commands.setTextSelection(startPos + 4); + } else { + this._tiptapEditor.commands.setTextSelection( + startPos + contentNode.nodeSize - 4 + ); + } } else { - this._tiptapEditor.commands.setTextSelection( - startPos + contentNode.nodeSize - 1 - ); + throw new UnreachableCaseError(contentType); } } /** * Gets a snapshot of the current selection. */ - public getSelection(): Selection | undefined { + public getSelection(): Selection | undefined { // Either the TipTap selection is empty, or it's a node selection. In either // case, it only spans one block, so we return undefined. if ( @@ -545,8 +762,10 @@ 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. this._tiptapEditor.state.doc.descendants((node, pos) => { if (node.type.spec.group !== "blockContent") { return true; @@ -562,7 +781,9 @@ export class BlockNoteEditor { blocks.push( nodeToBlock( this._tiptapEditor.state.doc.resolve(pos).node(), - this.schema, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, this.blockCache ) ); @@ -598,11 +819,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); } /** @@ -614,7 +835,7 @@ export class BlockNoteEditor { */ public updateBlock( blockToUpdate: BlockIdentifier, - update: PartialBlock + update: PartialBlock ) { updateBlock(blockToUpdate, update, this._tiptapEditor); } @@ -636,32 +857,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; } } @@ -672,23 +889,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); } } } @@ -697,7 +911,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)) { @@ -709,23 +923,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); } } } @@ -810,14 +1021,21 @@ export class BlockNoteEditor { this._tiptapEditor.commands.liftListItem("blockContainer"); } + // 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); + public async blocksToHTMLLossy( + blocks = this.topLevelBlocks + ): Promise { + const exporter = createExternalHTMLExporter( + this._tiptapEditor.schema, + this + ); + return exporter.exportBlocks(blocks); } /** @@ -827,8 +1045,16 @@ export class BlockNoteEditor { * @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); + public async tryParseHTMLToBlocks( + html: string + ): Promise[]> { + return HTMLToBlocks( + html, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this._tiptapEditor.schema + ); } /** @@ -837,8 +1063,10 @@ export class BlockNoteEditor { * @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); + public async blocksToMarkdownLossy( + blocks = this.topLevelBlocks + ): Promise { + return blocksToMarkdown(blocks, this._tiptapEditor.schema, this); } /** @@ -848,8 +1076,16 @@ export class BlockNoteEditor { * @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); + public async tryParseMarkdownToBlocks( + markdown: string + ): Promise[]> { + return markdownToBlocks( + markdown, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this._tiptapEditor.schema + ); } /** diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/BlockNoteExtensions.ts index d328c329b7..e7c52358fd 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -2,44 +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 styles from "./editor.module.css"; +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 { CustomBlockSerializerExtension } from "./extensions/Blocks/api/serialization"; -import blockStyles from "./extensions/Blocks/nodes/Block.module.css"; + 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: { @@ -62,9 +67,6 @@ export const getBlockNoteExtensions = (opts: { // DropCursor, Placeholder.configure({ - emptyNodeClass: blockStyles.isEmpty, - hasAnchorClass: blockStyles.hasAnchor, - isFilterClass: blockStyles.isFilter, includeChildren: true, showOnlyCurrent: false, }), @@ -78,33 +80,51 @@ export const getBlockNoteExtensions = (opts: { Text, // marks: - Bold, - Code, - Italic, - Strike, - Underline, Link, - TextColorMark, + ...Object.values(opts.styleSpecs).map((styleSpec) => { + return styleSpec.implementation.mark; + }), + TextColorExtension, - BackgroundColorMark, + BackgroundColorExtension, TextAlignmentExtension, // nodes Doc, BlockContainer.configure({ + editor: opts.editor as any, domAttributes: opts.domAttributes, }), BlockGroup.configure({ domAttributes: opts.domAttributes, }), - ...Object.values(opts.blockSchema).map((blockSpec) => - blockSpec.node.configure({ - editor: opts.editor, - domAttributes: opts.domAttributes, - }) - ), - CustomBlockSerializerExtension, + ...Object.values(opts.inlineContentSpecs) + .filter((a) => a.config !== "link" && a.config !== "text") + .map((inlineContentSpec) => { + return inlineContentSpec.implementation!.node.configure({ + editor: opts.editor as any, + }); + }), + + ...Object.values(opts.blockSpecs).flatMap((blockSpec) => { + return [ + // dependent nodes (e.g.: tablecell / row) + ...(blockSpec.implementation.requiredExtensions || []).map((ext) => + ext.configure({ + editor: opts.editor, + domAttributes: opts.domAttributes, + }) + ), + // the actual node itself + blockSpec.implementation.node.configure({ + editor: opts.editor, + domAttributes: opts.domAttributes, + }), + ]; + }), + createCopyToClipboardExtension(opts.editor), + createPasteFromClipboardExtension(opts.editor), Dropcursor.configure({ width: 5, color: "#ddeeff" }), // This needs to be at the bottom of this list, because Key events (such as enter, when selecting a /command), @@ -122,12 +142,12 @@ export const getBlockNoteExtensions = (opts: { const defaultRender = (user: { color: string; name: string }) => { const cursor = document.createElement("span"); - cursor.classList.add(styles["collaboration-cursor__caret"]); + cursor.classList.add("collaboration-cursor__caret"); cursor.setAttribute("style", `border-color: ${user.color}`); const label = document.createElement("span"); - label.classList.add(styles["collaboration-cursor__label"]); + label.classList.add("collaboration-cursor__label"); label.setAttribute("style", `background-color: ${user.color}`); label.insertBefore(document.createTextNode(user.name), null); diff --git a/packages/core/src/api/blockManipulation/blockManipulation.test.ts b/packages/core/src/api/blockManipulation/blockManipulation.test.ts index 790962015a..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,16 +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(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; - - editor = new BlockNoteEditor(); + editor = BlockNoteEditor.create(); singleBlock = { type: "paragraph", @@ -76,8 +94,52 @@ beforeEach(() => { afterEach(() => { editor._tiptapEditor.destroy(); editor = undefined as any; +}); - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +describe("Test strong typing", () => { + it("checks that block types are inferred correctly", () => { + try { + editor.updateBlock( + { id: "sdf" }, + { + // @ts-expect-error invalid type + type: "non-existing", + } + ); + } catch (e) { + // id doesn't exists, which is fine, this is a compile-time check + } + }); + + it("checks that block props are inferred correctly", () => { + try { + editor.updateBlock( + { id: "sdf" }, + { + type: "paragraph", + props: { + // @ts-expect-error level not suitable for paragraph + level: 1, + }, + } + ); + } catch (e) { + // id doesn't exists, which is fine, this is a compile-time check + } + try { + editor.updateBlock( + { id: "sdf" }, + { + type: "heading", + props: { + level: 1, + }, + } + ); + } catch (e) { + // id doesn't exists, which is fine, this is a compile-time check + } + }); }); describe("Inserting Blocks with Different Placements", () => { 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/exporters/copyExtension.ts b/packages/core/src/api/exporters/copyExtension.ts new file mode 100644 index 0000000000..4b580b1f86 --- /dev/null +++ b/packages/core/src/api/exporters/copyExtension.ts @@ -0,0 +1,70 @@ +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 { createExternalHTMLExporter } from "./html/externalHTMLExporter"; +import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; +import { cleanHTMLToMarkdown } from "./markdown/markdownExporter"; + +export const createCopyToClipboardExtension = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor +) => + Extension.create<{ editor: BlockNoteEditor }, undefined>({ + name: "copyToClipboard", + addProseMirrorPlugins() { + const tiptap = this.editor; + const schema = this.editor.schema; + return [ + new Plugin({ + props: { + handleDOMEvents: { + copy(_view, event) { + // Stops the default browser copy behaviour. + event.preventDefault(); + event.clipboardData!.clearData(); + + const selectedFragment = + tiptap.state.selection.content().content; + + const internalHTMLSerializer = createInternalHTMLSerializer( + schema, + editor + ); + const internalHTML = + internalHTMLSerializer.serializeProseMirrorFragment( + selectedFragment + ); + + const externalHTMLExporter = createExternalHTMLExporter( + schema, + editor + ); + const externalHTML = + externalHTMLExporter.exportProseMirrorFragment( + selectedFragment + ); + + const plainText = cleanHTMLToMarkdown(externalHTML); + + // TODO: Writing to other MIME types not working in Safari for + // some reason. + event.clipboardData!.setData("blocknote/html", internalHTML); + event.clipboardData!.setData("text/html", externalHTML); + event.clipboardData!.setData("text/plain", plainText); + + // Prevent default PM handler to be called + return true; + }, + }, + }, + }), + ]; + }, + }); 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/exporters/html/__snapshots__/customParagraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html new file mode 100644 index 0000000000..1930c65a95 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html @@ -0,0 +1 @@ +

    Hello World

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

    Custom Paragraph

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

    Hello World

    Hello World

    Hello World

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

    Custom Paragraph

    Nested Custom Paragraph 1

    Nested Custom Paragraph 2

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

    Hello World

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

    Plain Red Text Blue Background Mixed Colors

    \ No newline at end of file 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/exporters/html/__snapshots__/image/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html new file mode 100644 index 0000000000..f214a9a441 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html @@ -0,0 +1 @@ +
    Caption
    \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html new file mode 100644 index 0000000000..080ccf3ce4 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html @@ -0,0 +1 @@ +
    placeholder

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

    Add Image

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

    \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html new file mode 100644 index 0000000000..1a4a0986a2 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html @@ -0,0 +1 @@ +
    Caption
    Caption
    \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html new file mode 100644 index 0000000000..5c81aa0f04 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html @@ -0,0 +1 @@ +
    placeholder

    placeholder

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

    Paragraph

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

    Paragraph

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

    Paragraph

    Nested Paragraph 1

    Nested Paragraph 2

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

    Paragraph

    Nested Paragraph 1

    Nested Paragraph 2

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

    Plain Red Text Blue Background Mixed Colors

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

    Plain Red Text Blue Background Mixed Colors

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

    Custom Paragraph

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

    Custom Paragraph

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

    Custom Paragraph

    Nested Custom Paragraph 1

    Nested Custom Paragraph 2

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

    Custom Paragraph

    Nested Custom Paragraph 1

    Nested Custom Paragraph 2

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

    Plain Red Text Blue Background Mixed Colors

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

    Plain Red Text Blue Background Mixed Colors

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

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

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

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

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

    placeholder

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

    placeholder

    \ No newline at end of file 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/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts new file mode 100644 index 0000000000..8d62dd587c --- /dev/null +++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts @@ -0,0 +1,98 @@ +import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; +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/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 "./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 +// `blockSpec`, or `toInternalHTML` if `toExternalHTML` is not defined. +// +// The HTML created by this serializer is different to what's rendered by the +// editor to the DOM. This also means that data is likely to be lost when +// converting back to original blocks. The differences in the output HTML are: +// 1. It doesn't include the `blockGroup` and `blockContainer` wrappers meaning +// that nesting is not preserved for non-list-item blocks. +// 2. `li` items in the output HTML are wrapped in `ul` or `ol` elements. +// 3. While nesting for list items is preserved, other types of blocks nested +// inside a list are un-nested and a new list is created after them. +// 4. The HTML is wrapped in a single `div` element. +// +// The serializer has 2 main methods: +// `exportBlocks`: Exports an array of blocks to HTML. +// `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< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> { + exportBlocks: (blocks: PartialBlock[]) => string; + exportProseMirrorFragment: (fragment: Fragment) => string; +} + +export const createExternalHTMLExporter = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + schema: Schema, + editor: BlockNoteEditor +): ExternalHTMLExporter => { + const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { + serializeNodeInner: ( + node: Node, + options: { document?: Document } + ) => HTMLElement; + // 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; + }; + + serializer.serializeNodeInner = ( + node: Node, + options: { document?: Document } + ) => serializeNodeInner(node, options, serializer, editor, true); + + // Like the `internalHTMLSerializer`, also uses `serializeProseMirrorFragment` + // but additionally runs it through the `simplifyBlocks` rehype plugin to + // convert the internal HTML to external. + serializer.exportProseMirrorFragment = (fragment) => { + const externalHTML = unified() + .use(rehypeParse, { fragment: true }) + .use(simplifyBlocks, { + orderedListItemBlockTypes: new Set(["numberedListItem"]), + unorderedListItemBlockTypes: new Set(["bulletListItem"]), + }) + .use(rehypeStringify) + .processSync(serializeProseMirrorFragment(fragment, serializer)); + + return externalHTML.value as string; + }; + + 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)); + }; + + return serializer; +}; 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/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts new file mode 100644 index 0000000000..77785dd0ac --- /dev/null +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -0,0 +1,80 @@ +import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; +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"; +import { blockToNode } from "../../nodeConversions/nodeConversions"; +import { + serializeNodeInner, + serializeProseMirrorFragment, +} 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 +// `blockSpec`. +// +// The HTML created by this serializer is the same as what's rendered by the +// editor to the DOM. This means that it retains the same structure as the +// editor, including the `blockGroup` and `blockContainer` wrappers. This also +// means that it can be converted back to the original blocks without any data +// loss. +// +// The serializer has 2 main methods: +// `serializeFragment`: Serializes a ProseMirror fragment to HTML. This is +// 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< + 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; +} + +export const createInternalHTMLSerializer = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + schema: Schema, + editor: BlockNoteEditor +): InternalHTMLSerializer => { + const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { + serializeNodeInner: ( + node: Node, + options: { document?: Document } + ) => HTMLElement; + serializeBlocks: (blocks: PartialBlock[]) => string; + serializeProseMirrorFragment: ( + fragment: Fragment, + options?: { document?: Document | undefined } | undefined, + target?: HTMLElement | DocumentFragment | undefined + ) => string; + }; + + serializer.serializeNodeInner = ( + node: Node, + options: { document?: Document } + ) => serializeNodeInner(node, options, serializer, editor, false); + + serializer.serializeProseMirrorFragment = (fragment: Fragment) => + serializeProseMirrorFragment(fragment, serializer); + + 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)); + }; + + return serializer; +}; diff --git a/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts new file mode 100644 index 0000000000..03f45db17f --- /dev/null +++ b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts @@ -0,0 +1,126 @@ +import { DOMSerializer, Fragment, Node } from "prosemirror-model"; + +import { BlockNoteEditor } from "../../../../BlockNoteEditor"; +import { BlockSchema } from "../../../../extensions/Blocks/api/blocks/types"; +import { InlineContentSchema } from "../../../../extensions/Blocks/api/inlineContent/types"; +import { StyleSchema } from "../../../../extensions/Blocks/api/styles/types"; +import { nodeToBlock } from "../../../nodeConversions/nodeConversions"; + +function doc(options: { document?: Document }) { + return options.document || window.document; +} + +// Used to implement `serializeNodeInner` for the `internalHTMLSerializer` and +// `externalHTMLExporter`. Changes how the content of `blockContainer` nodes is +// serialized vs the default `DOMSerializer` implementation. For the +// `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 = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + node: Node, + options: { document?: Document }, + serializer: DOMSerializer, + 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) + ); + + if (contentDOM) { + if (node.isLeaf) { + throw new RangeError("Content hole not allowed in a leaf node spec"); + } + + // Handles converting `blockContainer` nodes to HTML. + if (node.type.name === "blockContainer") { + const blockContentNode = + node.childCount > 0 && + node.firstChild!.type.spec.group === "blockContent" + ? node.firstChild! + : undefined; + const blockGroupNode = + node.childCount > 0 && node.lastChild!.type.spec.group === "blockGroup" + ? node.lastChild! + : undefined; + + // Converts `blockContent` node using the custom `blockSpec`'s + // `toExternalHTML` or `toInternalHTML` function. + // Note: While `blockContainer` nodes should always contain a + // `blockContent` node according to the schema, PM Fragments don't always + // conform to the schema. This is unintuitive but important as it occurs + // when copying only nested blocks. + if (blockContentNode !== undefined) { + const impl = + editor.blockImplementations[blockContentNode.type.name] + .implementation; + const toHTML = toExternalHTML + ? impl.toExternalHTML + : impl.toInternalHTML; + const blockContent = toHTML( + nodeToBlock( + node, + editor.blockSchema, + editor.inlineContentSchema, + editor.styleSchema, + editor.blockCache + ), + editor as any + ); + + // Converts inline nodes in the `blockContent` node's content to HTML + // using their `renderHTML` methods. + if (blockContent.contentDOM !== undefined) { + if (node.isLeaf) { + throw new RangeError( + "Content hole not allowed in a leaf node spec" + ); + } + + blockContent.contentDOM.appendChild( + serializer.serializeFragment(blockContentNode.content, options) + ); + } + + contentDOM.appendChild(blockContent.dom); + } + + // Converts `blockGroup` node to HTML using its `renderHTML` method. + if (blockGroupNode !== undefined) { + serializer.serializeFragment( + Fragment.from(blockGroupNode), + options, + contentDOM + ); + } + } else { + // Converts the node normally, i.e. using its `renderHTML method`. + serializer.serializeFragment(node.content, options, contentDOM); + } + } + + return dom as HTMLElement; +}; + +// Used to implement `serializeProseMirrorFragment` for the +// `internalHTMLSerializer` and `externalHTMLExporter`. Does basically the same +// thing as `serializer.serializeFragment`, but takes fewer arguments and +// returns a string instead, to make it easier to use. +export const serializeProseMirrorFragment = ( + fragment: Fragment, + serializer: DOMSerializer +) => { + const internalHTML = serializer.serializeFragment(fragment); + const parent = document.createElement("div"); + parent.appendChild(internalHTML); + + return parent.innerHTML; +}; diff --git a/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts similarity index 91% rename from packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts rename to packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts index 13fa69e783..ba025dc5b0 100644 --- a/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts +++ b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts @@ -22,6 +22,19 @@ export function simplifyBlocks(options: SimplifyBlocksOptions) { ]); const simplifyBlocksHelper = (tree: HASTParent) => { + // Checks whether blocks in the tree are wrapped by a parent `blockGroup` + // element, in which case the `blockGroup`'s children are lifted out, and it + // is removed. + if ( + tree.children.length === 1 && + (tree.children[0] as HASTElement).properties?.["dataNodeType"] === + "blockGroup" + ) { + const blockGroup = tree.children[0] as HASTElement; + tree.children.pop(); + tree.children.push(...blockGroup.children); + } + let numChildElements = tree.children.length; let activeList: HASTElement | undefined; 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.test.ts b/packages/core/src/api/formatConversions/formatConversions.test.ts deleted file mode 100644 index bcf6714acf..0000000000 --- a/packages/core/src/api/formatConversions/formatConversions.test.ts +++ /dev/null @@ -1,753 +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(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; - - editor = new BlockNoteEditor(); -}); - -afterEach(() => { - editor._tiptapEditor.destroy(); - editor = undefined as any; - - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; -}); - -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 5e64dfa6b5..0000000000 --- a/packages/core/src/api/formatConversions/formatConversions.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { DOMParser, DOMSerializer, Schema } from "prosemirror-model"; -import rehypeParse from "rehype-parse"; -import rehypeRemark from "rehype-remark"; -import rehypeStringify from "rehype-stringify"; -import remarkGfm from "remark-gfm"; -import remarkParse from "remark-parse"; -import remarkRehype, { defaultHandlers } from "remark-rehype"; -import remarkStringify from "remark-stringify"; -import { unified } from "unified"; -import { Block, BlockSchema } from "../../extensions/Blocks/api/blockTypes"; - -import { blockToNode, nodeToBlock } from "../nodeConversions/nodeConversions"; -import { removeUnderlines } from "./removeUnderlinesRehypePlugin"; -import { simplifyBlocks } from "./simplifyBlocksRehypePlugin"; - -export async function blocksToHTML( - blocks: Block[], - schema: Schema -): Promise { - const htmlParentElement = document.createElement("div"); - const serializer = DOMSerializer.fromSchema(schema); - - 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 -): Promise { - const markdownString = await unified() - .use(rehypeParse, { fragment: true }) - .use(removeUnderlines) - .use(rehypeRemark) - .use(remarkGfm) - .use(remarkStringify) - .process(await blocksToHTML(blocks, schema)); - - 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);
    -}
    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 37bdadfdb9..be1d1cfaf2 100644
    --- a/packages/core/src/api/nodeConversions/nodeConversions.test.ts
    +++ b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
    @@ -1,501 +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(() => {
    -  (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {};
    -
    -  editor = new BlockNoteEditor();
    -  tt = editor._tiptapEditor;
    -});
    -
    -afterEach(() => {
    -  tt.destroy();
    -  editor = undefined as any;
    -  tt = undefined as any;
    -
    -  delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
    -});
    -
    -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 e20ff08315..391fbe1e5d 100644
    --- a/packages/core/src/api/nodeConversions/nodeConversions.ts
    +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts
    @@ -4,41 +4,51 @@ import {
       Block,
       BlockSchema,
       PartialBlock,
    -} from "../../extensions/Blocks/api/blockTypes";
    +  PartialTableContent,
    +  TableContent,
    +} from "../../extensions/Blocks/api/blocks/types";
     import {
    -  ColorStyle,
    +  CustomInlineContentConfig,
    +  CustomInlineContentFromConfig,
       InlineContent,
    +  InlineContentFromConfig,
    +  InlineContentSchema,
    +  PartialCustomInlineContentFromConfig,
       PartialInlineContent,
       PartialLink,
       StyledText,
    -  Styles,
    -  ToggledStyle,
    -} from "../../extensions/Blocks/api/inlineContentTypes";
    +  isLinkInlineContent,
    +  isPartialLinkInlineContent,
    +  isStyledTextInlineContent,
    +} from "../../extensions/Blocks/api/inlineContent/types";
    +import { StyleSchema, Styles } from "../../extensions/Blocks/api/styles/types";
     import { getBlockInfo } from "../../extensions/Blocks/helpers/getBlockInfoFromPos";
     import UniqueID from "../../extensions/UniqueID/UniqueID";
     import { UnreachableCaseError } from "../../shared/utils";
     
    -const toggleStyles = new Set([
    -  "bold",
    -  "italic",
    -  "underline",
    -  "strike",
    -  "code",
    -]);
    -const colorStyles = new Set(["textColor", "backgroundColor"]);
    -
     /**
      * Convert a StyledText inline element to a
      * prosemirror text node with the appropriate marks
      */
    -function styledTextToNodes(styledText: StyledText, schema: Schema): Node[] {
    +function styledTextToNodes(
    +  styledText: StyledText,
    +  schema: Schema,
    +  styleSchema: T
    +): Node[] {
       const marks: Mark[] = [];
     
       for (const [style, value] of Object.entries(styledText.styles)) {
    -    if (toggleStyles.has(style as ToggledStyle)) {
    +    const config = styleSchema[style];
    +    if (!config) {
    +      throw new Error(`style ${style} not found in styleSchema`);
    +    }
    +
    +    if (config.propSchema === "boolean") {
           marks.push(schema.mark(style));
    -    } else if (colorStyles.has(style as ColorStyle)) {
    -      marks.push(schema.mark(style, { color: value }));
    +    } else if (config.propSchema === "string") {
    +      marks.push(schema.mark(style, { stringValue: value }));
    +    } else {
    +      throw new UnreachableCaseError(config.propSchema);
         }
       }
     
    @@ -64,42 +74,53 @@ function styledTextToNodes(styledText: StyledText, schema: Schema): Node[] {
      * Converts a Link inline content element to
      * prosemirror text nodes with the appropriate marks
      */
    -function linkToNodes(link: PartialLink, schema: Schema): Node[] {
    +function linkToNodes(
    +  link: PartialLink,
    +  schema: Schema,
    +  styleSchema: StyleSchema
    +): Node[] {
       const linkMark = schema.marks.link.create({
         href: link.href,
       });
     
    -  return styledTextArrayToNodes(link.content, schema).map((node) => {
    -    if (node.type.name === "text") {
    -      return node.mark([...node.marks, linkMark]);
    -    }
    +  return styledTextArrayToNodes(link.content, schema, styleSchema).map(
    +    (node) => {
    +      if (node.type.name === "text") {
    +        return node.mark([...node.marks, linkMark]);
    +      }
     
    -    if (node.type.name === "hardBreak") {
    -      return node;
    +      if (node.type.name === "hardBreak") {
    +        return node;
    +      }
    +      throw new Error("unexpected node type");
         }
    -    throw new Error("unexpected node type");
    -  });
    +  );
     }
     
     /**
      * Converts an array of StyledText inline content elements to
      * prosemirror text nodes with the appropriate marks
      */
    -function styledTextArrayToNodes(
    -  content: string | StyledText[],
    -  schema: Schema
    +function styledTextArrayToNodes(
    +  content: string | StyledText[],
    +  schema: Schema,
    +  styleSchema: S
     ): Node[] {
       const nodes: Node[] = [];
     
       if (typeof content === "string") {
         nodes.push(
    -      ...styledTextToNodes({ type: "text", text: content, styles: {} }, schema)
    +      ...styledTextToNodes(
    +        { type: "text", text: content, styles: {} },
    +        schema,
    +        styleSchema
    +      )
         );
         return nodes;
       }
     
       for (const styledText of content) {
    -    nodes.push(...styledTextToNodes(styledText, schema));
    +    nodes.push(...styledTextToNodes(styledText, schema, styleSchema));
       }
       return nodes;
     }
    @@ -107,44 +128,85 @@ function styledTextArrayToNodes(
     /**
      * converts an array of inline content elements to prosemirror nodes
      */
    -export function inlineContentToNodes(
    -  blockContent: PartialInlineContent[],
    -  schema: Schema
    +export function inlineContentToNodes<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  blockContent: PartialInlineContent,
    +  schema: Schema,
    +  styleSchema: S
     ): Node[] {
       const nodes: Node[] = [];
     
       for (const content of blockContent) {
    -    if (content.type === "link") {
    -      nodes.push(...linkToNodes(content, schema));
    -    } else if (content.type === "text") {
    -      nodes.push(...styledTextArrayToNodes([content], schema));
    +    if (typeof content === "string") {
    +      nodes.push(...styledTextArrayToNodes(content, schema, styleSchema));
    +    } else if (isPartialLinkInlineContent(content)) {
    +      nodes.push(...linkToNodes(content, schema, styleSchema));
    +    } else if (isStyledTextInlineContent(content)) {
    +      nodes.push(...styledTextArrayToNodes([content], schema, styleSchema));
         } else {
    -      throw new UnreachableCaseError(content);
    +      nodes.push(
    +        blockOrInlineContentToContentNode(content, schema, styleSchema)
    +      );
         }
       }
       return nodes;
     }
     
     /**
    - * Converts a BlockNote block to a TipTap node.
    + * converts an array of inline content elements to prosemirror nodes
      */
    -export function blockToNode(
    -  block: PartialBlock,
    -  schema: Schema
    -) {
    -  let id = block.id;
    +export function tableContentToNodes<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  tableContent: PartialTableContent,
    +  schema: Schema,
    +  styleSchema: StyleSchema
    +): Node[] {
    +  const rowNodes: Node[] = [];
    +
    +  for (const row of tableContent.rows) {
    +    const columnNodes: Node[] = [];
    +    for (const cell of row.cells) {
    +      let pNode: Node;
    +      if (!cell) {
    +        pNode = schema.nodes["tableParagraph"].create({});
    +      } else if (typeof cell === "string") {
    +        pNode = schema.nodes["tableParagraph"].create({}, schema.text(cell));
    +      } else {
    +        const textNodes = inlineContentToNodes(cell, schema, styleSchema);
    +        pNode = schema.nodes["tableParagraph"].create({}, textNodes);
    +      }
     
    -  if (id === undefined) {
    -    id = UniqueID.options.generateID();
    +      const cellNode = schema.nodes["tableCell"].create({}, pNode);
    +      columnNodes.push(cellNode);
    +    }
    +    const rowNode = schema.nodes["tableRow"].create({}, columnNodes);
    +    rowNodes.push(rowNode);
       }
    +  return rowNodes;
    +}
     
    +function blockOrInlineContentToContentNode(
    +  block:
    +    | PartialBlock
    +    | PartialCustomInlineContentFromConfig,
    +  schema: Schema,
    +  styleSchema: StyleSchema
    +) {
    +  let contentNode: Node;
       let type = block.type;
     
    +  // TODO: needed? came from previous code
       if (type === undefined) {
         type = "paragraph";
       }
     
    -  let contentNode: Node;
    +  if (!schema.nodes[type]) {
    +    throw new Error(`node type ${type} not found in schema`);
    +  }
     
       if (!block.content) {
         contentNode = schema.nodes[type].create(block.props);
    @@ -153,16 +215,42 @@ export function blockToNode(
           block.props,
           schema.text(block.content)
         );
    -  } else {
    -    const nodes = inlineContentToNodes(block.content, schema);
    +  } else if (Array.isArray(block.content)) {
    +    const nodes = inlineContentToNodes(block.content, schema, styleSchema);
    +    contentNode = schema.nodes[type].create(block.props, nodes);
    +  } else if (block.content.type === "tableContent") {
    +    const nodes = tableContentToNodes(block.content, schema, styleSchema);
         contentNode = schema.nodes[type].create(block.props, nodes);
    +  } else {
    +    throw new UnreachableCaseError(block.content.type);
    +  }
    +  return contentNode;
    +}
    +/**
    + * Converts a BlockNote block to a TipTap node.
    + */
    +export function blockToNode(
    +  block: PartialBlock,
    +  schema: Schema,
    +  styleSchema: StyleSchema
    +) {
    +  let id = block.id;
    +
    +  if (id === undefined) {
    +    id = UniqueID.options.generateID();
       }
     
    +  const contentNode = blockOrInlineContentToContentNode(
    +    block,
    +    schema,
    +    styleSchema
    +  );
    +
       const children: Node[] = [];
     
       if (block.children) {
         for (const child of block.children) {
    -      children.push(blockToNode(child, schema));
    +      children.push(blockToNode(child, schema, styleSchema));
         }
       }
     
    @@ -177,12 +265,48 @@ export function blockToNode(
       );
     }
     
    +/**
    + * Converts an internal (prosemirror) table node contentto a BlockNote Tablecontent
    + */
    +function contentNodeToTableContent<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
    +  const ret: TableContent = {
    +    type: "tableContent",
    +    rows: [],
    +  };
    +
    +  contentNode.content.forEach((rowNode) => {
    +    const row: TableContent["rows"][0] = {
    +      cells: [],
    +    };
    +
    +    rowNode.content.forEach((cellNode) => {
    +      row.cells.push(
    +        contentNodeToInlineContent(
    +          cellNode.firstChild!,
    +          inlineContentSchema,
    +          styleSchema
    +        )
    +      );
    +    });
    +
    +    ret.rows.push(row);
    +  });
    +
    +  return ret;
    +}
    +
     /**
      * Converts an internal (prosemirror) content node to a BlockNote InlineContent array.
      */
    -function contentNodeToInlineContent(contentNode: Node) {
    -  const content: InlineContent[] = [];
    -  let currentContent: InlineContent | undefined = undefined;
    +export function contentNodeToInlineContent<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
    +  const content: InlineContent[] = [];
    +  let currentContent: InlineContent | undefined = undefined;
     
       // Most of the logic below is for handling links because in ProseMirror links are marks
       // while in BlockNote links are a type of inline content
    @@ -192,13 +316,15 @@ function contentNodeToInlineContent(contentNode: Node) {
         if (node.type.name === "hardBreak") {
           if (currentContent) {
             // Current content exists.
    -        if (currentContent.type === "text") {
    +        if (isStyledTextInlineContent(currentContent)) {
               // Current content is text.
               currentContent.text += "\n";
    -        } else if (currentContent.type === "link") {
    +        } else if (isLinkInlineContent(currentContent)) {
               // Current content is a link.
               currentContent.content[currentContent.content.length - 1].text +=
                 "\n";
    +        } else {
    +          throw new Error("unexpected");
             }
           } else {
             // Current content does not exist.
    @@ -212,18 +338,41 @@ function contentNodeToInlineContent(contentNode: Node) {
           return;
         }
     
    -    const styles: Styles = {};
    +    if (
    +      node.type.name !== "link" &&
    +      node.type.name !== "text" &&
    +      inlineContentSchema[node.type.name]
    +    ) {
    +      if (currentContent) {
    +        content.push(currentContent);
    +        currentContent = undefined;
    +      }
    +
    +      content.push(
    +        nodeToCustomInlineContent(node, inlineContentSchema, styleSchema)
    +      );
    +
    +      return;
    +    }
    +
    +    const styles: Styles = {};
         let linkMark: Mark | undefined;
     
         for (const mark of node.marks) {
           if (mark.type.name === "link") {
             linkMark = mark;
    -      } else if (toggleStyles.has(mark.type.name as ToggledStyle)) {
    -        styles[mark.type.name as ToggledStyle] = true;
    -      } else if (colorStyles.has(mark.type.name as ColorStyle)) {
    -        styles[mark.type.name as ColorStyle] = mark.attrs.color;
           } else {
    -        throw Error("Mark is of an unrecognized type: " + mark.type.name);
    +        const config = styleSchema[mark.type.name];
    +        if (!config) {
    +          throw new Error(`style ${mark.type.name} not found in styleSchema`);
    +        }
    +        if (config.propSchema === "boolean") {
    +          (styles as any)[config.type] = true;
    +        } else if (config.propSchema === "string") {
    +          (styles as any)[config.type] = mark.attrs.stringValue;
    +        } else {
    +          throw new UnreachableCaseError(config.propSchema);
    +        }
           }
         }
     
    @@ -231,7 +380,7 @@ function contentNodeToInlineContent(contentNode: Node) {
         // Current content exists.
         if (currentContent) {
           // Current content is text.
    -      if (currentContent.type === "text") {
    +      if (isStyledTextInlineContent(currentContent)) {
             if (!linkMark) {
               // Node is text (same type as current content).
               if (
    @@ -263,7 +412,7 @@ function contentNodeToInlineContent(contentNode: Node) {
                 ],
               };
             }
    -      } else if (currentContent.type === "link") {
    +      } else if (isLinkInlineContent(currentContent)) {
             // Current content is a link.
             if (linkMark) {
               // Node is a link (same type as current content).
    @@ -309,6 +458,8 @@ function contentNodeToInlineContent(contentNode: Node) {
                 styles,
               };
             }
    +      } else {
    +        // TODO
           }
         }
         // Current content does not exist.
    @@ -342,17 +493,66 @@ function contentNodeToInlineContent(contentNode: Node) {
         content.push(currentContent);
       }
     
    -  return content;
    +  return content as InlineContent[];
    +}
    +
    +export function nodeToCustomInlineContent<
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(node: Node, inlineContentSchema: I, styleSchema: S): InlineContent {
    +  if (node.type.name === "text" || node.type.name === "link") {
    +    throw new Error("unexpected");
    +  }
    +  const props: any = {};
    +  const icConfig = inlineContentSchema[
    +    node.type.name
    +  ] as CustomInlineContentConfig;
    +  for (const [attr, value] of Object.entries(node.attrs)) {
    +    if (!icConfig) {
    +      throw Error("ic node is of an unrecognized type: " + node.type.name);
    +    }
    +
    +    const propSchema = icConfig.propSchema;
    +
    +    if (attr in propSchema) {
    +      props[attr] = value;
    +    }
    +  }
    +
    +  let content: CustomInlineContentFromConfig["content"];
    +
    +  if (icConfig.content === "styled") {
    +    content = contentNodeToInlineContent(
    +      node,
    +      inlineContentSchema,
    +      styleSchema
    +    ) as any; // TODO: is this safe? could we have Links here that are undesired?
    +  } else {
    +    content = undefined;
    +  }
    +
    +  const ic = {
    +    type: node.type.name,
    +    props,
    +    content,
    +  } as InlineContentFromConfig;
    +  return ic;
     }
     
     /**
      * Convert a TipTap node to a BlockNote block.
      */
    -export function nodeToBlock(
    +export function nodeToBlock<
    +  BSchema extends BlockSchema,
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
       node: Node,
       blockSchema: BSchema,
    -  blockCache?: WeakMap>
    -): Block {
    +  inlineContentSchema: I,
    +  styleSchema: S,
    +  blockCache?: WeakMap>
    +): Block {
       if (node.type.name !== "blockContainer") {
         throw Error(
           "Node must be of type blockContainer, but is of type" +
    @@ -382,6 +582,7 @@ export function nodeToBlock(
         ...blockInfo.contentNode.attrs,
       })) {
         const blockSpec = blockSchema[blockInfo.contentType.name];
    +
         if (!blockSpec) {
           throw Error(
             "Block is of an unrecognized type: " + blockInfo.contentType.name
    @@ -395,25 +596,48 @@ export function nodeToBlock(
         }
       }
     
    -  const blockSpec = blockSchema[blockInfo.contentType.name];
    +  const blockConfig = blockSchema[blockInfo.contentType.name];
     
    -  const children: Block[] = [];
    +  const children: Block[] = [];
       for (let i = 0; i < blockInfo.numChildBlocks; i++) {
         children.push(
    -      nodeToBlock(node.lastChild!.child(i), blockSchema, blockCache)
    +      nodeToBlock(
    +        node.lastChild!.child(i),
    +        blockSchema,
    +        inlineContentSchema,
    +        styleSchema,
    +        blockCache
    +      )
         );
       }
     
    -  const block: Block = {
    +  let content: Block["content"];
    +
    +  if (blockConfig.content === "inline") {
    +    content = contentNodeToInlineContent(
    +      blockInfo.contentNode,
    +      inlineContentSchema,
    +      styleSchema
    +    );
    +  } else if (blockConfig.content === "table") {
    +    content = contentNodeToTableContent(
    +      blockInfo.contentNode,
    +      inlineContentSchema,
    +      styleSchema
    +    );
    +  } else if (blockConfig.content === "none") {
    +    content = undefined;
    +  } else {
    +    throw new UnreachableCaseError(blockConfig.content);
    +  }
    +
    +  const block = {
         id,
    -    type: blockSpec.node.name,
    +    type: blockConfig.type,
         props,
    -    content:
    -      blockSpec.node.config.content === "inline*"
    -        ? contentNodeToInlineContent(blockInfo.contentNode)
    -        : undefined,
    +    content,
         children,
    -  } as Block;
    +  } as Block;
     
       blockCache?.set(node, block);
     
    diff --git a/packages/core/src/api/nodeConversions/testUtil.ts b/packages/core/src/api/nodeConversions/testUtil.ts
    index c1740b0120..3398e19d2d 100644
    --- a/packages/core/src/api/nodeConversions/testUtil.ts
    +++ b/packages/core/src/api/nodeConversions/testUtil.ts
    @@ -2,16 +2,22 @@ import {
       Block,
       BlockSchema,
       PartialBlock,
    -} from "../../extensions/Blocks/api/blockTypes";
    +  TableContent,
    +} from "../../extensions/Blocks/api/blocks/types";
     import {
       InlineContent,
    +  InlineContentSchema,
       PartialInlineContent,
       StyledText,
    -} from "../../extensions/Blocks/api/inlineContentTypes";
    +  isPartialLinkInlineContent,
    +  isStyledTextInlineContent,
    +} from "../../extensions/Blocks/api/inlineContent/types";
    +import { StyleSchema } from "../../extensions/Blocks/api/styles/types";
    +import UniqueID from "../../extensions/UniqueID/UniqueID";
     
     function textShorthandToStyledText(
    -  content: string | StyledText[] = ""
    -): StyledText[] {
    +  content: string | StyledText[] = ""
    +): StyledText[] {
       if (typeof content === "string") {
         return [
           {
    @@ -25,41 +31,97 @@ function textShorthandToStyledText(
     }
     
     function partialContentToInlineContent(
    -  content: string | PartialInlineContent[] = ""
    -): InlineContent[] {
    +  content: PartialInlineContent | TableContent | undefined
    +): InlineContent[] | TableContent | undefined {
       if (typeof content === "string") {
         return textShorthandToStyledText(content);
       }
     
    -  return content.map((partialContent) => {
    -    if (partialContent.type === "link") {
    -      return {
    -        ...partialContent,
    -        content: textShorthandToStyledText(partialContent.content),
    -      };
    -    } else {
    -      return partialContent;
    -    }
    -  });
    +  if (Array.isArray(content)) {
    +    return content.flatMap((partialContent) => {
    +      if (typeof partialContent === "string") {
    +        return textShorthandToStyledText(partialContent);
    +      } else if (isPartialLinkInlineContent(partialContent)) {
    +        return {
    +          ...partialContent,
    +          content: textShorthandToStyledText(partialContent.content),
    +        };
    +      } else if (isStyledTextInlineContent(partialContent)) {
    +        return partialContent;
    +      } else {
    +        // custom inline content
    +
    +        return {
    +          props: {},
    +          ...partialContent,
    +          content: partialContentToInlineContent(partialContent.content),
    +        } as any;
    +      }
    +    });
    +  }
    +
    +  return content;
     }
     
    -export function partialBlockToBlockForTesting(
    -  partialBlock: PartialBlock
    -): Block {
    -  const withDefaults = {
    +export function partialBlocksToBlocksForTesting<
    +  BSchema extends BlockSchema,
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  schema: BSchema,
    +  partialBlocks: Array>
    +): Array> {
    +  return partialBlocks.map((partialBlock) =>
    +    partialBlockToBlockForTesting(schema, partialBlock)
    +  );
    +}
    +
    +export function partialBlockToBlockForTesting<
    +  BSchema extends BlockSchema,
    +  I extends InlineContentSchema,
    +  S extends StyleSchema
    +>(
    +  schema: BSchema,
    +  partialBlock: PartialBlock
    +): Block {
    +  const withDefaults: Block = {
         id: "",
    -    type: "paragraph",
    -    // because at this point we don't have an easy way to access default props at runtime,
    -    // partialBlockToBlockForTesting will not set them.
    +    type: partialBlock.type!,
         props: {} as any,
    -    content: [] as any,
    -    children: [],
    +    content:
    +      schema[partialBlock.type!].content === "inline" ? [] : (undefined as any),
    +    children: [] as any,
         ...partialBlock,
    -  } satisfies PartialBlock;
    +  };
    +
    +  Object.entries(schema[partialBlock.type!].propSchema).forEach(
    +    ([propKey, propValue]) => {
    +      if (withDefaults.props[propKey] === undefined) {
    +        (withDefaults.props as any)[propKey] = propValue.default;
    +      }
    +    }
    +  );
     
       return {
         ...withDefaults,
         content: partialContentToInlineContent(withDefaults.content),
    -    children: withDefaults.children.map(partialBlockToBlockForTesting),
    -  };
    +    children: withDefaults.children.map((c) => {
    +      return partialBlockToBlockForTesting(schema, c);
    +    }),
    +  } as any;
    +}
    +
    +export function addIdsToBlock(block: PartialBlock) {
    +  if (!block.id) {
    +    block.id = UniqueID.options.generateID();
    +  }
    +  if (block.children) {
    +    addIdsToBlocks(block.children);
    +  }
    +}
    +
    +export function addIdsToBlocks(blocks: PartialBlock[]) {
    +  for (const block of blocks) {
    +    addIdsToBlock(block);
    +  }
     }
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json b/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json
    new file mode 100644
    index 0000000000..7ef10bf491
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json
    @@ -0,0 +1,105 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "First",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Second",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "3",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Third",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "4",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Five Parent",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "5",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Child 1",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "6",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Child 2",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json
    new file mode 100644
    index 0000000000..2d11e081f6
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json
    @@ -0,0 +1,140 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 1
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 1",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 2
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 2",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "3",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 3
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 3",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "4",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Paragraph",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "5",
    +    "type": "image",
    +    "props": {
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "url": "exampleURL",
    +      "caption": "Image Caption",
    +      "width": 512
    +    },
    +    "children": []
    +  },
    +  {
    +    "id": "6",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "None ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "Bold ",
    +        "styles": {
    +          "bold": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Italic ",
    +        "styles": {
    +          "italic": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Underline ",
    +        "styles": {
    +          "underline": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Strikethrough ",
    +        "styles": {
    +          "strike": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "All",
    +        "styles": {
    +          "bold": true,
    +          "italic": true,
    +          "underline": true,
    +          "strike": true
    +        }
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json
    new file mode 100644
    index 0000000000..ae11e36cb7
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json
    @@ -0,0 +1,240 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Outer 1 Div Before",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": " Outer 2 Div Before",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "3",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": " Outer 3 Div Before",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "4",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": " Outer 4 Div Before",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "5",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 1
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 1",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "6",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 2
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 2",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "7",
    +    "type": "heading",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "level": 3
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Heading 3",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "8",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Paragraph",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "9",
    +    "type": "image",
    +    "props": {
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "url": "exampleURL",
    +      "caption": "Image Caption",
    +      "width": 512
    +    },
    +    "children": []
    +  },
    +  {
    +    "id": "10",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bold",
    +        "styles": {
    +          "bold": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": " ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "Italic",
    +        "styles": {
    +          "italic": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": " ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "Underline",
    +        "styles": {
    +          "underline": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": " ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "Strikethrough",
    +        "styles": {
    +          "strike": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": " ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "All",
    +        "styles": {
    +          "bold": true,
    +          "italic": true,
    +          "underline": true,
    +          "strike": true
    +        }
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "11",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": " Outer 4 Div After Outer 3 Div After Outer 2 Div After Outer 1 Div After",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json
    new file mode 100644
    index 0000000000..d06969a05f
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json
    @@ -0,0 +1,91 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "None ",
    +        "styles": {}
    +      },
    +      {
    +        "type": "text",
    +        "text": "Bold ",
    +        "styles": {
    +          "bold": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Italic ",
    +        "styles": {
    +          "italic": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Underline ",
    +        "styles": {
    +          "underline": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "Strikethrough ",
    +        "styles": {
    +          "strike": true
    +        }
    +      },
    +      {
    +        "type": "text",
    +        "text": "All",
    +        "styles": {
    +          "bold": true,
    +          "italic": true,
    +          "underline": true,
    +          "strike": true
    +        }
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "3",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Paragraph",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json
    new file mode 100644
    index 0000000000..764afd66ac
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json
    @@ -0,0 +1,121 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Single Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": " Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "3",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "4",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "5",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Single Div 2",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "6",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "7",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Nested Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json
    new file mode 100644
    index 0000000000..86a0cb8168
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json
    @@ -0,0 +1,31 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "image",
    +    "props": {
    +      "backgroundColor": "default",
    +      "textAlignment": "left",
    +      "url": "exampleURL",
    +      "caption": "",
    +      "width": 512
    +    },
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Image Caption",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json
    new file mode 100644
    index 0000000000..7bb12cd2cb
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json
    @@ -0,0 +1,140 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "2",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "3",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "4",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "5",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "6",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "7",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "8",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json
    new file mode 100644
    index 0000000000..cc6065d2d4
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json
    @@ -0,0 +1,140 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "2",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "3",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "4",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "5",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "6",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "7",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "8",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json
    new file mode 100644
    index 0000000000..e20435c9c8
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json
    @@ -0,0 +1,157 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "3",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "4",
    +        "type": "bulletListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Bullet List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "5",
    +    "type": "bulletListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Bullet List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "6",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": [
    +      {
    +        "id": "7",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      },
    +      {
    +        "id": "8",
    +        "type": "numberedListItem",
    +        "props": {
    +          "textColor": "default",
    +          "backgroundColor": "default",
    +          "textAlignment": "left"
    +        },
    +        "content": [
    +          {
    +            "type": "text",
    +            "text": "Nested Numbered List Item",
    +            "styles": {}
    +          }
    +        ],
    +        "children": []
    +      }
    +    ]
    +  },
    +  {
    +    "id": "9",
    +    "type": "numberedListItem",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Numbered List Item",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json
    new file mode 100644
    index 0000000000..aa21de34f0
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json
    @@ -0,0 +1,36 @@
    +[
    +  {
    +    "id": "1",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "Single Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  },
    +  {
    +    "id": "2",
    +    "type": "paragraph",
    +    "props": {
    +      "textColor": "default",
    +      "backgroundColor": "default",
    +      "textAlignment": "left"
    +    },
    +    "content": [
    +      {
    +        "type": "text",
    +        "text": "second Div",
    +        "styles": {}
    +      }
    +    ],
    +    "children": []
    +  }
    +]
    \ No newline at end of file
    diff --git a/packages/core/src/api/parsers/html/parseHTML.test.ts b/packages/core/src/api/parsers/html/parseHTML.test.ts
    new file mode 100644
    index 0000000000..5bd8238e3f
    --- /dev/null
    +++ b/packages/core/src/api/parsers/html/parseHTML.test.ts
    @@ -0,0 +1,267 @@
    +import { describe, expect, it } from "vitest";
    +import { BlockNoteEditor } from "../../..";
    +import { nestedListsToBlockNoteStructure } from "./util/nestedLists";
    +
    +async function parseHTMLAndCompareSnapshots(
    +  html: string,
    +  snapshotName: string
    +) {
    +  const view: any = await import("prosemirror-view");
    +
    +  const editor = BlockNoteEditor.create();
    +  const blocks = await editor.tryParseHTMLToBlocks(html);
    +
    +  const snapshotPath = "./__snapshots__/paste/" + snapshotName + ".json";
    +  expect(JSON.stringify(blocks, undefined, 2)).toMatchFileSnapshot(
    +    snapshotPath
    +  );
    +
    +  // Now, we also want to test actually pasting in the editor, and not just calling
    +  // tryParseHTMLToBlocks directly.
    +  // The reason is that the prosemirror logic for pasting can be a bit different, because
    +  // it's related to the context of where the user is pasting exactly (selection)
    +  //
    +  // The internal difference come that in tryParseHTMLToBlocks, we use DOMParser.parse,
    +  // while when pasting, Prosemirror uses DOMParser.parseSlice, and then tries to fit the
    +  // slice in the document. This fitting might change the structure / interpretation of the pasted blocks
    +
    +  // Simulate a paste event (this uses DOMParser.parseSlice internally)
    +
    +  (window as any).__TEST_OPTIONS.mockID = 0; // reset id counter
    +  const htmlNode = nestedListsToBlockNoteStructure(html);
    +  const tt = editor._tiptapEditor;
    +
    +  const slice = view.__parseFromClipboard(
    +    tt.view,
    +    "",
    +    htmlNode.innerHTML,
    +    false,
    +    tt.view.state.selection.$from
    +  );
    +  tt.view.dispatch(tt.view.state.tr.replaceSelection(slice));
    +
    +  // alternative paste simulation doesn't work in a non-browser vitest env
    +  //   editor._tiptapEditor.view.pasteHTML(html, {
    +  //     preventDefault: () => {
    +  //       // noop
    +  //     },
    +  //     clipboardData: {
    +  //       types: ["text/html"],
    +  //       getData: () => html,
    +  //     },
    +  //   } as any);
    +
    +  const pastedBlocks = editor.topLevelBlocks;
    +  pastedBlocks.pop(); // trailing paragraph
    +  expect(pastedBlocks).toStrictEqual(blocks);
    +}
    +
    +describe("Parse HTML", () => {
    +  it("Parse basic block types", async () => {
    +    const html = `

    Heading 1

    +

    Heading 2

    +

    Heading 3

    +

    Paragraph

    +
    Image Caption
    +

    None Bold Italic Underline Strikethrough All

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

      Bullet List Item

      +
        +
      • +

        Nested Bullet List Item

        +
      • +
      • +

        Nested Bullet List Item

        +
      • +
      +
    • +
    • +

      Bullet List Item

      +
    • +
    +
      +
    1. +

      Numbered List Item

      +
        +
      1. +

        Nested Numbered List Item

        +
      2. +
      3. +

        Nested Numbered List Item

        +
      4. +
      +
    2. +
    3. +

      Numbered List Item

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

        Nested Bullet List Item

        +
      • +
      • +

        Nested Bullet List Item

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

    Image Caption

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

    Heading 1

    +

    Heading 2

    +

    Heading 3

    +

    Paragraph

    +
    Image Caption
    +

    Bold Italic Underline Strikethrough All

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

    Nested Paragraph

    +
    `; + + await parseHTMLAndCompareSnapshots(html, "parse-div-with-inline-content"); + }); +}); diff --git a/packages/core/src/api/parsers/html/parseHTML.ts b/packages/core/src/api/parsers/html/parseHTML.ts new file mode 100644 index 0000000000..cf4e983248 --- /dev/null +++ b/packages/core/src/api/parsers/html/parseHTML.ts @@ -0,0 +1,36 @@ +import { DOMParser, Schema } from "prosemirror-model"; +import { Block, BlockSchema, nodeToBlock } from "../../.."; +import { InlineContentSchema } from "../../../extensions/Blocks/api/inlineContent/types"; +import { StyleSchema } from "../../../extensions/Blocks/api/styles/types"; +import { nestedListsToBlockNoteStructure } from "./util/nestedLists"; + +export async function HTMLToBlocks< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + html: string, + blockSchema: BSchema, + icSchema: I, + styleSchema: S, + pmSchema: Schema +): Promise[]> { + const htmlNode = nestedListsToBlockNoteStructure(html); + const parser = DOMParser.fromSchema(pmSchema); + + // const doc = pmSchema.nodes["doc"].createAndFill()!; + + const parentNode = parser.parse(htmlNode, { + topNode: pmSchema.nodes["blockGroup"].create(), + // context: doc.resolve(3), + }); //, { preserveWhitespace: "full" }); + const blocks: Block[] = []; + + for (let i = 0; i < parentNode.childCount; i++) { + blocks.push( + nodeToBlock(parentNode.child(i), blockSchema, icSchema, styleSchema) + ); + } + + return blocks; +} diff --git a/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap b/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap new file mode 100644 index 0000000000..d697b8db72 --- /dev/null +++ b/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap @@ -0,0 +1,129 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Lift nested lists > Lifts multiple bullet lists 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
        +
      • Nested Bullet List Item 3
      • +
      • Nested Bullet List Item 4
      • +
      +
      +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts multiple bullet lists with content in between 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
      +
      +
      +
    • In between content
    • +
      +
        +
      • Nested Bullet List Item 3
      • +
      • Nested Bullet List Item 4
      • +
      +
      +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested bullet lists 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
      +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested bullet lists with content after nested list 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
      +
      +
    • More content in list item 1
    • +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested bullet lists without li 1`] = ` +" +
      Bullet List Item 1 +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested mixed lists 1`] = ` +" +
      +
      +
    1. Numbered List Item 1
    2. +
      +
        +
      • Bullet List Item 1
      • +
      • Bullet List Item 2
      • +
      +
      +
      +
    3. Numbered List Item 2
    4. +
    +" +`; + +exports[`Lift nested lists > Lifts nested numbered lists 1`] = ` +" +
      +
      +
    1. Numbered List Item 1
    2. +
      +
        +
      1. Nested Numbered List Item 1
      2. +
      3. Nested Numbered List Item 2
      4. +
      +
      +
      +
    3. Numbered List Item 2
    4. +
    +" +`; diff --git a/packages/core/src/api/parsers/html/util/nestedLists.test.ts b/packages/core/src/api/parsers/html/util/nestedLists.test.ts new file mode 100644 index 0000000000..96b0e1e9d2 --- /dev/null +++ b/packages/core/src/api/parsers/html/util/nestedLists.test.ts @@ -0,0 +1,176 @@ +import rehypeFormat from "rehype-format"; +import rehypeParse from "rehype-parse"; +import rehypeStringify from "rehype-stringify"; +import { unified } from "unified"; +import { describe, expect, it } from "vitest"; +import { nestedListsToBlockNoteStructure } from "./nestedLists"; + +async function testHTML(html: string) { + const htmlNode = nestedListsToBlockNoteStructure(html); + + const pretty = await unified() + .use(rehypeParse, { fragment: true }) + .use(rehypeFormat) + .use(rehypeStringify) + .process(htmlNode.innerHTML); + + expect(pretty.value).toMatchSnapshot(); +} + +describe("Lift nested lists", () => { + it("Lifts nested bullet lists", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts nested bullet lists without li", async () => { + const html = `
      + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts nested bullet lists with content after nested list", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      + More content in list item 1 +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts multiple bullet lists", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      +
        +
      • + Nested Bullet List Item 3 +
      • +
      • + Nested Bullet List Item 4 +
      • +
      +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts multiple bullet lists with content in between", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      + In between content +
        +
      • + Nested Bullet List Item 3 +
      • +
      • + Nested Bullet List Item 4 +
      • +
      +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts nested numbered lists", async () => { + const html = `
      +
    1. + Numbered List Item 1 +
        +
      1. + Nested Numbered List Item 1 +
      2. +
      3. + Nested Numbered List Item 2 +
      4. +
      +
    2. +
    3. + Numbered List Item 2 +
    4. +
    `; + await testHTML(html); + }); + + it("Lifts nested mixed lists", async () => { + const html = `
      +
    1. + Numbered List Item 1 +
        +
      • + Bullet List Item 1 +
      • +
      • + Bullet List Item 2 +
      • +
      +
    2. +
    3. + Numbered List Item 2 +
    4. +
    `; + await testHTML(html); + }); +}); diff --git a/packages/core/src/api/parsers/html/util/nestedLists.ts b/packages/core/src/api/parsers/html/util/nestedLists.ts new file mode 100644 index 0000000000..78c60b2a1a --- /dev/null +++ b/packages/core/src/api/parsers/html/util/nestedLists.ts @@ -0,0 +1,113 @@ +function getChildIndex(node: Element) { + return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); +} + +function isWhitespaceNode(node: Node) { + return node.nodeType === 3 && !/\S/.test(node.nodeValue || ""); +} + +/** + * Step 1, Turns: + * + *
      + *
    • item
    • + *
    • + *
        + *
      • ...
      • + *
      • ...
      • + *
      + *
    • + * + * Into: + *
        + *
      • item
      • + *
          + *
        • ...
        • + *
        • ...
        • + *
        + *
      + * + */ +function liftNestedListsToParent(element: HTMLElement) { + element.querySelectorAll("li > ul, li > ol").forEach((list) => { + const index = getChildIndex(list); + const parentListItem = list.parentElement!; + const siblingsAfter = Array.from(parentListItem.childNodes).slice( + index + 1 + ); + list.remove(); + siblingsAfter.forEach((sibling) => { + sibling.remove(); + }); + + parentListItem.insertAdjacentElement("afterend", list); + + siblingsAfter.reverse().forEach((sibling) => { + if (isWhitespaceNode(sibling)) { + return; + } + const siblingContainer = document.createElement("li"); + siblingContainer.append(sibling); + list.insertAdjacentElement("afterend", siblingContainer); + }); + if (parentListItem.childNodes.length === 0) { + parentListItem.remove(); + } + }); +} + +/** + * Step 2, Turns (output of liftNestedListsToParent): + * + *
    • item
    • + *
        + *
      • ...
      • + *
      • ...
      • + *
      + * + * Into: + *
      + *
    • item
    • + *
      + *
        + *
      • ...
      • + *
      • ...
      • + *
      + *
      + *
      + * + * This resulting format is parsed + */ +function createGroups(element: HTMLElement) { + element.querySelectorAll("li + ul, li + ol").forEach((list) => { + const listItem = list.previousElementSibling as HTMLElement; + const blockContainer = document.createElement("div"); + + listItem.insertAdjacentElement("afterend", blockContainer); + blockContainer.append(listItem); + + const blockGroup = document.createElement("div"); + blockGroup.setAttribute("data-node-type", "blockGroup"); + blockContainer.append(blockGroup); + + while ( + blockContainer.nextElementSibling?.nodeName === "UL" || + blockContainer.nextElementSibling?.nodeName === "OL" + ) { + blockGroup.append(blockContainer.nextElementSibling); + } + }); +} + +export function nestedListsToBlockNoteStructure( + elementOrHTML: HTMLElement | string +) { + if (typeof elementOrHTML === "string") { + const element = document.createElement("div"); + element.innerHTML = elementOrHTML; + elementOrHTML = element; + } + liftNestedListsToParent(elementOrHTML); + createGroups(elementOrHTML); + return elementOrHTML; +} diff --git a/packages/core/src/api/parsers/markdown/parseMarkdown.ts b/packages/core/src/api/parsers/markdown/parseMarkdown.ts new file mode 100644 index 0000000000..f81cb7a0b3 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/parseMarkdown.ts @@ -0,0 +1,80 @@ +import { Schema } from "prosemirror-model"; +import rehypeStringify from "rehype-stringify"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import remarkRehype, { defaultHandlers } from "remark-rehype"; +import { unified } from "unified"; +import { Block, BlockSchema, InlineContentSchema, StyleSchema } from "../../.."; +import { HTMLToBlocks } from "../html/parseHTML"; + +// modified version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js +// that outputs a data-language attribute instead of a CSS class (e.g.: language-typescript) +function code(state: any, node: any) { + const value = node.value ? node.value + "\n" : ""; + /** @type {Properties} */ + const properties: any = {}; + + if (node.lang) { + // changed line + properties["data-language"] = node.lang; + } + + // Create ``. + /** @type {Element} */ + let result: any = { + type: "element", + tagName: "code", + properties, + children: [{ type: "text", value }], + }; + + if (node.meta) { + result.data = { meta: node.meta }; + } + + state.patch(node, result); + result = state.applyData(node, result); + + // Create `
      `.
      +  result = {
      +    type: "element",
      +    tagName: "pre",
      +    properties: {},
      +    children: [result],
      +  };
      +  state.patch(node, result);
      +  return result;
      +}
      +
      +// TODO: add tests
      +export function markdownToBlocks<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  markdown: string,
      +  blockSchema: BSchema,
      +  icSchema: I,
      +  styleSchema: S,
      +  pmSchema: Schema
      +): Promise[]> {
      +  const htmlString = unified()
      +    .use(remarkParse)
      +    .use(remarkGfm)
      +    .use(remarkRehype, {
      +      handlers: {
      +        ...(defaultHandlers as any),
      +        code,
      +      },
      +    })
      +    .use(rehypeStringify)
      +    .processSync(markdown);
      +
      +  return HTMLToBlocks(
      +    htmlString.value as string,
      +    blockSchema,
      +    icSchema,
      +    styleSchema,
      +    pmSchema
      +  );
      +}
      diff --git a/packages/core/src/api/parsers/pasteExtension.ts b/packages/core/src/api/parsers/pasteExtension.ts
      new file mode 100644
      index 0000000000..f0dec4f86d
      --- /dev/null
      +++ b/packages/core/src/api/parsers/pasteExtension.ts
      @@ -0,0 +1,61 @@
      +import { Extension } from "@tiptap/core";
      +import { Plugin } from "prosemirror-state";
      +
      +import { BlockNoteEditor } from "../../BlockNoteEditor";
      +import { BlockSchema } from "../../extensions/Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../../extensions/Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../../extensions/Blocks/api/styles/types";
      +import { nestedListsToBlockNoteStructure } from "./html/util/nestedLists";
      +
      +const acceptedMIMETypes = [
      +  "blocknote/html",
      +  "text/html",
      +  "text/plain",
      +] as const;
      +
      +export const createPasteFromClipboardExtension = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  editor: BlockNoteEditor
      +) =>
      +  Extension.create<{ editor: BlockNoteEditor }, undefined>({
      +    name: "pasteFromClipboard",
      +    addProseMirrorPlugins() {
      +      return [
      +        new Plugin({
      +          props: {
      +            handleDOMEvents: {
      +              paste(_view, event) {
      +                event.preventDefault();
      +                let format: (typeof acceptedMIMETypes)[number] | null = null;
      +
      +                for (const mimeType of acceptedMIMETypes) {
      +                  if (event.clipboardData!.types.includes(mimeType)) {
      +                    format = mimeType;
      +                    break;
      +                  }
      +                }
      +
      +                if (format !== null) {
      +                  let data = event.clipboardData!.getData(format);
      +                  if (format === "text/html") {
      +                    const htmlNode = nestedListsToBlockNoteStructure(
      +                      data.trim()
      +                    );
      +
      +                    data = htmlNode.innerHTML;
      +                    console.log(data);
      +                  }
      +                  editor._tiptapEditor.view.pasteHTML(data);
      +                }
      +
      +                return true;
      +              },
      +            },
      +          },
      +        }),
      +      ];
      +    },
      +  });
      diff --git a/packages/core/src/api/testCases/cases/customInlineContent.ts b/packages/core/src/api/testCases/cases/customInlineContent.ts
      new file mode 100644
      index 0000000000..a1603f4a87
      --- /dev/null
      +++ b/packages/core/src/api/testCases/cases/customInlineContent.ts
      @@ -0,0 +1,114 @@
      +import { EditorTestCases } from "..";
      +
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultStyleSchema,
      +  defaultInlineContentSpecs,
      +} from "../../../extensions/Blocks/api/defaultBlocks";
      +import { createInlineContentSpec } from "../../../extensions/Blocks/api/inlineContent/createSpec";
      +import {
      +  InlineContentSchemaFromSpecs,
      +  InlineContentSpecs,
      +} from "../../../extensions/Blocks/api/inlineContent/types";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +
      +const mention = createInlineContentSpec(
      +  {
      +    type: "mention" as const,
      +    propSchema: {
      +      user: {
      +        default: "",
      +      },
      +    },
      +    content: "none",
      +  },
      +  {
      +    render: (ic) => {
      +      const dom = document.createElement("span");
      +      dom.appendChild(document.createTextNode("@" + ic.props.user));
      +
      +      return {
      +        dom,
      +      };
      +    },
      +  }
      +);
      +
      +const tag = createInlineContentSpec(
      +  {
      +    type: "tag" as const,
      +    propSchema: {},
      +    content: "styled",
      +  },
      +  {
      +    render: () => {
      +      const dom = document.createElement("span");
      +      dom.textContent = "#";
      +
      +      const contentDOM = document.createElement("span");
      +      dom.appendChild(contentDOM);
      +
      +      return {
      +        dom,
      +        contentDOM,
      +      };
      +    },
      +  }
      +);
      +
      +const customInlineContent = {
      +  ...defaultInlineContentSpecs,
      +  mention,
      +  tag,
      +} satisfies InlineContentSpecs;
      +
      +export const customInlineContentTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  InlineContentSchemaFromSpecs,
      +  DefaultStyleSchema
      +> = {
      +  name: "custom inline content schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +      inlineContentSpecs: customInlineContent,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "mention/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            "I enjoy working with",
      +            {
      +              type: "mention",
      +              props: {
      +                user: "Matthew",
      +              },
      +              content: undefined,
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "tag/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            "I love ",
      +            {
      +              type: "tag",
      +              // props: {},
      +              content: "BlockNote",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testCases/cases/customStyles.ts b/packages/core/src/api/testCases/cases/customStyles.ts
      new file mode 100644
      index 0000000000..e7a4390e63
      --- /dev/null
      +++ b/packages/core/src/api/testCases/cases/customStyles.ts
      @@ -0,0 +1,103 @@
      +import { EditorTestCases } from "..";
      +
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  defaultStyleSpecs,
      +} from "../../../extensions/Blocks/api/defaultBlocks";
      +import { createStyleSpec } from "../../../extensions/Blocks/api/styles/createSpec";
      +import {
      +  StyleSchemaFromSpecs,
      +  StyleSpecs,
      +} from "../../../extensions/Blocks/api/styles/types";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +
      +const small = createStyleSpec(
      +  {
      +    type: "small",
      +    propSchema: "boolean",
      +  },
      +  {
      +    render: () => {
      +      const dom = document.createElement("small");
      +      return {
      +        dom,
      +        contentDOM: dom,
      +      };
      +    },
      +  }
      +);
      +
      +const fontSize = createStyleSpec(
      +  {
      +    type: "fontSize",
      +    propSchema: "string",
      +  },
      +  {
      +    render: (value) => {
      +      const dom = document.createElement("span");
      +      dom.setAttribute("style", "font-size: " + value);
      +      return {
      +        dom,
      +        contentDOM: dom,
      +      };
      +    },
      +  }
      +);
      +
      +const customStyles = {
      +  ...defaultStyleSpecs,
      +  small,
      +  fontSize,
      +} satisfies StyleSpecs;
      +
      +export const customStylesTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  StyleSchemaFromSpecs
      +> = {
      +  name: "custom style schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +      styleSpecs: customStyles,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "small/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "This is a small text",
      +              styles: {
      +                small: true,
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "fontSize/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "This is text with a custom fontSize",
      +              styles: {
      +                fontSize: "18px",
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testCases/cases/defaultSchema.ts b/packages/core/src/api/testCases/cases/defaultSchema.ts
      new file mode 100644
      index 0000000000..87aa6b01b1
      --- /dev/null
      +++ b/packages/core/src/api/testCases/cases/defaultSchema.ts
      @@ -0,0 +1,399 @@
      +import { EditorTestCases } from "..";
      +
      +import { BlockNoteEditor } from "../../../BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +} from "../../../extensions/Blocks/api/defaultBlocks";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +
      +export const defaultSchemaTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema
      +> = {
      +  name: "default schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "paragraph/empty",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: "Paragraph",
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/styled",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          props: {
      +            textAlignment: "center",
      +            textColor: "orange",
      +            backgroundColor: "pink",
      +          },
      +          content: [
      +            {
      +              type: "text",
      +              styles: {},
      +              text: "Plain ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +              },
      +              text: "Red Text ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                backgroundColor: "blue",
      +              },
      +              text: "Blue Background ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +                backgroundColor: "blue",
      +              },
      +              text: "Mixed Colors",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/nested",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: "Paragraph",
      +          children: [
      +            {
      +              type: "paragraph",
      +              content: "Nested Paragraph 1",
      +            },
      +            {
      +              type: "paragraph",
      +              content: "Nested Paragraph 2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/button",
      +      blocks: [
      +        {
      +          type: "image",
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/basic",
      +      blocks: [
      +        {
      +          type: "image",
      +          props: {
      +            url: "exampleURL",
      +            caption: "Caption",
      +            width: 256,
      +          },
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/nested",
      +      blocks: [
      +        {
      +          type: "image",
      +          props: {
      +            url: "exampleURL",
      +            caption: "Caption",
      +            width: 256,
      +          },
      +          children: [
      +            {
      +              type: "image",
      +              props: {
      +                url: "exampleURL",
      +                caption: "Caption",
      +                width: 256,
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/basic",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Website",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/styled",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: [
      +                {
      +                  type: "text",
      +                  text: "Web",
      +                  styles: {
      +                    bold: true,
      +                  },
      +                },
      +                {
      +                  type: "text",
      +                  text: "site",
      +                  styles: {},
      +                },
      +              ],
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/adjacent",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Website",
      +            },
      +            {
      +              type: "link",
      +              href: "https://www.website2.com",
      +              content: "Website2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/basic",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\nText2",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/multiple",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\nText2\nText3",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/start",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "\nText1",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/end",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\n",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/only",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "\n",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/styles",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\n",
      +              styles: {},
      +            },
      +            {
      +              type: "text",
      +              text: "Text2",
      +              styles: { bold: true },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/link",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Link1\nLink1",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/between-links",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Link1\n",
      +            },
      +            {
      +              type: "link",
      +              href: "https://www.website2.com",
      +              content: "Link2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "complex/misc",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "heading",
      +          props: {
      +            backgroundColor: "blue",
      +            textColor: "yellow",
      +            textAlignment: "right",
      +            level: 2,
      +          },
      +          content: [
      +            {
      +              type: "text",
      +              text: "Heading ",
      +              styles: {
      +                bold: true,
      +                underline: true,
      +              },
      +            },
      +            {
      +              type: "text",
      +              text: "2",
      +              styles: {
      +                italic: true,
      +                strike: true,
      +              },
      +            },
      +          ],
      +          children: [
      +            {
      +              // id: UniqueID.options.generateID(),
      +              type: "paragraph",
      +              props: {
      +                backgroundColor: "red",
      +              },
      +              content: "Paragraph",
      +              children: [],
      +            },
      +            {
      +              // id: UniqueID.options.generateID(),
      +              type: "bulletListItem",
      +              props: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testCases/index.ts b/packages/core/src/api/testCases/index.ts
      new file mode 100644
      index 0000000000..90e1f06005
      --- /dev/null
      +++ b/packages/core/src/api/testCases/index.ts
      @@ -0,0 +1,20 @@
      +import { BlockNoteEditor } from "../../BlockNoteEditor";
      +import {
      +  BlockSchema,
      +  PartialBlock,
      +} from "../../extensions/Blocks/api/blocks/types";
      +import { InlineContentSchema } from "../../extensions/Blocks/api/inlineContent/types";
      +import { StyleSchema } from "../../extensions/Blocks/api/styles/types";
      +
      +export type EditorTestCases<
      +  B extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  name: string;
      +  createEditor: () => BlockNoteEditor;
      +  documents: Array<{
      +    name: string;
      +    blocks: PartialBlock[];
      +  }>;
      +};
      diff --git a/packages/core/src/editor.module.css b/packages/core/src/editor.css
      similarity index 72%
      rename from packages/core/src/editor.module.css
      rename to packages/core/src/editor.css
      index 76c65d9d13..2dac0db661 100644
      --- a/packages/core/src/editor.module.css
      +++ b/packages/core/src/editor.css
      @@ -1,6 +1,6 @@
       @import url("./assets/fonts-inter.css");
       
      -.bnEditor {
      +.bn-editor {
         outline: none;
         padding-inline: 54px;
       
      @@ -11,31 +11,31 @@
       }
       
       /*
      -bnRoot should be applied to all top-level elements
      +bn-root should be applied to all top-level elements
       
       This includes the Prosemirror editor, but also 
      element such as Tippy popups that are appended to document.body directly */ -.bnRoot { +.bn-root { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } -.bnRoot *, -.bnRoot *::before, -.bnRoot *::after { +.bn-root *, +.bn-root *::before, +.bn-root *::after { -webkit-box-sizing: inherit; -moz-box-sizing: inherit; box-sizing: inherit; } /* reset styles, they will be set on blockContent */ -.defaultStyles p, -.defaultStyles h1, -.defaultStyles h2, -.defaultStyles h3, -.defaultStyles li { +.bn-default-styles p, +.bn-default-styles h1, +.bn-default-styles h2, +.bn-default-styles h3, +.bn-default-styles li { all: unset; margin: 0; padding: 0; @@ -44,7 +44,7 @@ Tippy popups that are appended to document.body directly min-width: 2px !important; } -.defaultStyles { +.bn-default-styles { font-size: 16px; font-weight: normal; font-family: "Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, @@ -54,9 +54,16 @@ Tippy popups that are appended to document.body directly -moz-osx-font-smoothing: grayscale; } -.dragPreview { +.bn-table-drop-cursor { position: absolute; - top: -1000px; + z-index: 20; + background-color: #adf; + pointer-events: none; +} + +.bn-drag-preview { + position: absolute; + left: -100000px; } /* Give a remote user a caret */ @@ -85,3 +92,23 @@ Tippy popups that are appended to document.body directly user-select: none; white-space: nowrap; } + +/* table related: */ +.bn-editor table { + width: auto !important; +} +.bn-editor th, +.bn-editor td { + min-width: 1em; + border: 1px solid #ddd; + padding: 3px 5px; +} + +.bn-editor .tableWrapper { + margin: 1em 0; +} + +.bn-editor th { + font-weight: bold; + text-align: left; +} diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts index caa76f6416..3f24ecdfea 100644 --- a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts +++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts @@ -1,18 +1,6 @@ import { Extension } from "@tiptap/core"; -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos"; import { defaultProps } from "../Blocks/api/defaultProps"; -declare module "@tiptap/core" { - interface Commands { - blockBackgroundColor: { - setBlockBackgroundColor: ( - posInBlock: number, - color: string - ) => ReturnType; - }; - } -} - export const BackgroundColorExtension = Extension.create({ name: "blockBackgroundColor", @@ -37,27 +25,4 @@ export const BackgroundColorExtension = Extension.create({ }, ]; }, - - addCommands() { - return { - setBlockBackgroundColor: - (posInBlock, color) => - ({ state, view }) => { - const blockInfo = getBlockInfoFromPos(state.doc, posInBlock); - if (blockInfo === undefined) { - return false; - } - - state.tr.setNodeAttribute( - blockInfo.startPos - 1, - "backgroundColor", - color - ); - - view.focus(); - - return true; - }, - }; - }, }); diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts index adcdca387f..df4b257588 100644 --- a/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts +++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts @@ -1,24 +1,16 @@ import { Mark } from "@tiptap/core"; -import { defaultProps } from "../Blocks/api/defaultProps"; +import { createStyleSpecFromTipTapMark } from "../Blocks/api/styles/internal"; -declare module "@tiptap/core" { - interface Commands { - backgroundColor: { - setBackgroundColor: (color: string) => ReturnType; - }; - } -} - -export const BackgroundColorMark = Mark.create({ +const BackgroundColorMark = Mark.create({ name: "backgroundColor", addAttributes() { return { - color: { + stringValue: { default: undefined, parseHTML: (element) => element.getAttribute("data-background-color"), renderHTML: (attributes) => ({ - "data-background-color": attributes.color, + "data-background-color": attributes.stringValue, }), }, }; @@ -34,7 +26,9 @@ export const BackgroundColorMark = Mark.create({ } if (element.hasAttribute("data-background-color")) { - return { color: element.getAttribute("data-background-color") }; + return { + stringValue: element.getAttribute("data-background-color"), + }; } return false; @@ -46,18 +40,9 @@ export const BackgroundColorMark = Mark.create({ renderHTML({ HTMLAttributes }) { return ["span", HTMLAttributes, 0]; }, - - addCommands() { - return { - setBackgroundColor: - (color) => - ({ commands }) => { - if (color !== defaultProps.backgroundColor.default) { - return commands.setMark(this.name, { color: color }); - } - - return commands.unsetMark(this.name); - }, - }; - }, }); + +export const BackgroundColor = createStyleSpecFromTipTapMark( + BackgroundColorMark, + "string" +); diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts deleted file mode 100644 index ec8a07e8b9..0000000000 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { Attribute, Attributes, Node } from "@tiptap/core"; -import { BlockNoteDOMAttributes, BlockNoteEditor } from "../../.."; -import styles from "../nodes/Block.module.css"; -import { - BlockConfig, - BlockSchema, - BlockSpec, - PropSchema, - TipTapNode, - TipTapNodeConfig, -} from "./blockTypes"; -import { mergeCSSClasses } from "../../../shared/utils"; -import { ParseRule } from "prosemirror-model"; - -export function camelToDataKebab(str: string): string { - return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); -} - -// Function that uses the 'propSchema' of a blockConfig to create a TipTap -// node's `addAttributes` property. -export function propsToAttributes< - BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema ->( - blockConfig: Omit< - BlockConfig, - "render" - > -): Attributes { - const tiptapAttributes: Record = {}; - - Object.entries(blockConfig.propSchema).forEach(([name, spec]) => { - tiptapAttributes[name] = { - default: spec.default, - keepOnSplit: true, - // Props are displayed in kebab-case as HTML attributes. If a prop's - // value is the same as its default, we don't display an HTML - // attribute for it. - parseHTML: (element) => { - const value = element.getAttribute(camelToDataKebab(name)); - - if (value === null) { - return null; - } - - if (typeof spec.default === "boolean") { - if (value === "true") { - return true; - } - - if (value === "false") { - return false; - } - - return null; - } - - if (typeof spec.default === "number") { - const asNumber = parseFloat(value); - const isNumeric = - !Number.isNaN(asNumber) && Number.isFinite(asNumber); - - if (isNumeric) { - return asNumber; - } - - return null; - } - - return value; - }, - renderHTML: (attributes) => - attributes[name] !== spec.default - ? { - [camelToDataKebab(name)]: attributes[name], - } - : {}, - }; - }); - - return tiptapAttributes; -} - -// Function that uses the 'parse' function of a blockConfig to create a -// TipTap node's `parseHTML` property. This is only used for parsing content -// from the clipboard. -export function parse< - BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema ->( - blockConfig: Omit< - BlockConfig, - "render" - > -): ParseRule[] { - return [ - { - tag: "div[data-content-type=" + blockConfig.type + "]", - }, - ]; -} - -// Function that uses the 'render' function of a blockConfig to create a -// TipTap node's `renderHTML` property. Since custom blocks use node views, -// this is only used for serializing content to the clipboard. -export function render< - BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema ->( - blockConfig: Omit< - BlockConfig, - "render" - >, - HTMLAttributes: Record -) { - // Create blockContent element - const blockContent = document.createElement("div"); - // Add blockContent HTML attribute - blockContent.setAttribute("data-content-type", blockConfig.type); - // Add props as HTML attributes in kebab-case with "data-" prefix - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - blockContent.setAttribute(attribute, value); - } - - // TODO: This only works for content copied within BlockNote. - // Creates contentDOM element to serialize inline content into. - let contentDOM: HTMLDivElement | undefined; - if (blockConfig.containsInlineContent) { - contentDOM = document.createElement("div"); - blockContent.appendChild(contentDOM); - } else { - contentDOM = undefined; - } - - return contentDOM !== undefined - ? { - dom: blockContent, - contentDOM: contentDOM, - } - : { - dom: blockContent, - }; -} - -// A function to create custom block for API consumers -// we want to hide the tiptap node from API consumers and provide a simpler API surface instead -export function createBlockSpec< - BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends false, - BSchema extends BlockSchema ->( - blockConfig: BlockConfig -): BlockSpec { - const node = createTipTapBlock< - BType, - ContainsInlineContent, - { - editor: BlockNoteEditor; - domAttributes?: BlockNoteDOMAttributes; - } - >({ - name: blockConfig.type, - content: (blockConfig.containsInlineContent - ? "inline*" - : "") as ContainsInlineContent extends true ? "inline*" : "", - selectable: true, - - addAttributes() { - return propsToAttributes(blockConfig); - }, - - parseHTML() { - return parse(blockConfig); - }, - - renderHTML({ HTMLAttributes }) { - return render(blockConfig, HTMLAttributes); - }, - - addNodeView() { - return ({ HTMLAttributes, getPos }) => { - // Create blockContent element - const blockContent = document.createElement("div"); - // Add custom HTML attributes - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - for (const [attribute, value] of Object.entries( - blockContentDOMAttributes - )) { - if (attribute !== "class") { - blockContent.setAttribute(attribute, value); - } - } - // Set blockContent & custom classes - blockContent.className = mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ); - // Add blockContent HTML attribute - blockContent.setAttribute("data-content-type", blockConfig.type); - // Add props as HTML attributes in kebab-case with "data-" prefix - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - blockContent.setAttribute(attribute, value); - } - - // Gets BlockNote editor instance - const editor = this.options.editor! as BlockNoteEditor< - BSchema & { - [k in BType]: BlockSpec; - } - >; - // Gets position of the node - if (typeof getPos === "boolean") { - throw new Error( - "Cannot find node position as getPos is a boolean, not a function." - ); - } - const pos = getPos(); - // Gets TipTap editor instance - const tipTapEditor = editor._tiptapEditor; - // Gets parent blockContainer node - const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); - // Gets block identifier - const blockIdentifier = blockContainer.attrs.id; - - // Get the block - const block = editor.getBlock(blockIdentifier)!; - if (block.type !== blockConfig.type) { - throw new Error("Block type does not match"); - } - - // Render elements - const rendered = blockConfig.render(block as any, editor); - // Add HTML attributes to contentDOM - if (blockConfig.containsInlineContent) { - const contentDOM = (rendered as { contentDOM: HTMLElement }) - .contentDOM; - - const inlineContentDOMAttributes = - this.options.domAttributes?.inlineContent || {}; - // Add custom HTML attributes - for (const [attribute, value] of Object.entries( - inlineContentDOMAttributes - )) { - if (attribute !== "class") { - contentDOM.setAttribute(attribute, value); - } - } - // Merge existing classes with inlineContent & custom classes - contentDOM.className = mergeCSSClasses( - contentDOM.className, - styles.inlineContent, - inlineContentDOMAttributes.class - ); - } - // Add elements to blockContent - blockContent.appendChild(rendered.dom); - - return "contentDOM" in rendered - ? { - dom: blockContent, - contentDOM: rendered.contentDOM, - destroy: rendered.destroy, - } - : { - dom: blockContent, - destroy: rendered.destroy, - }; - }; - }, - }); - - return { - node: node as TipTapNode, - propSchema: blockConfig.propSchema, - }; -} - -export function createTipTapBlock< - Type extends string, - ContainsInlineContent extends boolean, - Options extends { - domAttributes?: BlockNoteDOMAttributes; - } = { - domAttributes?: BlockNoteDOMAttributes; - }, - Storage = any ->( - config: TipTapNodeConfig -): TipTapNode { - // Type cast is needed as Node.name is mutable, though there is basically no - // reason to change it after creation. Alternative is to wrap Node in a new - // class, which I don't think is worth it since we'd only be changing 1 - // attribute to be read only. - return Node.create({ - ...config, - group: "blockContent", - content: config.content, - }) as TipTapNode; -} diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts deleted file mode 100644 index cb686eabc7..0000000000 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** Define the main block types **/ -import { Node, NodeConfig } from "@tiptap/core"; -import { BlockNoteEditor } from "../../../BlockNoteEditor"; -import { InlineContent, PartialInlineContent } from "./inlineContentTypes"; -import { DefaultBlockSchema } from "./defaultBlocks"; - -export type BlockNoteDOMElement = - | "editor" - | "blockContainer" - | "blockGroup" - | "blockContent" - | "inlineContent"; - -export type BlockNoteDOMAttributes = Partial<{ - [DOMElement in BlockNoteDOMElement]: Record; -}>; - -// A configuration for a TipTap node, but with stricter type constraints on the -// "name" and "content" properties. The "name" property is now always a string -// literal type, and the "content" property can only be "inline*" or "". Used as -// the parameter in `createTipTapNode`. The "group" is also removed as -// `createTipTapNode` always sets it to "blockContent" -export type TipTapNodeConfig< - Name extends string, - ContainsInlineContent extends boolean, - Options extends { - domAttributes?: BlockNoteDOMAttributes; - } = { - domAttributes?: BlockNoteDOMAttributes; - }, - Storage = any -> = { - [K in keyof NodeConfig]: K extends "name" - ? Name - : K extends "content" - ? ContainsInlineContent extends true - ? "inline*" - : "" - : K extends "group" - ? never - : NodeConfig[K]; -} & { - name: Name; - content: ContainsInlineContent extends true ? "inline*" : ""; -}; - -// A TipTap node with stricter type constraints on the "name", "group", and -// "content properties. The "name" property is now a string literal type, and -// the "blockGroup" property is now "blockContent", and the "content" property -// can only be "inline*" or "". Returned by `createTipTapNode`. -export type TipTapNode< - Name extends string, - ContainsInlineContent extends boolean, - Options extends { - domAttributes?: BlockNoteDOMAttributes; - } = { - domAttributes?: BlockNoteDOMAttributes; - }, - Storage = any -> = { - [Key in keyof Node]: Key extends "name" - ? Name - : Key extends "config" - ? { - [ConfigKey in keyof Node< - Options, - Storage - >["config"]]: ConfigKey extends "group" - ? "blockContent" - : ConfigKey extends "content" - ? ContainsInlineContent extends true - ? "inline*" - : "" - : NodeConfig["config"][ConfigKey]; - } & { - group: "blockContent"; - content: ContainsInlineContent extends true ? "inline*" : ""; - } - : Node["config"][Key]; -}; - -// Defines a single prop spec, which includes the default value the prop should -// take and possible values it can take. -export type PropSpec = { - values?: readonly PType[]; - default: PType; -}; - -// Defines multiple block prop specs. The key of each prop is the name of the -// prop, while the value is a corresponding prop spec. This should be included -// in a block config or schema. From a prop schema, we can derive both the props' -// internal implementation (as TipTap node attributes) and the type information -// for the external API. -export type PropSchema = Record>; - -// Defines Props objects for use in Block objects in the external API. Converts -// each prop spec into a union type of its possible values, or a string if no -// values are specified. -export type Props = { - [PName in keyof PSchema]: PSchema[PName]["default"] extends boolean - ? PSchema[PName]["values"] extends readonly boolean[] - ? PSchema[PName]["values"][number] - : boolean - : PSchema[PName]["default"] extends number - ? PSchema[PName]["values"] extends readonly number[] - ? PSchema[PName]["values"][number] - : number - : PSchema[PName]["default"] extends string - ? PSchema[PName]["values"] extends readonly string[] - ? PSchema[PName]["values"][number] - : string - : never; -}; - -// Defines the config for a single block. Meant to be used as an argument to -// `createBlockSpec`, which will create a new block spec from it. This is the -// main way we expect people to create custom blocks as consumers don't need to -// know anything about the TipTap API since the associated nodes are created -// automatically. -export type BlockConfig< - Type extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema -> = { - // Attributes to define block in the API as well as a TipTap node. - type: Type; - readonly propSchema: PSchema; - - // Additional attributes to help define block as a TipTap node. - containsInlineContent: ContainsInlineContent; - render: ( - /** - * The custom block to render - */ - block: SpecificBlock< - BSchema & { - [k in Type]: BlockSpec; - }, - Type - >, - /** - * 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< - BSchema & { [k in Type]: BlockSpec } - > - // (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 - ) => ContainsInlineContent extends true - ? { - dom: HTMLElement; - contentDOM: HTMLElement; - destroy?: () => void; - } - : { - dom: HTMLElement; - destroy?: () => void; - }; -}; - -// Defines a single block spec, which includes the props that the block has and -// the TipTap node used to implement it. Usually created using `createBlockSpec` -// though it can also be defined from scratch by providing your own TipTap node, -// allowing for more advanced custom blocks. -export type BlockSpec< - Type extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean -> = { - node: TipTapNode; - readonly propSchema: PSchema; -}; - -// Utility type. For a given object block schema, ensures that the key of each -// block spec matches the name of the TipTap node in it. -type NamesMatch< - Blocks extends Record> -> = Blocks extends { - [Type in keyof Blocks]: Type extends string - ? Blocks[Type] extends BlockSpec - ? Blocks[Type] - : never - : never; -} - ? Blocks - : never; - -// Defines multiple block specs. Also ensures that the key of each block schema -// is the same as name of the TipTap node in it. This should be passed in the -// `blocks` option of the BlockNoteEditor. From a block schema, we can derive -// both the blocks' internal implementation (as TipTap nodes) and the type -// information for the external API. -export type BlockSchema = NamesMatch< - Record> ->; - -// Converts each block spec into a Block object without children. We later merge -// them into a union type and add a children property to create the Block and -// PartialBlock objects we use in the external API. -type BlocksWithoutChildren = { - [BType in keyof BSchema]: { - id: string; - type: BType; - props: Props; - content: BSchema[BType]["node"]["config"]["content"] extends "inline*" - ? InlineContent[] - : undefined; - }; -}; - -// Converts each block spec into a Block object without children, merges them -// into a union type, and adds a children property -export type Block = - BlocksWithoutChildren[keyof BlocksWithoutChildren] & { - children: Block[]; - }; - -export type SpecificBlock< - BSchema extends BlockSchema, - BlockType extends keyof BSchema -> = BlocksWithoutChildren[BlockType] & { - children: Block[]; -}; - -// Same as BlockWithoutChildren, but as a partial type with some changes to make -// it easier to create/update blocks in the editor. -type PartialBlocksWithoutChildren = { - [BType in keyof BSchema]: Partial<{ - id: string; - type: BType; - props: Partial>; - content: BSchema[BType]["node"]["config"]["content"] extends "inline*" - ? PartialInlineContent[] | string - : undefined; - }>; -}; - -// Same as Block, but as a partial type with some changes to make it easier to -// create/update blocks in the editor. -export type PartialBlock = - PartialBlocksWithoutChildren[keyof PartialBlocksWithoutChildren] & - Partial<{ - children: PartialBlock[]; - }>; - -export type BlockIdentifier = { id: string } | string; diff --git a/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts new file mode 100644 index 0000000000..18b0d780f4 --- /dev/null +++ b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts @@ -0,0 +1,206 @@ +import { ParseRule } from "@tiptap/pm/model"; +import { BlockNoteEditor } from "../../../../BlockNoteEditor"; +import { InlineContentSchema } from "../inlineContent/types"; +import { StyleSchema } from "../styles/types"; +import { + createInternalBlockSpec, + createStronglyTypedTiptapNode, + getBlockFromPos, + propsToAttributes, + wrapInBlockStructure, +} from "./internal"; +import { + BlockConfig, + BlockFromConfig, + BlockSchemaWithBlock, + PartialBlockFromConfig, +} from "./types"; + +// restrict content to "inline" and "none" only +export type CustomBlockConfig = BlockConfig & { + content: "inline" | "none"; +}; + +export type CustomBlockImplementation< + T extends CustomBlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = { + render: ( + /** + * The custom block to render + */ + block: BlockFromConfig, + /** + * The BlockNote editor instance + * This is typed generically. If you want an editor with your custom schema, you need to + * cast it manually, e.g.: `const e = editor as BlockNoteEditor;` + */ + editor: BlockNoteEditor, I, S> + // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations + // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + destroy?: () => void; + }; + // Exports block to external HTML. If not defined, the output will be the same + // as `render(...).dom`. Used to create clipboard data when pasting outside + // BlockNote. + // TODO: Maybe can return undefined to ignore when serializing? + toExternalHTML?: ( + block: BlockFromConfig, + editor: BlockNoteEditor, I, S> + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + + parse?: (el: HTMLElement) => PartialBlockFromConfig | undefined; +}; + +// Function that uses the 'parse' function of a blockConfig to create a +// TipTap node's `parseHTML` property. This is only used for parsing content +// from the clipboard. +export function getParseRules( + config: BlockConfig, + customParseFunction: CustomBlockImplementation["parse"] +) { + const rules: ParseRule[] = [ + { + tag: "div[data-content-type=" + config.type + "]", + }, + ]; + + if (customParseFunction) { + rules.push({ + tag: "*", + getAttrs(node: string | HTMLElement) { + if (typeof node === "string") { + return false; + } + + const block = customParseFunction?.(node); + + if (block === undefined) { + return false; + } + + return block.props || {}; + }, + }); + } + // getContent(node, schema) { + // const block = blockConfig.parse?.(node as HTMLElement); + // + // if (block !== undefined && block.content !== undefined) { + // return Fragment.from( + // typeof block.content === "string" + // ? schema.text(block.content) + // : inlineContentToNodes(block.content, schema) + // ); + // } + // + // return Fragment.empty; + // }, + // }); + // } + + return rules; +} + +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function createBlockSpec< + T extends CustomBlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +>(blockConfig: T, blockImplementation: CustomBlockImplementation) { + const node = createStronglyTypedTiptapNode({ + name: blockConfig.type as T["type"], + content: (blockConfig.content === "inline" + ? "inline*" + : "") as T["content"] extends "inline" ? "inline*" : "", + group: "blockContent", + selectable: true, + + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + + parseHTML() { + return getParseRules(blockConfig, blockImplementation.parse); + }, + + addNodeView() { + return ({ getPos }) => { + // Gets the BlockNote editor instance + const editor = this.options.editor; + // Gets the block + const block = getBlockFromPos( + getPos, + editor, + this.editor, + blockConfig.type + ); + // Gets the custom HTML attributes for `blockContent` nodes + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; + + const output = blockImplementation.render(block as any, editor); + + return wrapInBlockStructure( + output, + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + }; + }, + }); + + if (node.name !== blockConfig.type) { + throw new Error( + "Node name does not match block type. This is a bug in BlockNote." + ); + } + + return createInternalBlockSpec(blockConfig, { + node, + toInternalHTML: (block, editor) => { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + const output = blockImplementation.render(block as any, editor as any); + + return wrapInBlockStructure( + output, + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + }, + toExternalHTML: (block, editor) => { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + let output = blockImplementation.toExternalHTML?.( + block as any, + editor as any + ); + if (output === undefined) { + output = blockImplementation.render(block as any, editor as any); + } + + return wrapInBlockStructure( + output, + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + }, + }); +} diff --git a/packages/core/src/extensions/Blocks/api/blocks/internal.ts b/packages/core/src/extensions/Blocks/api/blocks/internal.ts new file mode 100644 index 0000000000..58f8b84d50 --- /dev/null +++ b/packages/core/src/extensions/Blocks/api/blocks/internal.ts @@ -0,0 +1,256 @@ +import { + Attribute, + Attributes, + Editor, + Extension, + Node, + NodeConfig, +} from "@tiptap/core"; +import { BlockNoteEditor } from "../../../../BlockNoteEditor"; +import { mergeCSSClasses } from "../../../../shared/utils"; +import { defaultBlockToHTML } from "../../nodes/BlockContent/defaultBlockHelpers"; +import { inheritedProps } from "../defaultProps"; +import { InlineContentSchema } from "../inlineContent/types"; +import { StyleSchema } from "../styles/types"; +import { + BlockConfig, + BlockSchemaFromSpecs, + BlockSchemaWithBlock, + BlockSpec, + BlockSpecs, + PropSchema, + Props, + SpecificBlock, + TiptapBlockImplementation, +} from "./types"; + +export function camelToDataKebab(str: string): string { + return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +// Function that uses the 'propSchema' of a blockConfig to create a TipTap +// node's `addAttributes` property. +// TODO: extract function +export function propsToAttributes(propSchema: PropSchema): Attributes { + const tiptapAttributes: Record = {}; + + Object.entries(propSchema) + .filter(([name, _spec]) => !inheritedProps.includes(name)) + .forEach(([name, spec]) => { + tiptapAttributes[name] = { + default: spec.default, + keepOnSplit: true, + // Props are displayed in kebab-case as HTML attributes. If a prop's + // value is the same as its default, we don't display an HTML + // attribute for it. + parseHTML: (element) => { + const value = element.getAttribute(camelToDataKebab(name)); + + if (value === null) { + return null; + } + + if (typeof spec.default === "boolean") { + if (value === "true") { + return true; + } + + if (value === "false") { + return false; + } + + return null; + } + + if (typeof spec.default === "number") { + const asNumber = parseFloat(value); + const isNumeric = + !Number.isNaN(asNumber) && Number.isFinite(asNumber); + + if (isNumeric) { + return asNumber; + } + + return null; + } + + return value; + }, + renderHTML: (attributes) => + attributes[name] !== spec.default + ? { + [camelToDataKebab(name)]: attributes[name], + } + : {}, + }; + }); + + return tiptapAttributes; +} + +// Used to figure out which block should be rendered. This block is then used to +// create the node view. +export function getBlockFromPos< + BType extends string, + Config extends BlockConfig, + BSchema extends BlockSchemaWithBlock, + I extends InlineContentSchema, + S extends StyleSchema +>( + getPos: (() => number) | boolean, + editor: BlockNoteEditor, + tipTapEditor: Editor, + type: BType +) { + // Gets position of the node + if (typeof getPos === "boolean") { + throw new Error( + "Cannot find node position as getPos is a boolean, not a function." + ); + } + const pos = getPos(); + // Gets parent blockContainer node + const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); + // Gets block identifier + const blockIdentifier = blockContainer.attrs.id; + // Gets the block + const block = editor.getBlock(blockIdentifier)! as SpecificBlock< + BSchema, + BType, + I, + S + >; + if (block.type !== type) { + throw new Error("Block type does not match"); + } + + return block; +} + +// Function that wraps the `dom` element returned from 'blockConfig.render' in a +// `blockContent` div, which contains the block type and props as HTML +// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds +// an `inlineContent` class to it. +export function wrapInBlockStructure< + BType extends string, + PSchema extends PropSchema +>( + element: { + dom: HTMLElement; + contentDOM?: HTMLElement; + destroy?: () => void; + }, + blockType: BType, + blockProps: Props, + propSchema: PSchema, + domAttributes?: Record +): { + dom: HTMLElement; + contentDOM?: HTMLElement; + destroy?: () => void; +} { + // Creates `blockContent` element + const blockContent = document.createElement("div"); + + // Adds custom HTML attributes + if (domAttributes !== undefined) { + for (const [attr, value] of Object.entries(domAttributes)) { + if (attr !== "class") { + blockContent.setAttribute(attr, value); + } + } + } + // Sets blockContent class + blockContent.className = mergeCSSClasses( + "bn-block-content", + domAttributes?.class || "" + ); + // Sets content type attribute + blockContent.setAttribute("data-content-type", blockType); + // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props + // which are already added as HTML attributes to the parent `blockContent` + // element (inheritedProps) and props set to their default values. + for (const [prop, value] of Object.entries(blockProps)) { + if (!inheritedProps.includes(prop) && value !== propSchema[prop].default) { + blockContent.setAttribute(camelToDataKebab(prop), value); + } + } + + blockContent.appendChild(element.dom); + + if (element.contentDOM !== undefined) { + element.contentDOM.className = mergeCSSClasses( + "bn-inline-content", + element.contentDOM.className + ); + } + + return { + ...element, + dom: blockContent, + }; +} + +// Helper type to keep track of the `name` and `content` properties after calling Node.create. +type StronglyTypedTipTapNode< + Name extends string, + Content extends "inline*" | "tableRow+" | "" +> = Node & { name: Name; config: { content: Content } }; + +export function createStronglyTypedTiptapNode< + Name extends string, + Content extends "inline*" | "tableRow+" | "" +>(config: NodeConfig & { name: Name; content: Content }) { + return Node.create(config) as StronglyTypedTipTapNode; // force re-typing (should be safe as it's type-checked from the config) +} + +// This helper function helps to instantiate a blockspec with a +// config and implementation that conform to the type of Config +export function createInternalBlockSpec( + config: T, + implementation: TiptapBlockImplementation< + T, + any, + InlineContentSchema, + StyleSchema + > +) { + return { + config, + implementation, + } satisfies BlockSpec; +} + +export function createBlockSpecFromStronglyTypedTiptapNode< + T extends Node, + P extends PropSchema +>(node: T, propSchema: P, requiredExtensions?: Array) { + return createInternalBlockSpec( + { + type: node.name as T["name"], + content: (node.config.content === "inline*" + ? "inline" + : node.config.content === "tableRow+" + ? "table" + : "none") as T["config"]["content"] extends "inline*" + ? "inline" + : T["config"]["content"] extends "tableRow+" + ? "table" + : "none", + propSchema, + }, + { + node, + requiredExtensions, + toInternalHTML: defaultBlockToHTML, + toExternalHTML: defaultBlockToHTML, + // parse: () => undefined, // parse rules are in node already + } + ); +} + +export function getBlockSchemaFromSpecs(specs: T) { + return Object.fromEntries( + Object.entries(specs).map(([key, value]) => [key, value.config]) + ) as BlockSchemaFromSpecs; +} diff --git a/packages/core/src/extensions/Blocks/api/blocks/types.ts b/packages/core/src/extensions/Blocks/api/blocks/types.ts new file mode 100644 index 0000000000..29b4acfe79 --- /dev/null +++ b/packages/core/src/extensions/Blocks/api/blocks/types.ts @@ -0,0 +1,284 @@ +/** Define the main block types **/ +import { Extension, Node } from "@tiptap/core"; + +import { BlockNoteEditor } from "../../../../BlockNoteEditor"; +import { + InlineContent, + InlineContentSchema, + PartialInlineContent, +} from "../inlineContent/types"; +import { StyleSchema } from "../styles/types"; + +export type BlockNoteDOMElement = + | "editor" + | "blockContainer" + | "blockGroup" + | "blockContent" + | "inlineContent"; + +export type BlockNoteDOMAttributes = Partial<{ + [DOMElement in BlockNoteDOMElement]: Record; +}>; + +// Defines a single prop spec, which includes the default value the prop should +// take and possible values it can take. +export type PropSpec = { + values?: readonly PType[]; + default: PType; +}; + +// Defines multiple block prop specs. The key of each prop is the name of the +// prop, while the value is a corresponding prop spec. This should be included +// in a block config or schema. From a prop schema, we can derive both the props' +// internal implementation (as TipTap node attributes) and the type information +// for the external API. +export type PropSchema = Record>; + +// Defines Props objects for use in Block objects in the external API. Converts +// each prop spec into a union type of its possible values, or a string if no +// values are specified. +export type Props = { + [PName in keyof PSchema]: PSchema[PName]["default"] extends boolean + ? PSchema[PName]["values"] extends readonly boolean[] + ? PSchema[PName]["values"][number] + : boolean + : PSchema[PName]["default"] extends number + ? PSchema[PName]["values"] extends readonly number[] + ? PSchema[PName]["values"][number] + : number + : PSchema[PName]["default"] extends string + ? PSchema[PName]["values"] extends readonly string[] + ? PSchema[PName]["values"][number] + : string + : never; +}; + +// BlockConfig contains the "schema" info about a Block type +// i.e. what props it supports, what content it supports, etc. +export type BlockConfig = { + type: string; + readonly propSchema: PropSchema; + content: "inline" | "none" | "table"; +}; + +// Block implementation contains the "implementation" info about a Block +// such as the functions / Nodes required to render and / or serialize it +export type TiptapBlockImplementation< + T extends BlockConfig, + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + requiredExtensions?: Array; + node: Node; + toInternalHTML: ( + block: BlockFromConfigNoChildren & { + children: Block[]; + }, + editor: BlockNoteEditor + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + toExternalHTML: ( + block: BlockFromConfigNoChildren & { + children: Block[]; + }, + editor: BlockNoteEditor + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; +}; + +// A Spec contains both the Config and Implementation +export type BlockSpec< + T extends BlockConfig, + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + config: T; + implementation: TiptapBlockImplementation; +}; + +// Utility type. For a given object block schema, ensures that the key of each +// block spec matches the name of the TipTap node in it. +type NamesMatch> = Blocks extends { + [Type in keyof Blocks]: Type extends string + ? Blocks[Type] extends { type: Type } + ? Blocks[Type] + : never + : never; +} + ? Blocks + : never; + +// A Schema contains all the types (Configs) supported in an editor +// The keys are the "type" of a block +export type BlockSchema = NamesMatch>; + +export type BlockSpecs = Record< + string, + BlockSpec +>; + +export type BlockImplementations = Record< + string, + TiptapBlockImplementation +>; + +export type BlockSchemaFromSpecs = { + [K in keyof T]: T[K]["config"]; +}; + +export type BlockSchemaWithBlock< + BType extends string, + C extends BlockConfig +> = { + [k in BType]: C; +}; + +export type TableContent< + I extends InlineContentSchema, + S extends StyleSchema = StyleSchema +> = { + type: "tableContent"; + rows: { + cells: InlineContent[][]; + }[]; +}; + +// A BlockConfig has all the information to get the type of a Block (which is a specific instance of the BlockConfig. +// i.e.: paragraphConfig: BlockConfig defines what a "paragraph" is / supports, and BlockFromConfigNoChildren is the shape of a specific paragraph block. +// (for internal use) +export type BlockFromConfigNoChildren< + B extends BlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = { + id: string; + type: B["type"]; + props: Props; + content: B["content"] extends "inline" + ? InlineContent[] + : B["content"] extends "table" + ? TableContent + : B["content"] extends "none" + ? undefined + : never; +}; + +export type BlockFromConfig< + B extends BlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = BlockFromConfigNoChildren & { + children: Block[]; +}; + +// Converts each block spec into a Block object without children. We later merge +// them into a union type and add a children property to create the Block and +// PartialBlock objects we use in the external API. +type BlocksWithoutChildren< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + [BType in keyof BSchema]: BlockFromConfigNoChildren; +}; + +// Converts each block spec into a Block object without children, merges them +// into a union type, and adds a children property +export type Block< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = BlocksWithoutChildren[keyof BSchema] & { + children: Block[]; +}; + +export type SpecificBlock< + BSchema extends BlockSchema, + BType extends keyof BSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = BlocksWithoutChildren[BType] & { + children: Block[]; +}; + +/** CODE FOR PARTIAL BLOCKS, analogous to above + * + * Partial blocks are convenience-wrappers to make it easier to + *create/update blocks in the editor. + * + */ + +export type PartialTableContent< + I extends InlineContentSchema, + S extends StyleSchema = StyleSchema +> = { + type: "tableContent"; + rows: { + cells: PartialInlineContent[]; + }[]; +}; + +type PartialBlockFromConfigNoChildren< + B extends BlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = { + id?: string; + type?: B["type"]; + props?: Partial>; + content?: B["content"] extends "inline" + ? PartialInlineContent + : B["content"] extends "table" + ? PartialTableContent + : undefined; +}; + +type PartialBlocksWithoutChildren< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + [BType in keyof BSchema]: PartialBlockFromConfigNoChildren< + BSchema[BType], + I, + S + >; +}; + +export type PartialBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = PartialBlocksWithoutChildren< + BSchema, + I, + S +>[keyof PartialBlocksWithoutChildren] & + Partial<{ + children: PartialBlock[]; + }>; + +export type SpecificPartialBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + BType extends keyof BSchema, + S extends StyleSchema +> = PartialBlocksWithoutChildren[BType] & { + children?: Block[]; +}; + +export type PartialBlockFromConfig< + B extends BlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = PartialBlockFromConfigNoChildren & { + children?: Block[]; +}; + +export type BlockIdentifier = { id: string } | string; diff --git a/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts b/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts index eb17e098f3..ce21cda6f4 100644 --- a/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts +++ b/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts @@ -1,7 +1,13 @@ -import { Block, BlockSchema } from "./blockTypes"; +import { Block, BlockSchema } from "./blocks/types"; +import { InlineContentSchema } from "./inlineContent/types"; +import { StyleSchema } from "./styles/types"; -export type TextCursorPosition = { - block: Block; - prevBlock: Block | undefined; - nextBlock: Block | undefined; +export type TextCursorPosition< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + block: Block; + prevBlock: Block | undefined; + nextBlock: Block | undefined; }; diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts index 88c7f0f640..dd15f12f74 100644 --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts @@ -1,16 +1,60 @@ +import Bold from "@tiptap/extension-bold"; +import Code from "@tiptap/extension-code"; +import Italic from "@tiptap/extension-italic"; +import Strike from "@tiptap/extension-strike"; +import Underline from "@tiptap/extension-underline"; +import { BackgroundColor } from "../../BackgroundColor/BackgroundColorMark"; +import { TextColor } from "../../TextColor/TextColorMark"; import { Heading } from "../nodes/BlockContent/HeadingBlockContent/HeadingBlockContent"; +import { Image } from "../nodes/BlockContent/ImageBlockContent/ImageBlockContent"; import { BulletListItem } from "../nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent"; import { NumberedListItem } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; import { Paragraph } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent"; -import { Image } from "../nodes/BlockContent/ImageBlockContent/ImageBlockContent"; -import { BlockSchema } from "./blockTypes"; +import { Table } from "../nodes/BlockContent/TableBlockContent/TableBlockContent"; +import { getBlockSchemaFromSpecs } from "./blocks/internal"; +import { BlockSpecs } from "./blocks/types"; +import { getInlineContentSchemaFromSpecs } from "./inlineContent/internal"; +import { InlineContentSpecs } from "./inlineContent/types"; +import { + createStyleSpecFromTipTapMark, + getStyleSchemaFromSpecs, +} from "./styles/internal"; +import { StyleSpecs } from "./styles/types"; -export const defaultBlockSchema = { +export const defaultBlockSpecs = { paragraph: Paragraph, heading: Heading, bulletListItem: BulletListItem, numberedListItem: NumberedListItem, image: Image, -} as const satisfies BlockSchema; + table: Table, +} satisfies BlockSpecs; + +export const defaultBlockSchema = getBlockSchemaFromSpecs(defaultBlockSpecs); export type DefaultBlockSchema = typeof defaultBlockSchema; + +export const defaultStyleSpecs = { + bold: createStyleSpecFromTipTapMark(Bold, "boolean"), + italic: createStyleSpecFromTipTapMark(Italic, "boolean"), + underline: createStyleSpecFromTipTapMark(Underline, "boolean"), + strike: createStyleSpecFromTipTapMark(Strike, "boolean"), + code: createStyleSpecFromTipTapMark(Code, "boolean"), + textColor: TextColor, + backgroundColor: BackgroundColor, +} satisfies StyleSpecs; + +export const defaultStyleSchema = getStyleSchemaFromSpecs(defaultStyleSpecs); + +export type DefaultStyleSchema = typeof defaultStyleSchema; + +export const defaultInlineContentSpecs = { + text: { config: "text", implementation: {} as any }, + link: { config: "link", implementation: {} as any }, +} satisfies InlineContentSpecs; + +export const defaultInlineContentSchema = getInlineContentSchemaFromSpecs( + defaultInlineContentSpecs +); + +export type DefaultInlineContentSchema = typeof defaultInlineContentSchema; diff --git a/packages/core/src/extensions/Blocks/api/defaultProps.ts b/packages/core/src/extensions/Blocks/api/defaultProps.ts index b4fa7b97c5..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: { @@ -14,3 +14,8 @@ export const defaultProps = { } satisfies PropSchema; export type DefaultProps = Props; + +// Default props which are set on `blockContainer` nodes rather than +// `blockContent` nodes. Ensures that they are not redundantly added to +// a custom block's TipTap node attributes. +export const inheritedProps = ["backgroundColor", "textColor"]; 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/serialization.ts b/packages/core/src/extensions/Blocks/api/serialization.ts deleted file mode 100644 index 58557853c3..0000000000 --- a/packages/core/src/extensions/Blocks/api/serialization.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Extension } from "@tiptap/core"; -import { Plugin } from "prosemirror-state"; -import { DOMSerializer, Schema } from "prosemirror-model"; - -const customBlockSerializer = (schema: Schema) => { - const defaultSerializer = DOMSerializer.fromSchema(schema); - - return new DOMSerializer( - { - ...defaultSerializer.nodes, - // TODO: If a serializer is defined in the config for a custom block, it - // should be added here. We still need to figure out how the serializer - // should be defined in the custom blocks API though, and implement that, - // before we can do this. - }, - defaultSerializer.marks - ); -}; -export const CustomBlockSerializerExtension = Extension.create({ - addProseMirrorPlugins() { - return [ - new Plugin({ - props: { - clipboardSerializer: customBlockSerializer(this.editor.schema), - }, - }), - ]; - }, -}); \ No newline at end of file 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/Block.module.css b/packages/core/src/extensions/Blocks/nodes/Block.css similarity index 66% rename from packages/core/src/extensions/Blocks/nodes/Block.module.css rename to packages/core/src/extensions/Blocks/nodes/Block.css index f8626d053a..bc5ea21eb2 100644 --- a/packages/core/src/extensions/Blocks/nodes/Block.module.css +++ b/packages/core/src/extensions/Blocks/nodes/Block.css @@ -2,24 +2,24 @@ BASIC STYLES */ -.blockOuter { +.bn-block-outer { line-height: 1.5; transition: margin 0.2s; } /*Ensures blocks & block content spans editor width*/ -.block { +.bn-block { display: flex; flex-direction: column; } /*Ensures block content inside React node views spans editor width*/ -.reactNodeViewRenderer { +.bn-react-node-view-renderer { display: flex; flex-grow: 1; } -.blockContent { +.bn-block-content { padding: 3px 0; flex-grow: 1; transition: font-size 0.2s; @@ -30,7 +30,7 @@ BASIC STYLES */ } -.blockContent::before { +.bn-block-content::before { /* content: ""; */ transition: all 0.2s; /*margin: 0px;*/ @@ -40,15 +40,16 @@ BASIC STYLES NESTED BLOCKS */ -.blockGroup .blockGroup { +.bn-block-group .bn-block-group { margin-left: 1.5em; } -.blockGroup .blockGroup > .blockOuter { +.bn-block-group .bn-block-group > .bn-block-outer { position: relative; } -.blockGroup .blockGroup > .blockOuter:not([data-prev-depth-changed])::before { +.bn-block-group .bn-block-group + > .bn-block-outer:not([data-prev-depth-changed])::before { content: " "; display: inline; position: absolute; @@ -57,7 +58,8 @@ NESTED BLOCKS transition: all 0.2s 0.1s; } -.blockGroup .blockGroup > .blockOuter[data-prev-depth-change="-2"]::before { +.bn-block-group .bn-block-group + > .bn-block-outer[data-prev-depth-change="-2"]::before { height: 0; } @@ -95,11 +97,12 @@ NESTED BLOCKS --x: -5; } -.blockOuter[data-prev-depth-change] { +.bn-block-outer[data-prev-depth-change] { margin-left: calc(10px * var(--x)); } -.blockOuter[data-prev-depth-change] .blockOuter[data-prev-depth-change] { +.bn-block-outer[data-prev-depth-change] + .bn-block-outer[data-prev-depth-change] { margin-left: 0; } @@ -124,21 +127,21 @@ NESTED BLOCKS --prev-level: 1.3em; } -.blockOuter[data-prev-type="heading"] > .block > .blockContent { +.bn-block-outer[data-prev-type="heading"] > .bn-block > .bn-block-content { font-size: var(--prev-level); font-weight: bold; } -.blockOuter:not([data-prev-type]) - > .block - > .blockContent[data-content-type="heading"] { +.bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-block-content[data-content-type="heading"] { font-size: var(--level); font-weight: bold; } /* LISTS */ -.blockContent::before { +.bn-block-content::before { margin-right: 0; content: ""; } @@ -152,79 +155,81 @@ NESTED BLOCKS --prev-index: attr(data-prev-index); } -.blockOuter[data-prev-type="numberedListItem"]:not([data-prev-index="none"]) - > .block - > .blockContent::before { +.bn-block-outer[data-prev-type="numberedListItem"]:not([data-prev-index="none"]) + > .bn-block + > .bn-block-content::before { margin-right: 1.2em; content: var(--prev-index) "."; } -.blockOuter:not([data-prev-type]) - > .block - > .blockContent[data-content-type="numberedListItem"]::before { +.bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-block-content[data-content-type="numberedListItem"]::before { margin-right: 1.2em; content: var(--index) "."; } /* Unordered */ /* No list nesting */ -.blockOuter[data-prev-type="bulletListItem"] > .block > .blockContent::before { +.bn-block-outer[data-prev-type="bulletListItem"] + > .bn-block + > .bn-block-content::before { margin-right: 1.2em; content: "•"; } -.blockOuter:not([data-prev-type]) - > .block - > .blockContent[data-content-type="bulletListItem"]::before { +.bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-block-content[data-content-type="bulletListItem"]::before { margin-right: 1.2em; content: "•"; } /* 1 level of list nesting */ [data-content-type="bulletListItem"] - ~ .blockGroup - > .blockOuter[data-prev-type="bulletListItem"] - > .block - > .blockContent::before { + ~ .bn-block-group + > .bn-block-outer[data-prev-type="bulletListItem"] + > .bn-block + > .bn-block-content::before { margin-right: 1.2em; content: "◦"; } [data-content-type="bulletListItem"] - ~ .blockGroup - > .blockOuter:not([data-prev-type]) - > .block - > .blockContent[data-content-type="bulletListItem"]::before { + ~ .bn-block-group + > .bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-block-content[data-content-type="bulletListItem"]::before { margin-right: 1.2em; content: "◦"; } /* 2 levels of list nesting */ [data-content-type="bulletListItem"] - ~ .blockGroup + ~ .bn-block-group [data-content-type="bulletListItem"] - ~ .blockGroup - > .blockOuter[data-prev-type="bulletListItem"] - > .block - > .blockContent::before { + ~ .bn-block-group + > .bn-block-outer[data-prev-type="bulletListItem"] + > .bn-block + > .bn-block-content::before { margin-right: 1.2em; content: "▪"; } [data-content-type="bulletListItem"] - ~ .blockGroup + ~ .bn-block-group [data-content-type="bulletListItem"] - ~ .blockGroup - > .blockOuter:not([data-prev-type]) - > .block - > .blockContent[data-content-type="bulletListItem"]::before { + ~ .bn-block-group + > .bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-block-content[data-content-type="bulletListItem"]::before { margin-right: 1.2em; content: "▪"; } /* IMAGES */ -[data-content-type="image"] .wrapper { +[data-content-type="image"] .bn-image-block-content-wrapper { display: flex; flex-direction: column; justify-content: center; @@ -232,7 +237,7 @@ NESTED BLOCKS width: 100%; } -[data-content-type="image"] .addImageButton { +[data-content-type="image"] .bn-add-image-button { display: flex; flex-direction: row; align-items: center; @@ -244,27 +249,27 @@ NESTED BLOCKS width: 100%; } -[data-content-type="image"] .addImageButton:hover { +[data-content-type="image"] .bn-add-image-button:hover { background-color: gainsboro; } -[data-content-type="image"] .addImageButtonIcon { +[data-content-type="image"] .bn-add-image-button-icon { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 5H4V19L13.2923 9.70649C13.6828 9.31595 14.3159 9.31591 14.7065 9.70641L20 15.0104V5ZM2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z'%3E%3C/path%3E%3C/svg%3E"); width: 24px; height: 24px; } -[data-content-type="image"] .addImageButtonText { +[data-content-type="image"] .bn-add-image-button-text { color: black; } -[data-content-type="image"] .imageAndCaptionWrapper { +[data-content-type="image"] .bn-image-and-caption-wrapper { display: flex; flex-direction: column; border-radius: 4px; } -[data-content-type="image"] .imageWrapper { +[data-content-type="image"] .bn-image-wrapper { display: flex; flex-direction: row; align-items: center; @@ -272,12 +277,12 @@ NESTED BLOCKS width: fit-content; } -[data-content-type="image"] .image { +[data-content-type="image"] .bn-image { border-radius: 4px; max-width: 100%; } -[data-content-type="image"] .resizeHandle { +[data-content-type="image"] .bn-image-resize-handle { display: none; position: absolute; width: 8px; @@ -294,8 +299,8 @@ NESTED BLOCKS /* PLACEHOLDERS*/ -.isEmpty .inlineContent:before, -.isFilter .inlineContent:before { +.bn-is-empty .bn-inline-content:before, +.bn-is-filter .bn-inline-content:before { /*float: left; */ content: ""; pointer-events: none; @@ -307,25 +312,27 @@ NESTED BLOCKS /* TODO: would be nicer if defined from code */ -.blockContent.isEmpty.hasAnchor .inlineContent:before { +.bn-block-content.bn-is-empty.bn-has-anchor .bn-inline-content:before { content: "Enter text or type '/' for commands"; } -.blockContent.isFilter.hasAnchor .inlineContent:before { +.bn-block-content.bn-is-filter.bn-has-anchor .bn-inline-content:before { content: "Type to filter"; } -.blockContent[data-content-type="heading"].isEmpty .inlineContent:before { +.bn-block-content[data-content-type="heading"].bn-is-empty + .bn-inline-content:before { content: "Heading"; } -.blockContent[data-content-type="bulletListItem"].isEmpty .inlineContent:before, -.blockContent[data-content-type="numberedListItem"].isEmpty -.inlineContent:before { +.bn-block-content[data-content-type="bulletListItem"].bn-is-empty + .bn-inline-content:before, + .bn-block-content[data-content-type="numberedListItem"].bn-is-empty + .bn-inline-content:before { content: "List"; } -.isEmpty .blockContent[data-content-type="captionedImage"] .inlineContent:before { +.bn-is-empty .bn-block-content[data-content-type="captionedImage"] .bn-inline-content:before { content: "Caption"; } diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index 2265668ed0..bba83b4308 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -1,22 +1,25 @@ -import { mergeAttributes, Node } from "@tiptap/core"; +import { Node } from "@tiptap/core"; import { Fragment, Node as PMNode, Slice } from "prosemirror-model"; import { NodeSelection, TextSelection } from "prosemirror-state"; + +import { BlockNoteEditor } from "../../../BlockNoteEditor"; import { blockToNode, inlineContentToNodes, + tableContentToNodes, } from "../../../api/nodeConversions/nodeConversions"; - +import { UnreachableCaseError, mergeCSSClasses } from "../../../shared/utils"; +import { NonEditableBlockPlugin } from "../NonEditableBlockPlugin"; +import { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin"; import { BlockNoteDOMAttributes, BlockSchema, PartialBlock, -} from "../api/blockTypes"; +} from "../api/blocks/types"; +import { InlineContentSchema } from "../api/inlineContent/types"; +import { StyleSchema } from "../api/styles/types"; import { getBlockInfoFromPos } from "../helpers/getBlockInfoFromPos"; -import { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin"; -import styles from "./Block.module.css"; import BlockAttributes from "./BlockAttributes"; -import { mergeCSSClasses } from "../../../shared/utils"; -import { NonEditableBlockPlugin } from "../NonEditableBlockPlugin"; declare module "@tiptap/core" { interface Commands { @@ -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", @@ -78,27 +90,34 @@ export const BlockContainer = Node.create<{ }, renderHTML({ HTMLAttributes }) { - const domAttributes = this.options.domAttributes?.blockContainer || {}; + const blockOuter = document.createElement("div"); + blockOuter.className = "bn-block-outer"; + blockOuter.setAttribute("data-node-type", "blockOuter"); + for (const [attribute, value] of Object.entries(HTMLAttributes)) { + if (attribute !== "class") { + blockOuter.setAttribute(attribute, value); + } + } + + const blockHTMLAttributes = { + ...(this.options.domAttributes?.blockContainer || {}), + ...HTMLAttributes, + }; + const block = document.createElement("div"); + block.className = mergeCSSClasses("bn-block", blockHTMLAttributes.class); + block.setAttribute("data-node-type", this.name); + for (const [attribute, value] of Object.entries(blockHTMLAttributes)) { + if (attribute !== "class") { + block.setAttribute(attribute, value); + } + } - return [ - "div", - mergeAttributes(HTMLAttributes, { - class: styles.blockOuter, - "data-node-type": "block-outer", - }), - [ - "div", - mergeAttributes( - { - ...domAttributes, - class: mergeCSSClasses(styles.block, domAttributes.class), - "data-node-type": this.name, - }, - HTMLAttributes - ), - 0, - ], - ]; + blockOuter.appendChild(block); + + return { + dom: blockOuter, + contentDOM: block, + }; }, addCommands() { @@ -151,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. @@ -171,59 +196,63 @@ export const BlockContainer = Node.create<{ } } - // Replaces the blockContent node's content if necessary. - if (block.content !== undefined) { - let content: PMNode[] = []; + const oldType = contentNode.type.name; + const newType = block.type || oldType; + + // The code below determines the new content of the block. + // or "keep" to keep as-is + let content: PMNode[] | "keep" = "keep"; - // Checks if the provided content is a string or InlineContent[] type. + // Has there been any custom content provided? + if (block.content) { if (typeof block.content === "string") { // Adds a single text node with no marks to the content. - content.push(state.schema.text(block.content)); + content = [state.schema.text(block.content)]; + } else if (Array.isArray(block.content)) { + // Adds a text node with the provided styles converted into marks to the content, + // for each InlineContent object. + content = inlineContentToNodes( + block.content, + state.schema, + this.options.editor.styleSchema + ); + } else if (block.content.type === "tableContent") { + content = tableContentToNodes( + block.content, + state.schema, + this.options.editor.styleSchema + ); } else { - // Adds a text node with the provided styles converted into marks to the content, for each InlineContent - // object. - content = inlineContentToNodes(block.content, state.schema); + throw new UnreachableCaseError(block.content.type); + } + } else { + // no custom content has been provided, use existing content IF possible + + // Since some block types contain inline content and others don't, + // we either need to call setNodeMarkup to just update type & + // attributes, or replaceWith to replace the whole blockContent. + const oldContentType = state.schema.nodes[oldType].spec.content; + const newContentType = state.schema.nodes[newType].spec.content; + + if (oldContentType === "") { + // keep old content, because it's empty anyway and should be compatible with + // any newContentType + } else if (newContentType !== oldContentType) { + // the content type changed, replace the previous content + content = []; + } else { + // keep old content, because the content type is the same and should be compatible } - - // Replaces the contents of the blockContent node with the previously created text node(s). - state.tr.replace( - startPos + 1, - startPos + contentNode.nodeSize - 1, - new Slice(Fragment.from(content), 0, 0) - ); } - // Since some block types contain inline content and others don't, - // we either need to call setNodeMarkup to just update type & - // attributes, or replaceWith to replace the whole blockContent. - const oldType = contentNode.type.name; - const newType = block.type || oldType; - - const oldContentType = state.schema.nodes[oldType].spec.content; - const newContentType = state.schema.nodes[newType].spec.content; - - if (oldContentType === "inline*" && newContentType === "") { - // Replaces the blockContent node with one of the new type and - // adds the provided props as attributes. Also preserves all - // existing attributes that are compatible with the new type. - // Need to reset the selection since replacing the block content - // sets it to the next block. - state.tr - .replaceWith( - startPos, - endPos, - state.schema.nodes[newType].create({ - ...contentNode.attrs, - ...block.props, - }) - ) - .setSelection( - new NodeSelection(state.tr.doc.resolve(startPos)) - ); - } else { - // Changes the blockContent node type and adds the provided props - // as attributes. Also preserves all existing attributes that are - // compatible with the new type. + // Now, changes the blockContent node type and adds the provided props + // as attributes. Also preserves all existing attributes that are + // compatible with the new type. + // + // Use either setNodeMarkup or replaceWith depending on whether the + // content is being replaced or not. + if (content === "keep") { + // use setNodeMarkup to only update the type and attributes state.tr.setNodeMarkup( startPos, block.type === undefined @@ -234,6 +263,35 @@ export const BlockContainer = Node.create<{ ...block.props, } ); + } else { + // use replaceWith to replace the content and the block itself + // also reset the selection since replacing the block content + // sets it to the next block. + state.tr + .replaceWith( + startPos, + endPos, + state.schema.nodes[newType].create( + { + ...contentNode.attrs, + ...block.props, + }, + content + ) + ) + // If the node doesn't contain editable content, we want to + // select the whole node. But if it does have editable content, + // we want to set the selection to the start of it. + .setSelection( + state.schema.nodes[newType].spec.content === "" + ? new NodeSelection(state.tr.doc.resolve(startPos)) + : state.schema.nodes[newType].spec.content === "inline*" + ? new TextSelection(state.tr.doc.resolve(startPos)) + : // Need to offset the position as we have to get through the + // `tableRow` and `tableCell` nodes to get to the + // `tableParagraph` node we want to set the selection in. + new TextSelection(state.tr.doc.resolve(startPos + 4)) + ); } // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing @@ -425,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) { @@ -446,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"); @@ -464,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; @@ -494,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 ad9d7b73d8..50a0b74197 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts @@ -1,25 +1,34 @@ -import { InputRule, mergeAttributes } from "@tiptap/core"; +import { InputRule } from "@tiptap/core"; +import { + createBlockSpecFromStronglyTypedTiptapNode, + createStronglyTypedTiptapNode, +} from "../../../api/blocks/internal"; +import { PropSchema } from "../../../api/blocks/types"; import { defaultProps } from "../../../api/defaultProps"; -import { createTipTapBlock } from "../../../api/block"; -import { BlockSpec, PropSchema } from "../../../api/blockTypes"; -import { mergeCSSClasses } from "../../../../../shared/utils"; -import styles from "../../Block.module.css"; +import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers"; export const headingPropSchema = { ...defaultProps, level: { default: 1, values: [1, 2, 3] as const }, } satisfies PropSchema; -const HeadingBlockContent = createTipTapBlock<"heading", true>({ +const HeadingBlockContent = createStronglyTypedTiptapNode({ name: "heading", content: "inline*", - + group: "blockContent", addAttributes() { return { level: { default: 1, // instead of "level" attributes, use "data-level" - parseHTML: (element) => element.getAttribute("data-level")!, + parseHTML: (element) => { + const attr = element.getAttribute("data-level")!; + const parsed = parseInt(attr); + if (isFinite(parsed)) { + return parsed; + } + return undefined; + }, renderHTML: (attributes) => { return { "data-level": (attributes.level as number).toString(), @@ -37,12 +46,10 @@ const HeadingBlockContent = createTipTapBlock<"heading", true>({ find: new RegExp(`^(#{${level}})\\s$`), handler: ({ state, chain, range }) => { chain() - .BNUpdateBlock<{ - heading: BlockSpec<"heading", typeof headingPropSchema, true>; - }>(state.selection.from, { + .BNUpdateBlock(state.selection.from, { type: "heading", props: { - level: level as 1 | 2 | 3, + level: level as any, }, }) // Removes the "#" character(s) used to set the heading. @@ -56,37 +63,42 @@ const HeadingBlockContent = createTipTapBlock<"heading", true>({ addKeyboardShortcuts() { return { "Mod-Alt-1": () => - this.editor.commands.BNUpdateBlock<{ - heading: BlockSpec<"heading", typeof headingPropSchema, true>; - }>(this.editor.state.selection.anchor, { + this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, { type: "heading", props: { - level: 1, + level: 1 as any, }, }), "Mod-Alt-2": () => - this.editor.commands.BNUpdateBlock<{ - heading: BlockSpec<"heading", typeof headingPropSchema, true>; - }>(this.editor.state.selection.anchor, { + this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, { type: "heading", props: { - level: 2, + level: 2 as any, }, }), "Mod-Alt-3": () => - this.editor.commands.BNUpdateBlock<{ - heading: BlockSpec<"heading", typeof headingPropSchema, true>; - }>(this.editor.state.selection.anchor, { + this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, { type: "heading", props: { - level: 3, + level: 3 as any, }, }), }; }, - 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 }, @@ -106,37 +118,19 @@ const HeadingBlockContent = createTipTapBlock<"heading", true>({ }, renderHTML({ node, HTMLAttributes }) { - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - const inlineContentDOMAttributes = - this.options.domAttributes?.inlineContent || {}; - - return [ - "div", - mergeAttributes(HTMLAttributes, { - ...blockContentDOMAttributes, - class: mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ), - "data-content-type": this.name, - }), - [ - `h${node.attrs.level}`, - { - ...inlineContentDOMAttributes, - class: mergeCSSClasses( - styles.inlineContent, - inlineContentDOMAttributes.class - ), - }, - 0, - ], - ]; + return createDefaultBlockDOMOutputSpec( + this.name, + `h${node.attrs.level}`, + { + ...(this.options.domAttributes?.blockContent || {}), + ...HTMLAttributes, + }, + this.options.domAttributes?.inlineContent || {} + ); }, }); -export const Heading = { - node: HeadingBlockContent, - propSchema: headingPropSchema, -} satisfies BlockSpec<"heading", typeof headingPropSchema, true>; +export const Heading = createBlockSpecFromStronglyTypedTiptapNode( + HeadingBlockContent, + headingPropSchema +); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts index 95b5fd3173..4a373c03e5 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -1,9 +1,18 @@ -import { createBlockSpec } from "../../../api/block"; -import { defaultProps } from "../../../api/defaultProps"; -import { BlockSpec, PropSchema, SpecificBlock } from "../../../api/blockTypes"; import { BlockNoteEditor } from "../../../../../BlockNoteEditor"; import { imageToolbarPluginKey } from "../../../../ImageToolbar/ImageToolbarPlugin"; -import styles from "../../Block.module.css"; + +import { + CustomBlockConfig, + createBlockSpec, +} from "../../../api/blocks/createSpec"; +import { + BlockFromConfig, + BlockSchemaWithBlock, + PropSchema, +} 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, @@ -41,50 +50,51 @@ const textAlignmentToAlignItems = ( // Min image width in px. const minWidth = 64; -const renderImage = ( - block: SpecificBlock< - { image: BlockSpec<"image", typeof imagePropSchema, false> }, - "image" - >, - editor: BlockNoteEditor<{ - image: BlockSpec<"image", typeof imagePropSchema, false>; - }> +const blockConfig = { + type: "image" as const, + propSchema: imagePropSchema, + content: "none", +} satisfies CustomBlockConfig; + +export const renderImage = ( + block: BlockFromConfig, + editor: BlockNoteEditor> ) => { // Wrapper element to set the image alignment, contains both image/image // upload dashboard and caption. const wrapper = document.createElement("div"); - wrapper.className = styles.wrapper; + wrapper.className = "bn-image-block-content-wrapper"; wrapper.style.alignItems = textAlignmentToAlignItems( block.props.textAlignment ); // Button element that acts as a placeholder for images with no src. const addImageButton = document.createElement("div"); - addImageButton.className = styles.addImageButton; + addImageButton.className = "bn-add-image-button"; addImageButton.style.display = block.props.url === "" ? "" : "none"; // Icon for the add image button. const addImageButtonIcon = document.createElement("div"); - addImageButtonIcon.className = styles.addImageButtonIcon; + addImageButtonIcon.className = "bn-add-image-button-icon"; // Text for the add image button. const addImageButtonText = document.createElement("p"); - addImageButtonText.className = styles.addImageButtonText; + addImageButtonText.className = "bn-add-image-button-text"; addImageButtonText.innerText = "Add Image"; // Wrapper element for the image, resize handles and caption. const imageAndCaptionWrapper = document.createElement("div"); - imageAndCaptionWrapper.className = styles.imageAndCaptionWrapper; + imageAndCaptionWrapper.className = "bn-image-and-caption-wrapper"; imageAndCaptionWrapper.style.display = block.props.url !== "" ? "" : "none"; // Wrapper element for the image and resize handles. const imageWrapper = document.createElement("div"); - imageWrapper.className = styles.imageWrapper; + imageWrapper.className = "bn-image-wrapper"; imageWrapper.style.display = block.props.url !== "" ? "" : "none"; // Image element. const image = document.createElement("img"); - image.className = styles.image; + image.className = "bn-image"; image.src = block.props.url; image.alt = "placeholder"; image.contentEditable = "false"; @@ -96,15 +106,15 @@ const renderImage = ( // Resize handle elements. const leftResizeHandle = document.createElement("div"); - leftResizeHandle.className = styles.resizeHandle; + leftResizeHandle.className = "bn-image-resize-handle"; leftResizeHandle.style.left = "4px"; const rightResizeHandle = document.createElement("div"); - rightResizeHandle.className = styles.resizeHandle; + rightResizeHandle.className = "bn-image-resize-handle"; rightResizeHandle.style.right = "4px"; // Caption element. const caption = document.createElement("p"); - caption.className = styles.caption; + caption.className = "bn-image-caption"; caption.innerText = block.props.caption; caption.style.padding = block.props.caption ? "4px" : ""; @@ -207,7 +217,7 @@ const renderImage = ( type: "image", props: { // Removes "px" from the end of the width string and converts to float. - width: parseFloat(image.style.width.slice(0, -2)), + width: parseFloat(image.style.width.slice(0, -2)) as any, }, }); }; @@ -327,9 +337,63 @@ const renderImage = ( }; }; -export const Image = createBlockSpec({ - type: "image", - propSchema: imagePropSchema, - containsInlineContent: false, - render: renderImage, -}); +export const Image = createBlockSpec( + { + type: "image" as const, + propSchema: imagePropSchema, + content: "none", + }, + { + render: renderImage, + toExternalHTML: (block) => { + if (block.props.url === "") { + const div = document.createElement("p"); + div.innerHTML = "Add Image"; + + return { + dom: div, + }; + } + + const figure = document.createElement("figure"); + + const img = document.createElement("img"); + img.src = block.props.url; + figure.appendChild(img); + + if (block.props.caption !== "") { + const figcaption = document.createElement("figcaption"); + figcaption.innerHTML = block.props.caption; + figure.appendChild(figcaption); + } + + return { + dom: figure, + }; + }, + parse: (element: HTMLElement) => { + if (element.tagName === "FIGURE") { + const img = element.querySelector("img"); + const caption = element.querySelector("figcaption"); + return { + type: "image", + props: { + url: img?.getAttribute("src") || "", + caption: + caption?.textContent || img?.getAttribute("alt") || undefined, + }, + }; + } else if (element.tagName === "IMG") { + return { + type: "image", + props: { + url: element.getAttribute("src") || "", + caption: element.getAttribute("alt") || undefined, + }, + }; + } + + return undefined; + }, + } +); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts index 04c97338fb..602510ade1 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts @@ -1,19 +1,21 @@ -import { InputRule, mergeAttributes } from "@tiptap/core"; +import { InputRule } from "@tiptap/core"; +import { + createBlockSpecFromStronglyTypedTiptapNode, + createStronglyTypedTiptapNode, +} from "../../../../api/blocks/internal"; +import { PropSchema } from "../../../../api/blocks/types"; import { defaultProps } from "../../../../api/defaultProps"; -import { createTipTapBlock } from "../../../../api/block"; -import { BlockSpec, PropSchema } from "../../../../api/blockTypes"; -import { mergeCSSClasses } from "../../../../../../shared/utils"; +import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers"; import { handleEnter } from "../ListItemKeyboardShortcuts"; -import styles from "../../../Block.module.css"; export const bulletListItemPropSchema = { ...defaultProps, } satisfies PropSchema; -const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({ +const BulletListItemBlockContent = createStronglyTypedTiptapNode({ name: "bulletListItem", content: "inline*", - + group: "blockContent", addInputRules() { return [ // Creates an unordered list when starting with "-", "+", or "*". @@ -36,13 +38,7 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({ return { Enter: () => handleEnter(this.editor), "Mod-Shift-7": () => - this.editor.commands.BNUpdateBlock<{ - bulletListItem: BlockSpec< - "bulletListItem", - typeof bulletListItemPropSchema, - true - >; - }>(this.editor.state.selection.anchor, { + this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, { type: "bulletListItem", props: {}, }), @@ -52,6 +48,9 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({ parseHTML() { return [ // Case for regular HTML list structure. + { + tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + }, { tag: "li", getAttrs: (element) => { @@ -65,7 +64,10 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({ return false; } - if (parent.tagName === "UL") { + if ( + parent.tagName === "UL" || + (parent.tagName === "DIV" && parent.parentElement!.tagName === "UL") + ) { return {}; } @@ -100,37 +102,22 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({ }, renderHTML({ HTMLAttributes }) { - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - const inlineContentDOMAttributes = - this.options.domAttributes?.inlineContent || {}; - - return [ - "div", - mergeAttributes(HTMLAttributes, { - ...blockContentDOMAttributes, - class: mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ), - "data-content-type": this.name, - }), - [ - "p", - { - ...inlineContentDOMAttributes, - class: mergeCSSClasses( - styles.inlineContent, - inlineContentDOMAttributes.class - ), - }, - 0, - ], - ]; + return createDefaultBlockDOMOutputSpec( + this.name, + // We use a

      tag, because for

    • tags we'd need a
        element to put + // them in to be semantically correct, which we can't have due to the + // schema. + "p", + { + ...(this.options.domAttributes?.blockContent || {}), + ...HTMLAttributes, + }, + this.options.domAttributes?.inlineContent || {} + ); }, }); -export const BulletListItem = { - node: BulletListItemBlockContent, - propSchema: bulletListItemPropSchema, -} satisfies BlockSpec<"bulletListItem", typeof bulletListItemPropSchema, true>; +export const BulletListItem = createBlockSpecFromStronglyTypedTiptapNode( + BulletListItemBlockContent, + bulletListItemPropSchema +); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts index 5972e215c0..e8db16998f 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts @@ -1,23 +1,22 @@ -import { InputRule, mergeAttributes } from "@tiptap/core"; +import { InputRule } from "@tiptap/core"; +import { + createBlockSpecFromStronglyTypedTiptapNode, + createStronglyTypedTiptapNode, +} from "../../../../api/blocks/internal"; +import { PropSchema } from "../../../../api/blocks/types"; import { defaultProps } from "../../../../api/defaultProps"; -import { createTipTapBlock } from "../../../../api/block"; -import { BlockSpec, PropSchema } from "../../../../api/blockTypes"; -import { mergeCSSClasses } from "../../../../../../shared/utils"; +import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers"; import { handleEnter } from "../ListItemKeyboardShortcuts"; import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin"; -import styles from "../../../Block.module.css"; export const numberedListItemPropSchema = { ...defaultProps, } satisfies PropSchema; -const NumberedListItemBlockContent = createTipTapBlock< - "numberedListItem", - true ->({ +const NumberedListItemBlockContent = createStronglyTypedTiptapNode({ name: "numberedListItem", content: "inline*", - + group: "blockContent", addAttributes() { return { index: { @@ -54,13 +53,7 @@ const NumberedListItemBlockContent = createTipTapBlock< return { Enter: () => handleEnter(this.editor), "Mod-Shift-8": () => - this.editor.commands.BNUpdateBlock<{ - numberedListItem: BlockSpec< - "numberedListItem", - typeof numberedListItemPropSchema, - true - >; - }>(this.editor.state.selection.anchor, { + this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, { type: "numberedListItem", props: {}, }), @@ -73,6 +66,9 @@ const NumberedListItemBlockContent = createTipTapBlock< parseHTML() { return [ + { + tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + }, // Case for regular HTML list structure. // (e.g.: when pasting from other apps) { @@ -88,7 +84,10 @@ const NumberedListItemBlockContent = createTipTapBlock< return false; } - if (parent.tagName === "OL") { + if ( + parent.tagName === "OL" || + (parent.tagName === "DIV" && parent.parentElement!.tagName === "OL") + ) { return {}; } @@ -124,43 +123,22 @@ const NumberedListItemBlockContent = createTipTapBlock< }, renderHTML({ HTMLAttributes }) { - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - const inlineContentDOMAttributes = - this.options.domAttributes?.inlineContent || {}; - - return [ - "div", - mergeAttributes(HTMLAttributes, { - ...blockContentDOMAttributes, - class: mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ), - "data-content-type": this.name, - }), - // we use a

        tag, because for

      • tags we'd need to add a
          parent for around siblings to be semantically correct, - // which would be quite cumbersome - [ - "p", - { - ...inlineContentDOMAttributes, - class: mergeCSSClasses( - styles.inlineContent, - inlineContentDOMAttributes.class - ), - }, - 0, - ], - ]; + return createDefaultBlockDOMOutputSpec( + this.name, + // We use a

          tag, because for

        • tags we'd need an
            element to + // put them in to be semantically correct, which we can't have due to the + // schema. + "p", + { + ...(this.options.domAttributes?.blockContent || {}), + ...HTMLAttributes, + }, + this.options.domAttributes?.inlineContent || {} + ); }, }); -export const NumberedListItem = { - node: NumberedListItemBlockContent, - propSchema: numberedListItemPropSchema, -} satisfies BlockSpec< - "numberedListItem", - typeof numberedListItemPropSchema, - true ->; +export const NumberedListItem = createBlockSpecFromStronglyTypedTiptapNode( + NumberedListItemBlockContent, + numberedListItemPropSchema +); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts index 98d5b40435..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,19 +1,21 @@ -import { mergeAttributes } from "@tiptap/core"; +import { + createBlockSpecFromStronglyTypedTiptapNode, + createStronglyTypedTiptapNode, +} from "../../../api/blocks/internal"; import { defaultProps } from "../../../api/defaultProps"; -import { createTipTapBlock } from "../../../api/block"; -import { mergeCSSClasses } from "../../../../../shared/utils"; -import styles from "../../Block.module.css"; +import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers"; export const paragraphPropSchema = { ...defaultProps, }; -export const ParagraphBlockContent = createTipTapBlock<"paragraph", true>({ +export const ParagraphBlockContent = createStronglyTypedTiptapNode({ name: "paragraph", content: "inline*", - + group: "blockContent", parseHTML() { return [ + { tag: "div[data-content-type=" + this.name + "]" }, { tag: "p", priority: 200, @@ -23,40 +25,19 @@ export const ParagraphBlockContent = createTipTapBlock<"paragraph", true>({ }, renderHTML({ HTMLAttributes }) { - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - const inlineContentDOMAttributes = - this.options.domAttributes?.inlineContent || {}; - - return [ - "div", - mergeAttributes( - { - ...blockContentDOMAttributes, - class: mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ), - "data-content-type": this.name, - }, - HTMLAttributes - ), - [ - "p", - { - ...inlineContentDOMAttributes, - class: mergeCSSClasses( - styles.inlineContent, - inlineContentDOMAttributes.class - ), - }, - 0, - ], - ]; + return createDefaultBlockDOMOutputSpec( + this.name, + "p", + { + ...(this.options.domAttributes?.blockContent || {}), + ...HTMLAttributes, + }, + this.options.domAttributes?.inlineContent || {} + ); }, }); -export const Paragraph = { - node: ParagraphBlockContent, - propSchema: paragraphPropSchema, -}; +export const Paragraph = createBlockSpecFromStronglyTypedTiptapNode( + ParagraphBlockContent, + paragraphPropSchema +); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts new file mode 100644 index 0000000000..8807586fdc --- /dev/null +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableBlockContent.ts @@ -0,0 +1,73 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { TableCell } from "@tiptap/extension-table-cell"; +import { TableHeader } from "@tiptap/extension-table-header"; +import { TableRow } from "@tiptap/extension-table-row"; +import { + createBlockSpecFromStronglyTypedTiptapNode, + createStronglyTypedTiptapNode, +} from "../../../api/blocks/internal"; +import { defaultProps } from "../../../api/defaultProps"; +import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers"; +import { TableExtension } from "./TableExtension"; + +export const tablePropSchema = { + ...defaultProps, +}; + +export const TableBlockContent = createStronglyTypedTiptapNode({ + name: "table", + content: "tableRow+", + group: "blockContent", + tableRole: "table", + + isolating: true, + + parseHTML() { + return [{ tag: "table" }]; + }, + + renderHTML({ HTMLAttributes }) { + return createDefaultBlockDOMOutputSpec( + this.name, + "table", + { + ...(this.options.domAttributes?.blockContent || {}), + ...HTMLAttributes, + }, + this.options.domAttributes?.inlineContent || {} + ); + }, +}); + +const TableParagraph = Node.create({ + name: "tableParagraph", + group: "tableContent", + + parseHTML() { + return [{ tag: "p" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "p", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, +}); + +export const Table = createBlockSpecFromStronglyTypedTiptapNode( + TableBlockContent, + tablePropSchema, + [ + TableExtension, + TableParagraph, + TableHeader.extend({ + content: "tableContent", + }), + TableCell.extend({ + content: "tableContent", + }), + TableRow, + ] +); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts new file mode 100644 index 0000000000..14bc48310c --- /dev/null +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/TableBlockContent/TableExtension.ts @@ -0,0 +1,54 @@ +import { callOrReturn, Extension, getExtensionField } from "@tiptap/core"; +import { columnResizing, tableEditing } from "prosemirror-tables"; + +export const TableExtension = Extension.create({ + name: "BlockNoteTableExtension", + + addProseMirrorPlugins: () => { + return [ + columnResizing({ + cellMinWidth: 100, + }), + tableEditing(), + ]; + }, + + addKeyboardShortcuts() { + return { + // Makes enter create a new line within the cell. + Enter: () => { + this.editor.commands.setHardBreak(); + return true; + }, + // Ensures that backspace won't delete the table if the text cursor is at + // the start of a cell and the selection is empty. + Backspace: () => { + const selection = this.editor.state.selection; + const selectionIsEmpty = selection.empty; + const selectionIsAtStartOfNode = selection.$head.parentOffset === 0; + const selectionIsInTableParagraphNode = + selection.$head.node().type.name === "tableParagraph"; + + return ( + selectionIsEmpty && + selectionIsAtStartOfNode && + selectionIsInTableParagraphNode + ); + }, + }; + }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + }; + + return { + tableRole: callOrReturn( + getExtensionField(extension, "tableRole", context) + ), + }; + }, +}); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts new file mode 100644 index 0000000000..b5ade2c570 --- /dev/null +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/defaultBlockHelpers.ts @@ -0,0 +1,92 @@ +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 +// `inlineContent` element inside), this function only needs the block's name +// for the `data-content-type` attribute of the `blockContent` element and the +// HTML tag of the `inlineContent` element, as well as any HTML attributes to +// add to those. +export function createDefaultBlockDOMOutputSpec( + blockName: string, + htmlTag: string, + blockContentHTMLAttributes: Record, + inlineContentHTMLAttributes: Record +) { + const blockContent = document.createElement("div"); + blockContent.className = mergeCSSClasses( + "bn-block-content", + blockContentHTMLAttributes.class + ); + blockContent.setAttribute("data-content-type", blockName); + for (const [attribute, value] of Object.entries(blockContentHTMLAttributes)) { + if (attribute !== "class") { + blockContent.setAttribute(attribute, value); + } + } + + const inlineContent = document.createElement(htmlTag); + inlineContent.className = mergeCSSClasses( + "bn-inline-content", + inlineContentHTMLAttributes.class + ); + for (const [attribute, value] of Object.entries( + inlineContentHTMLAttributes + )) { + if (attribute !== "class") { + inlineContent.setAttribute(attribute, value); + } + } + + blockContent.appendChild(inlineContent); + + return { + dom: blockContent, + contentDOM: inlineContent, + }; +} + +// 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 = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + block: Block, + editor: BlockNoteEditor +): { + dom: HTMLElement; + contentDOM?: HTMLElement; +} => { + const node = blockToNode( + block, + editor._tiptapEditor.schema, + editor.styleSchema + ).firstChild!; + const toDOM = editor._tiptapEditor.schema.nodes[node.type.name].spec.toDOM; + + if (toDOM === undefined) { + throw new Error( + "This block has no default HTML serialization as its corresponding TipTap node doesn't implement `renderHTML`." + ); + } + + const renderSpec = toDOM(node); + + if (typeof renderSpec !== "object" || !("dom" in renderSpec)) { + throw new Error( + "Cannot use this block's default HTML serialization as its corresponding TipTap node's `renderHTML` function does not return an object with the `dom` property." + ); + } + + return renderSpec as { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; +}; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts b/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts index 2f18094b3d..88f4a3025c 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts @@ -1,7 +1,6 @@ -import { mergeAttributes, Node } from "@tiptap/core"; -import styles from "./Block.module.css"; -import { BlockNoteDOMAttributes } from "../api/blockTypes"; +import { Node } from "@tiptap/core"; import { mergeCSSClasses } from "../../../shared/utils"; +import { BlockNoteDOMAttributes } from "../api/blocks/types"; export const BlockGroup = Node.create<{ domAttributes?: BlockNoteDOMAttributes; @@ -31,23 +30,25 @@ export const BlockGroup = Node.create<{ }, renderHTML({ HTMLAttributes }) { - const blockGroupDOMAttributes = - this.options.domAttributes?.blockGroup || {}; + const blockGroupHTMLAttributes = { + ...(this.options.domAttributes?.blockGroup || {}), + ...HTMLAttributes, + }; + const blockGroup = document.createElement("div"); + blockGroup.className = mergeCSSClasses( + "bn-block-group", + blockGroupHTMLAttributes.class + ); + blockGroup.setAttribute("data-node-type", "blockGroup"); + for (const [attribute, value] of Object.entries(blockGroupHTMLAttributes)) { + if (attribute !== "class") { + blockGroup.setAttribute(attribute, value); + } + } - return [ - "div", - mergeAttributes( - { - ...blockGroupDOMAttributes, - class: mergeCSSClasses( - styles.blockGroup, - blockGroupDOMAttributes.class - ), - "data-node-type": "blockGroup", - }, - HTMLAttributes - ), - 0, - ]; + return { + dom: blockGroup, + contentDOM: blockGroup, + }; }, }); diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts index 29d893fccb..1af1cc2328 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts @@ -1,19 +1,22 @@ import { isNodeSelection, isTextSelection, posToDOMRect } from "@tiptap/core"; import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; + +import { BlockNoteEditor } from "../../BlockNoteEditor"; import { BaseUiElementCallbacks, BaseUiElementState, - BlockNoteEditor, - BlockSchema, -} from "../.."; +} from "../../shared/BaseUiElementTypes"; import { EventEmitter } from "../../shared/EventEmitter"; +import { BlockSchema } from "../Blocks/api/blocks/types"; +import { InlineContentSchema } from "../Blocks/api/inlineContent/types"; +import { StyleSchema } from "../Blocks/api/styles/types"; export type FormattingToolbarCallbacks = BaseUiElementCallbacks; export type FormattingToolbarState = BaseUiElementState; -export class FormattingToolbarView { +export class FormattingToolbarView { private formattingToolbarState?: FormattingToolbarState; public updateFormattingToolbar: () => void; @@ -26,21 +29,22 @@ export class FormattingToolbarView { state: EditorState; from: number; to: number; - }) => boolean = ({ view, state, from, to }) => { - const { doc, selection } = state; + }) => boolean = ({ state }) => { + const { selection } = state; const { empty } = selection; - // Sometime check for `empty` is not enough. - // Doubleclick an empty paragraph returns a node size of 2. - // So we check also for an empty text size. - const isEmptyTextBlock = - !doc.textBetween(from, to).length && isTextSelection(state.selection); - - return !(!view.hasFocus() || empty || isEmptyTextBlock); + if (!isTextSelection(selection)) { + return false; + } + return !empty; }; constructor( - private readonly editor: BlockNoteEditor, + private readonly editor: BlockNoteEditor< + BlockSchema, + InlineContentSchema, + StyleSchema + >, private readonly pmView: EditorView, updateFormattingToolbar: ( formattingToolbarState: FormattingToolbarState @@ -216,13 +220,11 @@ export const formattingToolbarPluginKey = new PluginKey( "FormattingToolbarPlugin" ); -export class FormattingToolbarProsemirrorPlugin< - BSchema extends BlockSchema -> extends EventEmitter { - private view: FormattingToolbarView | undefined; +export class FormattingToolbarProsemirrorPlugin extends EventEmitter { + private view: FormattingToolbarView | undefined; public readonly plugin: Plugin; - constructor(editor: BlockNoteEditor) { + constructor(editor: BlockNoteEditor) { super(); this.plugin = new Plugin({ key: formattingToolbarPluginKey, diff --git a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts index 65ae274279..abdd1f4cab 100644 --- a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts +++ b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts @@ -5,7 +5,9 @@ import { Plugin, PluginKey } from "prosemirror-state"; import { BlockNoteEditor } from "../../BlockNoteEditor"; import { BaseUiElementState } from "../../shared/BaseUiElementTypes"; import { EventEmitter } from "../../shared/EventEmitter"; -import { BlockSchema } from "../Blocks/api/blockTypes"; +import { BlockSchema } from "../Blocks/api/blocks/types"; +import { InlineContentSchema } from "../Blocks/api/inlineContent/types"; +import { StyleSchema } from "../Blocks/api/styles/types"; export type HyperlinkToolbarState = BaseUiElementState & { // The hovered hyperlink's URL, and the text it's displayed with in the @@ -14,11 +16,11 @@ export type HyperlinkToolbarState = BaseUiElementState & { text: string; }; -class HyperlinkToolbarView { +class HyperlinkToolbarView { private hyperlinkToolbarState?: HyperlinkToolbarState; public updateHyperlinkToolbar: () => void; - menuUpdateTimer: NodeJS.Timeout | undefined; + menuUpdateTimer: ReturnType | undefined; startMenuUpdateTimer: () => void; stopMenuUpdateTimer: () => void; @@ -32,7 +34,7 @@ class HyperlinkToolbarView { hyperlinkMarkRange: Range | undefined; constructor( - private readonly editor: BlockNoteEditor, + private readonly editor: BlockNoteEditor, private readonly pmView: EditorView, updateHyperlinkToolbar: ( hyperlinkToolbarState: HyperlinkToolbarState @@ -275,12 +277,14 @@ export const hyperlinkToolbarPluginKey = new PluginKey( ); export class HyperlinkToolbarProsemirrorPlugin< - BSchema extends BlockSchema + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema > extends EventEmitter { - private view: HyperlinkToolbarView | undefined; + private view: HyperlinkToolbarView | undefined; public readonly plugin: Plugin; - constructor(editor: BlockNoteEditor) { + constructor(editor: BlockNoteEditor) { super(); this.plugin = new Plugin({ key: hyperlinkToolbarPluginKey, diff --git a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts index 40a8cd2d59..9ecb162d3a 100644 --- a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts +++ b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts @@ -1,48 +1,41 @@ -import { Node as PMNode } from "prosemirror-model"; import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; + +import { BlockNoteEditor } from "../../BlockNoteEditor"; import { BaseUiElementCallbacks, BaseUiElementState, - BlockNoteEditor, - BlockSchema, - BlockSpec, - SpecificBlock, -} from "../.."; +} from "../../shared/BaseUiElementTypes"; import { EventEmitter } from "../../shared/EventEmitter"; - +import { BlockSchema, SpecificBlock } from "../Blocks/api/blocks/types"; +import { InlineContentSchema } from "../Blocks/api/inlineContent/types"; +import { StyleSchema } from "../Blocks/api/styles/types"; export type ImageToolbarCallbacks = BaseUiElementCallbacks; -export type ImageToolbarState = BaseUiElementState & { - block: SpecificBlock< - BlockSchema & { - image: BlockSpec< - "image", - { - src: { default: string }; - }, - false - >; - }, - "image" - >; +export type ImageToolbarState< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema = StyleSchema +> = BaseUiElementState & { + block: SpecificBlock; }; -export class ImageToolbarView { - private imageToolbarState?: ImageToolbarState; +export class ImageToolbarView< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> { + private imageToolbarState?: ImageToolbarState; public updateImageToolbar: () => void; public prevWasEditable: boolean | null = null; - public shouldShow: (state: EditorState) => boolean = (state) => - "node" in state.selection && - (state.selection.node as PMNode).type.name === "image" && - (state.selection.node as PMNode).attrs.src === ""; - constructor( private readonly pluginKey: PluginKey, private readonly pmView: EditorView, - updateImageToolbar: (imageToolbarState: ImageToolbarState) => void + updateImageToolbar: ( + imageToolbarState: ImageToolbarState + ) => void ) { this.updateImageToolbar = () => { if (!this.imageToolbarState) { @@ -112,18 +105,7 @@ export class ImageToolbarView { update(view: EditorView, prevState: EditorState) { const pluginState: { - block: SpecificBlock< - BlockSchema & { - image: BlockSpec< - "image", - { - src: { default: string }; - }, - false - >; - }, - "image" - >; + block: SpecificBlock; } = this.pluginKey.getState(view.state); if (!this.imageToolbarState?.show && pluginState.block) { @@ -168,28 +150,17 @@ export class ImageToolbarView { export const imageToolbarPluginKey = new PluginKey("ImageToolbarPlugin"); export class ImageToolbarProsemirrorPlugin< - BSchema extends BlockSchema + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema > extends EventEmitter { - private view: ImageToolbarView | undefined; + private view: ImageToolbarView | undefined; public readonly plugin: Plugin; - constructor(_editor: BlockNoteEditor) { + constructor(_editor: BlockNoteEditor) { super(); this.plugin = new Plugin<{ - block: - | SpecificBlock< - BlockSchema & { - image: BlockSpec< - "image", - { - src: { default: string }; - }, - false - >; - }, - "image" - > - | undefined; + block: SpecificBlock | undefined; }>({ key: imageToolbarPluginKey, view: (editorView) => { @@ -210,20 +181,8 @@ export class ImageToolbarProsemirrorPlugin< }; }, apply: (transaction) => { - const block: - | SpecificBlock< - BlockSchema & { - image: BlockSpec< - "image", - { - src: { default: string }; - }, - false - >; - }, - "image" - > - | undefined = transaction.getMeta(imageToolbarPluginKey)?.block; + const block: SpecificBlock | undefined = + transaction.getMeta(imageToolbarPluginKey)?.block; return { block, @@ -233,7 +192,7 @@ export class ImageToolbarProsemirrorPlugin< }); } - public onUpdate(callback: (state: ImageToolbarState) => void) { + public onUpdate(callback: (state: ImageToolbarState) => void) { return this.on("update", callback); } } diff --git a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts index 7f31321c59..4bfa6ec41f 100644 --- a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts +++ b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts @@ -36,10 +36,10 @@ export const Placeholder = Extension.create({ addOptions() { return { - emptyEditorClass: "is-editor-empty", - emptyNodeClass: "is-empty", - isFilterClass: "is-filter", - hasAnchorClass: "has-anchor", + emptyEditorClass: "bn-is-editor-empty", + emptyNodeClass: "bn-is-empty", + isFilterClass: "bn-is-filter", + hasAnchorClass: "bn-has-anchor", placeholder: "Write something …", showOnlyWhenEditable: true, showOnlyCurrent: true, diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index aeee6ce41b..ed87b5df07 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -1,28 +1,32 @@ import { PluginView } from "@tiptap/pm/state"; import { Node } from "prosemirror-model"; import { NodeSelection, Plugin, PluginKey, Selection } from "prosemirror-state"; -import * as pv from "prosemirror-view"; import { EditorView } from "prosemirror-view"; import { BlockNoteEditor } from "../../BlockNoteEditor"; -import styles from "../../editor.module.css"; +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"; -const serializeForClipboard = (pv as any).__serializeForClipboard; -// code based on https://github.com/ueberdosis/tiptap/issues/323#issuecomment-506637799 - let dragImageElement: Element | undefined; -export type SideMenuState = BaseUiElementState & { +export type SideMenuState< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = BaseUiElementState & { // The block that the side menu is attached to. - block: Block; + block: Block; }; -function getDraggableBlockFromCoords( +export function getDraggableBlockFromCoords( coords: { left: number; top: number }, view: EditorView ) { @@ -153,18 +157,14 @@ function setDragImage(view: EditorView, from: number, to = from) { const inheritedClasses = classes .filter( (className) => - !className.includes("bn") && - !className.includes("ProseMirror") && - !className.includes("editor") + className !== "ProseMirror" && + className !== "bn-root" && + className !== "bn-editor" ) .join(" "); dragImageElement.className = - dragImageElement.className + - " " + - styles.dragPreview + - " " + - inheritedClasses; + dragImageElement.className + " bn-drag-preview " + inheritedClasses; document.body.appendChild(dragImageElement); } @@ -176,14 +176,20 @@ function unsetDragImage() { } } -function dragStart( +function dragStart< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( e: { dataTransfer: DataTransfer | null; clientY: number }, - view: EditorView + editor: BlockNoteEditor ) { if (!e.dataTransfer) { return; } + const view = editor.prosemirrorView; + const editorBoundingBox = view.dom.getBoundingClientRect(); const coords = { @@ -215,20 +221,38 @@ function dragStart( setDragImage(view, pos); } - const slice = view.state.selection.content(); - const { dom, text } = serializeForClipboard(view, slice); + const selectedSlice = view.state.selection.content(); + const schema = editor._tiptapEditor.schema; + + const internalHTMLSerializer = createInternalHTMLSerializer(schema, editor); + const internalHTML = internalHTMLSerializer.serializeProseMirrorFragment( + selectedSlice.content + ); + + const externalHTMLExporter = createExternalHTMLExporter(schema, editor); + const externalHTML = externalHTMLExporter.exportProseMirrorFragment( + selectedSlice.content + ); + + const plainText = cleanHTMLToMarkdown(externalHTML); e.dataTransfer.clearData(); - e.dataTransfer.setData("text/html", dom.innerHTML); - e.dataTransfer.setData("text/plain", text); + e.dataTransfer.setData("blocknote/html", internalHTML); + e.dataTransfer.setData("text/html", externalHTML); + e.dataTransfer.setData("text/plain", plainText); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setDragImage(dragImageElement!, 0, 0); - view.dragging = { slice, move: true }; + view.dragging = { slice: selectedSlice, move: true }; } } -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 @@ -244,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; @@ -552,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, @@ -574,7 +600,7 @@ export class SideMenuProsemirrorPlugin< }); } - public onUpdate(callback: (state: SideMenuState) => void) { + public onUpdate(callback: (state: SideMenuState) => void) { return this.on("update", callback); } @@ -592,7 +618,7 @@ export class SideMenuProsemirrorPlugin< clientY: number; }) => { this.sideMenuView!.isDragging = true; - dragStart(event, this.editor.prosemirrorView); + dragStart(event, this.editor); }; /** diff --git a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts index 41fc78917c..6bcfd8c361 100644 --- a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts +++ b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts @@ -1,11 +1,14 @@ -import { SuggestionItem } from "../../shared/plugins/suggestion/SuggestionItem"; import { BlockNoteEditor } from "../../BlockNoteEditor"; -import { BlockSchema } from "../Blocks/api/blockTypes"; -import { DefaultBlockSchema } from "../Blocks/api/defaultBlocks"; +import { SuggestionItem } from "../../shared/plugins/suggestion/SuggestionItem"; +import { BlockSchema } from "../Blocks/api/blocks/types"; +import { InlineContentSchema } from "../Blocks/api/inlineContent/types"; +import { StyleSchema } from "../Blocks/api/styles/types"; export type BaseSlashMenuItem< - BSchema extends BlockSchema = DefaultBlockSchema + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema > = SuggestionItem & { - execute: (editor: BlockNoteEditor) => void; + execute: (editor: BlockNoteEditor) => void; aliases?: string[]; }; diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts index a190cc3209..67aec3cdb0 100644 --- a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts +++ b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts @@ -6,21 +6,25 @@ import { SuggestionsMenuState, setupSuggestionsMenu, } from "../../shared/plugins/suggestion/SuggestionPlugin"; -import { BlockSchema } from "../Blocks/api/blockTypes"; +import { BlockSchema } from "../Blocks/api/blocks/types"; +import { InlineContentSchema } from "../Blocks/api/inlineContent/types"; +import { StyleSchema } from "../Blocks/api/styles/types"; import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; export const slashMenuPluginKey = new PluginKey("SlashMenuPlugin"); export class SlashMenuProsemirrorPlugin< BSchema extends BlockSchema, - SlashMenuItem extends BaseSlashMenuItem + I extends InlineContentSchema, + S extends StyleSchema, + SlashMenuItem extends BaseSlashMenuItem > extends EventEmitter { public readonly plugin: Plugin; public readonly itemCallback: (item: SlashMenuItem) => void; - constructor(editor: BlockNoteEditor, items: SlashMenuItem[]) { + constructor(editor: BlockNoteEditor, items: SlashMenuItem[]) { super(); - const suggestions = setupSuggestionsMenu( + const suggestions = setupSuggestionsMenu( editor, (state) => { this.emit("update", state); diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index 8646ee1b99..52a9bc9337 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -1,42 +1,88 @@ import { BlockNoteEditor } from "../../BlockNoteEditor"; -import { BlockSchema, PartialBlock } from "../Blocks/api/blockTypes"; -import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; +import { Block, BlockSchema, PartialBlock } from "../Blocks/api/blocks/types"; import { defaultBlockSchema } from "../Blocks/api/defaultBlocks"; +import { + InlineContentSchema, + isStyledTextInlineContent, +} from "../Blocks/api/inlineContent/types"; +import { StyleSchema } from "../Blocks/api/styles/types"; import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin"; +import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; + +// Sets the editor's text cursor position to the next content editable block, +// so either a block with inline content or a table. The last block is always a +// paragraph, so this function won't try to set the cursor position past the +// last block. +function setSelectionToNextContentEditableBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(editor: BlockNoteEditor) { + let block = editor.getTextCursorPosition().block; + let contentType = editor.blockSchema[block.type].content as + | "inline" + | "table" + | "none"; + + while (contentType === "none") { + editor.setTextCursorPosition(block, "end"); + block = editor.getTextCursorPosition().nextBlock!; + contentType = editor.blockSchema[block.type].content as + | "inline" + | "table" + | "none"; + } +} -function insertOrUpdateBlock( - editor: BlockNoteEditor, - block: PartialBlock -) { +// Checks if the current block is empty or only contains a slash, and if so, +// updates the current block instead of inserting a new one below. If the new +// block doesn't contain editable content, the cursor is moved to the next block +// that does. +function insertOrUpdateBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + block: PartialBlock +): Block { const currentBlock = editor.getTextCursorPosition().block; if (currentBlock.content === undefined) { - throw new Error( - "Slash Menu open in a block that doesn't contain inline content." - ); + throw new Error("Slash Menu open in a block that doesn't contain content."); } if ( - (currentBlock.content.length === 1 && + Array.isArray(currentBlock.content) && + ((currentBlock.content.length === 1 && + isStyledTextInlineContent(currentBlock.content[0]) && currentBlock.content[0].type === "text" && currentBlock.content[0].text === "/") || - currentBlock.content.length === 0 + currentBlock.content.length === 0) ) { editor.updateBlock(currentBlock, block); } else { editor.insertBlocks([block], currentBlock, "after"); - editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!); + editor.setTextCursorPosition( + editor.getTextCursorPosition().nextBlock!, + "end" + ); } + + const insertedBlock = editor.getTextCursorPosition().block; + setSelectionToNextContentEditableBlock(editor); + + return insertedBlock; } -export const getDefaultSlashMenuItems = ( - // This type casting is weird, but it's the best way of doing it, as it allows - // the schema type to be automatically inferred if it is defined, or be - // inferred as any if it is not defined. I don't think it's possible to make it - // infer to DefaultBlockSchema if it is not defined. +export const getDefaultSlashMenuItems = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( schema: BSchema = defaultBlockSchema as unknown as BSchema ) => { - const slashMenuItems: BaseSlashMenuItem[] = []; + const slashMenuItems: BaseSlashMenuItem[] = []; if ("heading" in schema && "level" in schema.heading.propSchema) { // Command for creating a level 1 heading @@ -48,7 +94,7 @@ export const getDefaultSlashMenuItems = ( insertOrUpdateBlock(editor, { type: "heading", props: { level: 1 }, - } as PartialBlock), + } as PartialBlock), }); } @@ -61,7 +107,7 @@ export const getDefaultSlashMenuItems = ( insertOrUpdateBlock(editor, { type: "heading", props: { level: 2 }, - } as PartialBlock), + } as PartialBlock), }); } @@ -74,7 +120,7 @@ export const getDefaultSlashMenuItems = ( insertOrUpdateBlock(editor, { type: "heading", props: { level: 3 }, - } as PartialBlock), + } as PartialBlock), }); } } @@ -86,7 +132,7 @@ export const getDefaultSlashMenuItems = ( execute: (editor) => insertOrUpdateBlock(editor, { type: "bulletListItem", - } as PartialBlock), + }), }); } @@ -97,7 +143,7 @@ export const getDefaultSlashMenuItems = ( execute: (editor) => insertOrUpdateBlock(editor, { type: "numberedListItem", - } as PartialBlock), + }), }); } @@ -108,7 +154,35 @@ export const getDefaultSlashMenuItems = ( execute: (editor) => insertOrUpdateBlock(editor, { type: "paragraph", - } as PartialBlock), + }), + }); + } + + if ("table" in schema) { + slashMenuItems.push({ + name: "Table", + aliases: ["table"], + execute: (editor) => { + insertOrUpdateBlock(editor, { + type: "table", + content: { + type: "tableContent", + rows: [ + // TODO: replace with empty content before merging + { + cells: [ + "ab", + [{ type: "text", styles: { bold: true }, text: "hello" }], + "", + ], + }, + { + cells: ["", "cd", ""], + }, + ], + }, + } as PartialBlock); + }, }); } @@ -127,19 +201,14 @@ export const getDefaultSlashMenuItems = ( "dropbox", ], execute: (editor) => { - insertOrUpdateBlock(editor, { + const insertedBlock = insertOrUpdateBlock(editor, { type: "image", - } as PartialBlock); - // Don't want to select the add image button, instead select the block - // below it - editor.setTextCursorPosition( - editor.getTextCursorPosition().nextBlock!, - "start" - ); + }); + // Immediately open the image toolbar editor._tiptapEditor.view.dispatch( editor._tiptapEditor.state.tr.setMeta(imageToolbarPluginKey, { - block: editor.getTextCursorPosition().prevBlock, + block: insertedBlock, }) ); }, diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts new file mode 100644 index 0000000000..f600bbf4d4 --- /dev/null +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -0,0 +1,617 @@ +import { Plugin, PluginKey, PluginView } from "prosemirror-state"; +import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; +import { + Block, + BlockFromConfigNoChildren, + BlockNoteEditor, + BlockSchemaWithBlock, + DefaultBlockSchema, + InlineContentSchema, + PartialBlock, + SpecificBlock, + StyleSchema, + getDraggableBlockFromCoords, + nodeToBlock, +} from "../.."; +import { EventEmitter } from "../../shared/EventEmitter"; + +let dragImageElement: HTMLElement | undefined; + +function setHiddenDragImage() { + if (dragImageElement) { + return; + } + + dragImageElement = document.createElement("div"); + dragImageElement.innerHTML = "_"; + dragImageElement.style.visibility = "hidden"; + document.body.appendChild(dragImageElement); +} + +function unsetHiddenDragImage() { + if (dragImageElement) { + document.body.removeChild(dragImageElement); + dragImageElement = undefined; + } +} + +export type TableHandlesState< + I extends InlineContentSchema, + S extends StyleSchema +> = { + show: boolean; + referencePosCell: DOMRect; + referencePosTable: DOMRect; + + block: BlockFromConfigNoChildren; + colIndex: number; + rowIndex: number; + + draggingState: + | { + draggedCellOrientation: "row" | "col"; + originalIndex: number; + mousePos: number; + } + | undefined; +}; + +function getChildIndex(node: Element) { + return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); +} + +// Finds the DOM element corresponding to the table cell that the target element +// is currently in. If the target element is not in a table cell, returns null. +function domCellAround(target: Element | null): Element | null { + while (target && target.nodeName !== "TD" && target.nodeName !== "TH") { + target = + target.classList && target.classList.contains("ProseMirror") + ? null + : (target.parentNode as Element); + } + return target; +} + +// Hides elements in the DOMwith the provided class names. +function hideElementsWithClassNames(classNames: string[]) { + classNames.forEach((className) => { + const elementsToHide = document.getElementsByClassName(className); + for (let i = 0; i < elementsToHide.length; i++) { + (elementsToHide[i] as HTMLElement).style.visibility = "hidden"; + } + }); +} + +export class TableHandlesView< + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema +> implements PluginView +{ + public state?: TableHandlesState; + public updateState: () => void; + + public tableId: string | undefined; + public tablePos: number | undefined; + + public menuFrozen = false; + + public prevWasEditable: boolean | null = null; + + constructor( + private readonly editor: BlockNoteEditor, + private readonly pmView: EditorView, + updateState: (state: TableHandlesState) => void + ) { + this.updateState = () => { + if (!this.state) { + throw new Error("Attempting to update uninitialized image toolbar"); + } + + updateState(this.state); + }; + + pmView.dom.addEventListener("mousemove", this.mouseMoveHandler); + + document.addEventListener("dragover", this.dragOverHandler); + document.addEventListener("drop", this.dropHandler); + + document.addEventListener("scroll", this.scrollHandler); + } + + mouseMoveHandler = (event: MouseEvent) => { + if (this.menuFrozen) { + return; + } + + const target = domCellAround(event.target as HTMLElement); + + if (!target || !this.editor.isEditable) { + if (this.state?.show) { + this.state.show = false; + this.updateState(); + } + return; + } + + const colIndex = getChildIndex(target); + const rowIndex = getChildIndex(target.parentElement!); + const cellRect = target.getBoundingClientRect(); + const tableRect = + target.parentElement!.parentElement!.getBoundingClientRect(); + + const blockEl = getDraggableBlockFromCoords(cellRect, this.pmView); + if (!blockEl) { + throw new Error( + "Found table cell element, but could not find surrounding blockContent element." + ); + } + this.tableId = blockEl.id; + + if ( + this.state !== undefined && + this.state.show && + this.tableId === blockEl.id && + this.state.rowIndex === rowIndex && + this.state.colIndex === colIndex + ) { + return; + } + + let block: Block | undefined = undefined; + + // Copied from `getBlock`. We don't use `getBlock` since we also need the PM + // node for the table, so we would effectively be doing the same work twice. + this.editor._tiptapEditor.state.doc.descendants((node, pos) => { + if (typeof block !== "undefined") { + return false; + } + + if (node.type.name !== "blockContainer" || node.attrs.id !== blockEl.id) { + return true; + } + + block = nodeToBlock( + node, + this.editor.blockSchema, + this.editor.inlineContentSchema, + this.editor.styleSchema, + this.editor.blockCache + ); + this.tablePos = pos + 1; + + return false; + }); + + this.state = { + show: true, + referencePosCell: cellRect, + referencePosTable: tableRect, + + block: block! as SpecificBlock, + colIndex: colIndex, + rowIndex: rowIndex, + + draggingState: undefined, + }; + this.updateState(); + + return false; + }; + + dragOverHandler = (event: DragEvent) => { + if (this.state?.draggingState === undefined) { + return; + } + + event.preventDefault(); + event.dataTransfer!.dropEffect = "move"; + + hideElementsWithClassNames([ + "column-resize-handle", + "prosemirror-dropcursor-block", + "prosemirror-dropcursor-inline", + ]); + + // The mouse cursor coordinates, bounded to the table's bounding box. The + // bounding box is shrunk by 1px on each side to ensure that the bounded + // coordinates are always inside a table cell. + const boundedMouseCoords = { + left: Math.min( + Math.max(event.clientX, this.state.referencePosTable.left + 1), + this.state.referencePosTable.right - 1 + ), + top: Math.min( + Math.max(event.clientY, this.state.referencePosTable.top + 1), + this.state.referencePosTable.bottom - 1 + ), + }; + + // Gets the table cell element that the bounded mouse cursor coordinates lie + // in. + const tableCellElements = document + .elementsFromPoint(boundedMouseCoords.left, boundedMouseCoords.top) + .filter( + (element) => element.tagName === "TD" || element.tagName === "TH" + ); + if (tableCellElements.length === 0) { + throw new Error( + "Could not find table cell element that the mouse cursor is hovering over." + ); + } + const tableCellElement = tableCellElements[0]; + + let emitStateUpdate = false; + + // Gets current row and column index. + const rowIndex = getChildIndex(tableCellElement.parentElement!); + const colIndex = getChildIndex(tableCellElement); + + // Checks if the drop cursor needs to be updated. This affects decorations + // only so it doesn't trigger a state update. + const oldIndex = + this.state.draggingState.draggedCellOrientation === "row" + ? this.state.rowIndex + : this.state.colIndex; + const newIndex = + this.state.draggingState.draggedCellOrientation === "row" + ? rowIndex + : colIndex; + const dispatchDecorationsTransaction = newIndex !== oldIndex; + + // Checks if either the hovered cell has changed and updates the row and + // column index. Also updates the reference DOMRect. + if (this.state.rowIndex !== rowIndex || this.state.colIndex !== colIndex) { + this.state.rowIndex = rowIndex; + this.state.colIndex = colIndex; + + this.state.referencePosCell = tableCellElement.getBoundingClientRect(); + + emitStateUpdate = true; + } + + // Checks if the mouse cursor position along the axis that the user is + // dragging on has changed and updates it. + const mousePos = + this.state.draggingState.draggedCellOrientation === "row" + ? boundedMouseCoords.top + : boundedMouseCoords.left; + if (this.state.draggingState.mousePos !== mousePos) { + this.state.draggingState.mousePos = mousePos; + + emitStateUpdate = true; + } + + // Emits a state update if any of the fields have changed. + if (emitStateUpdate) { + this.updateState(); + } + + // Dispatches a dummy transaction to force a decorations update if + // necessary. + if (dispatchDecorationsTransaction) { + this.pmView.dispatch( + this.pmView.state.tr.setMeta(tableHandlesPluginKey, true) + ); + } + }; + + dropHandler = (event: DragEvent) => { + if (this.state === undefined || this.state.draggingState === undefined) { + return; + } + + event.preventDefault(); + + const rows = this.state.block.content.rows; + + if (this.state.draggingState.draggedCellOrientation === "row") { + const rowToMove = rows[this.state.draggingState.originalIndex]; + rows.splice(this.state.draggingState.originalIndex, 1); + rows.splice(this.state.rowIndex, 0, rowToMove); + } else { + const cellsToMove = rows.map( + (row) => row.cells[this.state!.draggingState!.originalIndex] + ); + rows.forEach((row, rowIndex) => { + row.cells.splice(this.state!.draggingState!.originalIndex, 1); + row.cells.splice(this.state!.colIndex, 0, cellsToMove[rowIndex]); + }); + } + + this.editor.updateBlock(this.state.block, { + type: "table", + content: { + type: "tableContent", + rows: rows, + }, + } as PartialBlock); + }; + + scrollHandler = () => { + if (this.state?.show) { + const tableElement = document.querySelector( + `[data-node-type="blockContainer"][data-id="${this.tableId}"] table` + )!; + const cellElement = tableElement.querySelector( + `tr:nth-child(${this.state.rowIndex + 1}) > td:nth-child(${ + this.state.colIndex + 1 + })` + )!; + + this.state.referencePosTable = tableElement.getBoundingClientRect(); + this.state.referencePosCell = cellElement.getBoundingClientRect(); + this.updateState(); + } + }; + + destroy() { + this.pmView.dom.removeEventListener("mousedown", this.mouseMoveHandler); + + document.removeEventListener("dragover", this.dragOverHandler); + document.removeEventListener("drop", this.dropHandler); + + document.removeEventListener("scroll", this.scrollHandler); + } +} + +export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin"); + +export class TableHandlesProsemirrorPlugin< + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema +> extends EventEmitter { + private view: TableHandlesView | undefined; + public readonly plugin: Plugin; + + constructor(private readonly editor: BlockNoteEditor) { + super(); + this.plugin = new Plugin({ + key: tableHandlesPluginKey, + view: (editorView) => { + this.view = new TableHandlesView(editor, editorView, (state) => { + this.emit("update", state); + }); + return this.view; + }, + // We use decorations to render the drop cursor when dragging a table row + // or column. The decorations are updated in the `dragOverHandler` method. + props: { + decorations: (state) => { + if ( + this.view === undefined || + this.view.state === undefined || + this.view.state.draggingState === undefined || + this.view.tablePos === undefined + ) { + return; + } + + const newIndex = + this.view.state.draggingState.draggedCellOrientation === "row" + ? this.view.state.rowIndex + : this.view.state.colIndex; + + const decorations: Decoration[] = []; + + if (newIndex === this.view.state.draggingState.originalIndex) { + return DecorationSet.create(state.doc, decorations); + } + + // Gets the table to show the drop cursor in. + const tableResolvedPos = state.doc.resolve(this.view.tablePos + 1); + const tableNode = tableResolvedPos.node(); + + if (this.view.state.draggingState.draggedCellOrientation === "row") { + // Gets the row at the new index. + const rowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(newIndex) + 1 + ); + const rowNode = rowResolvedPos.node(); + + // Iterates over all cells in the row. + for (let i = 0; i < rowNode.childCount; i++) { + // Gets each cell in the row. + const cellResolvedPos = state.doc.resolve( + rowResolvedPos.posAtIndex(i) + 1 + ); + const cellNode = cellResolvedPos.node(); + + // Creates a decoration at the start or end of each cell, + // depending on whether the new index is before or after the + // original index. + const decorationPos = + cellResolvedPos.pos + + (newIndex > this.view.state.draggingState.originalIndex + ? cellNode.nodeSize - 2 + : 0); + decorations.push( + // The widget is a small bar which spans the width of the cell. + Decoration.widget(decorationPos, () => { + const widget = document.createElement("div"); + widget.className = "bn-table-drop-cursor"; + widget.style.left = "0"; + widget.style.right = "0"; + // This is only necessary because the drop indicator's height + // is an even number of pixels, whereas the border between + // table cells is an odd number of pixels. So this makes the + // positioning slightly more consistent regardless of where + // the row is being dropped. + if ( + newIndex > this.view!.state!.draggingState!.originalIndex + ) { + widget.style.bottom = "-2px"; + } else { + widget.style.top = "-3px"; + } + widget.style.height = "4px"; + + return widget; + }) + ); + } + } else { + // Iterates over all rows in the table. + for (let i = 0; i < tableNode.childCount; i++) { + // Gets each row in the table. + const rowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(i) + 1 + ); + + // Gets the cell at the new index in the row. + const cellResolvedPos = state.doc.resolve( + rowResolvedPos.posAtIndex(newIndex) + 1 + ); + const cellNode = cellResolvedPos.node(); + + // Creates a decoration at the start or end of each cell, + // depending on whether the new index is before or after the + // original index. + const decorationPos = + cellResolvedPos.pos + + (newIndex > this.view.state.draggingState.originalIndex + ? cellNode.nodeSize - 2 + : 0); + decorations.push( + // The widget is a small bar which spans the height of the cell. + Decoration.widget(decorationPos, () => { + const widget = document.createElement("div"); + widget.className = "bn-table-drop-cursor"; + widget.style.top = "0"; + widget.style.bottom = "0"; + // This is only necessary because the drop indicator's width + // is an even number of pixels, whereas the border between + // table cells is an odd number of pixels. So this makes the + // positioning slightly more consistent regardless of where + // the column is being dropped. + if ( + newIndex > this.view!.state!.draggingState!.originalIndex + ) { + widget.style.right = "-2px"; + } else { + widget.style.left = "-3px"; + } + widget.style.width = "4px"; + + return widget; + }) + ); + } + } + + return DecorationSet.create(state.doc, decorations); + }, + }, + }); + } + + public onUpdate(callback: (state: TableHandlesState) => void) { + return this.on("update", callback); + } + + /** + * Callback that should be set on the `dragStart` event for whichever element + * is used as the column drag handle. + */ + colDragStart = (event: { + dataTransfer: DataTransfer | null; + clientX: number; + }) => { + if (this.view!.state === undefined) { + throw new Error( + "Attempted to drag table column, but no table block was hovered prior." + ); + } + + this.view!.state.draggingState = { + draggedCellOrientation: "col", + originalIndex: this.view!.state.colIndex, + mousePos: event.clientX, + }; + this.view!.updateState(); + + this.editor._tiptapEditor.view.dispatch( + this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { + draggedCellOrientation: + this.view!.state.draggingState.draggedCellOrientation, + originalIndex: this.view!.state.colIndex, + newIndex: this.view!.state.colIndex, + tablePos: this.view!.tablePos, + }) + ); + + setHiddenDragImage(); + event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + event.dataTransfer!.effectAllowed = "move"; + }; + + /** + * Callback that should be set on the `dragStart` event for whichever element + * is used as the row drag handle. + */ + rowDragStart = (event: { + dataTransfer: DataTransfer | null; + clientY: number; + }) => { + if (this.view!.state === undefined) { + throw new Error( + "Attempted to drag table row, but no table block was hovered prior." + ); + } + + this.view!.state.draggingState = { + draggedCellOrientation: "row", + originalIndex: this.view!.state.rowIndex, + mousePos: event.clientY, + }; + this.view!.updateState(); + + this.editor._tiptapEditor.view.dispatch( + this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { + draggedCellOrientation: + this.view!.state.draggingState.draggedCellOrientation, + originalIndex: this.view!.state.rowIndex, + newIndex: this.view!.state.rowIndex, + tablePos: this.view!.tablePos, + }) + ); + + setHiddenDragImage(); + event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + event.dataTransfer!.effectAllowed = "copyMove"; + }; + + /** + * Callback that should be set on the `dragEnd` event for both the element + * used as the row drag handle, and the one used as the column drag handle. + */ + dragEnd = () => { + if (this.view!.state === undefined) { + throw new Error( + "Attempted to drag table row, but no table block was hovered prior." + ); + } + + this.view!.state.draggingState = undefined; + this.view!.updateState(); + + this.editor._tiptapEditor.view.dispatch( + this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, null) + ); + + unsetHiddenDragImage(); + }; + + /** + * Freezes the drag handles. When frozen, they will stay attached to the same + * cell regardless of which cell is hovered by the mouse cursor. + */ + freezeHandles = () => (this.view!.menuFrozen = true); + + /** + * Unfreezes the drag handles. When frozen, they will stay attached to the + * same cell regardless of which cell is hovered by the mouse cursor. + */ + unfreezeHandles = () => (this.view!.menuFrozen = false); +} diff --git a/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts b/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts index 6a99548918..7f9fb505ea 100644 --- a/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts +++ b/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts @@ -1,15 +1,4 @@ import { Extension } from "@tiptap/core"; -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos"; - -declare module "@tiptap/core" { - interface Commands { - textAlignment: { - setTextAlignment: ( - textAlignment: "left" | "center" | "right" | "justify" - ) => ReturnType; - }; - } -} export const TextAlignmentExtension = Extension.create({ name: "textAlignment", @@ -23,7 +12,9 @@ export const TextAlignmentExtension = Extension.create({ attributes: { textAlignment: { default: "left", - parseHTML: (element) => element.getAttribute("data-text-alignment"), + parseHTML: (element) => { + return element.getAttribute("data-text-alignment"); + }, renderHTML: (attributes) => attributes.textAlignment !== "left" && { "data-text-alignment": attributes.textAlignment, @@ -33,43 +24,4 @@ export const TextAlignmentExtension = Extension.create({ }, ]; }, - - addCommands() { - return { - setTextAlignment: - (textAlignment) => - ({ state }) => { - const positionsBeforeSelectedContent = []; - - const blockInfo = getBlockInfoFromPos( - state.doc, - state.selection.from - ); - if (blockInfo === undefined) { - return false; - } - - // Finds all blockContent nodes that the current selection is in. - let pos = blockInfo.startPos; - while (pos < state.selection.to) { - if ( - state.doc.resolve(pos).node().type.spec.group === "blockContent" - ) { - positionsBeforeSelectedContent.push(pos - 1); - - pos += state.doc.resolve(pos).node().nodeSize - 1; - } else { - pos += 1; - } - } - - // Sets text alignment for all blockContent nodes that the current selection is in. - for (const pos of positionsBeforeSelectedContent) { - state.tr.setNodeAttribute(pos, "textAlignment", textAlignment); - } - - return true; - }, - }; - }, }); diff --git a/packages/core/src/extensions/TextColor/TextColorExtension.ts b/packages/core/src/extensions/TextColor/TextColorExtension.ts index a3ab7b8db8..09a5d894f4 100644 --- a/packages/core/src/extensions/TextColor/TextColorExtension.ts +++ b/packages/core/src/extensions/TextColor/TextColorExtension.ts @@ -1,15 +1,6 @@ import { Extension } from "@tiptap/core"; -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos"; import { defaultProps } from "../Blocks/api/defaultProps"; -declare module "@tiptap/core" { - interface Commands { - blockTextColor: { - setBlockTextColor: (posInBlock: number, color: string) => ReturnType; - }; - } -} - export const TextColorExtension = Extension.create({ name: "blockTextColor", @@ -33,23 +24,4 @@ export const TextColorExtension = Extension.create({ }, ]; }, - - addCommands() { - return { - setBlockTextColor: - (posInBlock, color) => - ({ state, view }) => { - const blockInfo = getBlockInfoFromPos(state.doc, posInBlock); - if (blockInfo === undefined) { - return false; - } - - state.tr.setNodeAttribute(blockInfo.startPos - 1, "textColor", color); - - view.focus(); - - return true; - }, - }; - }, }); diff --git a/packages/core/src/extensions/TextColor/TextColorMark.ts b/packages/core/src/extensions/TextColor/TextColorMark.ts index ce8a0cb4ca..c18ab0b374 100644 --- a/packages/core/src/extensions/TextColor/TextColorMark.ts +++ b/packages/core/src/extensions/TextColor/TextColorMark.ts @@ -1,24 +1,16 @@ import { Mark } from "@tiptap/core"; -import { defaultProps } from "../Blocks/api/defaultProps"; +import { createStyleSpecFromTipTapMark } from "../Blocks/api/styles/internal"; -declare module "@tiptap/core" { - interface Commands { - textColor: { - setTextColor: (color: string) => ReturnType; - }; - } -} - -export const TextColorMark = Mark.create({ +const TextColorMark = Mark.create({ name: "textColor", addAttributes() { return { - color: { + stringValue: { default: undefined, parseHTML: (element) => element.getAttribute("data-text-color"), renderHTML: (attributes) => ({ - "data-text-color": attributes.color, + "data-text-color": attributes.stringValue, }), }, }; @@ -34,7 +26,7 @@ export const TextColorMark = Mark.create({ } if (element.hasAttribute("data-text-color")) { - return { color: element.getAttribute("data-text-color") }; + return { stringValue: element.getAttribute("data-text-color") }; } return false; @@ -46,18 +38,6 @@ export const TextColorMark = Mark.create({ renderHTML({ HTMLAttributes }) { return ["span", HTMLAttributes, 0]; }, - - addCommands() { - return { - setTextColor: - (color) => - ({ commands }) => { - if (color !== defaultProps.textColor.default) { - return commands.setMark(this.name, { color: color }); - } - - return commands.unsetMark(this.name); - }, - }; - }, }); + +export const TextColor = createStyleSpecFromTipTapMark(TextColorMark, "string"); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 08525b7fe2..41637442bb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,13 +1,21 @@ export * from "./BlockNoteEditor"; export * from "./BlockNoteExtensions"; -export * from "./extensions/Blocks/api/block"; -export * from "./extensions/Blocks/api/blockTypes"; -export * from "./extensions/Blocks/api/defaultProps"; +export * from "./api/exporters/html/externalHTMLExporter"; +export * from "./api/exporters/html/internalHTMLSerializer"; +export * from "./api/testCases/index"; +export * from "./extensions/Blocks/api/blocks/createSpec"; +export * from "./extensions/Blocks/api/blocks/internal"; +export * from "./extensions/Blocks/api/blocks/types"; export * from "./extensions/Blocks/api/defaultBlocks"; -export * from "./extensions/Blocks/api/inlineContentTypes"; +export * from "./extensions/Blocks/api/defaultProps"; +export * from "./extensions/Blocks/api/inlineContent/createSpec"; +export * from "./extensions/Blocks/api/inlineContent/internal"; +export * from "./extensions/Blocks/api/inlineContent/types"; export * from "./extensions/Blocks/api/selectionTypes"; -export * from "./extensions/Blocks/api/serialization"; -export * as blockStyles from "./extensions/Blocks/nodes/Block.module.css"; +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"; export * from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; @@ -16,7 +24,12 @@ export * from "./extensions/SideMenu/SideMenuPlugin"; export * from "./extensions/SlashMenu/BaseSlashMenuItem"; export * from "./extensions/SlashMenu/SlashMenuPlugin"; export { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; +export * from "./extensions/TableHandles/TableHandlesPlugin"; export * from "./shared/BaseUiElementTypes"; export type { SuggestionItem } from "./shared/plugins/suggestion/SuggestionItem"; export * from "./shared/plugins/suggestion/SuggestionPlugin"; export * from "./shared/utils"; +// for testing from react (TODO: move): +export * from "./api/nodeConversions/nodeConversions"; +export * from "./api/nodeConversions/testUtil"; +export * from "./extensions/UniqueID/UniqueID"; diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts index a3c4e4e010..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, @@ -398,7 +404,7 @@ export const setupSuggestionsMenu = < blockNode.pos + blockNode.node.nodeSize, { nodeName: "span", - class: "suggestion-decorator", + class: "bn-suggestion-decorator", "data-decoration-id": decorationId, } ), @@ -412,7 +418,7 @@ export const setupSuggestionsMenu = < queryStartPos, { nodeName: "span", - class: "suggestion-decorator", + class: "bn-suggestion-decorator", "data-decoration-id": decorationId, } ), diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index dd95c8759b..d3389d8eb6 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -9,6 +9,7 @@ const deps = Object.keys(pkg.dependencies); export default defineConfig({ test: { environment: "jsdom", + setupFiles: ["./vitestSetup.ts"], }, plugins: [], build: { diff --git a/packages/core/vitestSetup.ts b/packages/core/vitestSetup.ts new file mode 100644 index 0000000000..78f5b890bf --- /dev/null +++ b/packages/core/vitestSetup.ts @@ -0,0 +1,9 @@ +import { beforeEach, afterEach } from "vitest"; + +beforeEach(() => { + (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; +}); + +afterEach(() => { + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +}); diff --git a/packages/react/package.json b/packages/react/package.json index 0518ab4417..c2a2ee4fca 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -44,7 +44,9 @@ "build": "tsc && vite build", "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" + "lint": "eslint src --max-warnings 0", + "test": "vitest --run", + "test:watch": "vitest --watch" }, "dependencies": { "@blocknote/core": "^0.9.6", @@ -56,7 +58,8 @@ "@tiptap/core": "^2.0.3", "@tiptap/react": "^2.0.3", "lodash": "^4.17.21", - "react": "^18.2.0", + "react": "^18", + "react-dom": "^18.2.0", "react-icons": "^4.3.1", "tippy.js": "^6.3.7", "use-prefers-color-scheme": "^1.1.3" @@ -70,7 +73,8 @@ "typescript": "^5.0.4", "vite": "^4.4.8", "vite-plugin-eslint": "^1.8.1", - "vite-plugin-externalize-deps": "^0.7.0" + "vite-plugin-externalize-deps": "^0.7.0", + "vitest": "^0.34.1" }, "peerDependencies": { "react": "^18", diff --git a/packages/react/src/BlockNoteTheme.ts b/packages/react/src/BlockNoteTheme.ts index 3bad92071b..156eaced8a 100644 --- a/packages/react/src/BlockNoteTheme.ts +++ b/packages/react/src/BlockNoteTheme.ts @@ -1,5 +1,4 @@ import { CSSObject, MantineThemeOverride } from "@mantine/core"; -import { blockStyles } from "@blocknote/core"; import _ from "lodash"; export type CombinedColor = { @@ -41,6 +40,10 @@ export type ComponentStyles = Partial<{ Editor: CSSObject; // Used in the Image Toolbar FileInput: CSSObject; + // Handle that appears next to tables and the menu that opens when clicking it + TableHandle: CSSObject; + TableHandleMenu: CSSObject; + // Used in the Image Toolbar Tabs: CSSObject; TextInput: CSSObject; // Wraps Formatting Toolbar & Hyperlink Toolbar @@ -61,6 +64,7 @@ export type Theme = { export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { const shadow = `0 4px 12px ${theme.colors.shadow}`; + const lightShadow = `0 2px 6px ${theme.colors.border}`; const border = `1px solid ${theme.colors.border}`; const textColors = { @@ -131,6 +135,40 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { ), }), }, + TableHandle: { + styles: () => ({ + root: _.merge( + { + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.menu.background, + border: border, + borderRadius: innerBorderRadius, + boxShadow: lightShadow, + color: theme.colors.sideMenu, + ":hover, div.bn-table-handle-dragging": { + backgroundColor: theme.colors.hovered.background, + }, + cursor: "pointer", + }, + theme.componentStyles?.(theme).TableHandle || {} + ), + }), + }, + TableHandleMenu: { + styles: () => ({ + root: _.merge( + { + ".mantine-Menu-item": { + fontSize: "12px", + height: "30px", + }, + }, + theme.componentStyles?.(theme).TableHandleMenu || {} + ), + }), + }, Tabs: { styles: () => ({ root: _.merge( @@ -244,17 +282,16 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { fontFamily: theme.fontFamily, }, // Placeholders - [`.${blockStyles.isEmpty} .${blockStyles.inlineContent}:before, .${blockStyles.isFilter} .${blockStyles.inlineContent}:before`]: + ".bn-is-empty .bn-inline-content:before, .bn-is-filter .bn-inline-content:before": { color: theme.colors.sideMenu, }, // Indent lines - [`.${blockStyles.blockGroup}`]: { - [`.${blockStyles.blockGroup}`]: { - [`.${blockStyles.blockOuter}:not([data-prev-depth-changed])::before`]: - { - borderLeft: `1px solid ${theme.colors.sideMenu}`, - }, + ".bn-block-group": { + ".bn-block-group": { + ".bn-block-outer:not([data-prev-depth-changed])::before": { + borderLeft: `1px solid ${theme.colors.sideMenu}`, + }, }, }, // Highlight text colors diff --git a/packages/react/src/BlockNoteView.tsx b/packages/react/src/BlockNoteView.tsx index a5f3ee308b..c65cbc9b26 100644 --- a/packages/react/src/BlockNoteView.tsx +++ b/packages/react/src/BlockNoteView.tsx @@ -1,20 +1,31 @@ -import { BlockNoteEditor, BlockSchema, mergeCSSClasses } from "@blocknote/core"; -import { createStyles, MantineProvider } from "@mantine/core"; +import { + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, + mergeCSSClasses, +} from "@blocknote/core"; +import { MantineProvider, createStyles } from "@mantine/core"; import { EditorContent } from "@tiptap/react"; import { HTMLAttributes, ReactNode, useMemo } from "react"; import usePrefersColorScheme from "use-prefers-color-scheme"; -import { blockNoteToMantineTheme, Theme } from "./BlockNoteTheme"; +import { Theme, blockNoteToMantineTheme } from "./BlockNoteTheme"; import { FormattingToolbarPositioner } from "./FormattingToolbar/components/FormattingToolbarPositioner"; import { HyperlinkToolbarPositioner } from "./HyperlinkToolbar/components/HyperlinkToolbarPositioner"; +import { ImageToolbarPositioner } from "./ImageToolbar/components/ImageToolbarPositioner"; import { SideMenuPositioner } from "./SideMenu/components/SideMenuPositioner"; import { SlashMenuPositioner } from "./SlashMenu/components/SlashMenuPositioner"; +import { TableHandlesPositioner } from "./TableHandles/components/TableHandlePositioner"; import { darkDefaultTheme, lightDefaultTheme } from "./defaultThemes"; -import { ImageToolbarPositioner } from "./ImageToolbar/components/ImageToolbarPositioner"; // Renders the editor as well as all menus & toolbars using default styles. -function BaseBlockNoteView( +function BaseBlockNoteView< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema +>( props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; children?: ReactNode; } & HTMLAttributes ) { @@ -36,15 +47,22 @@ function BaseBlockNoteView( + {props.editor.blockSchema.table && ( + + )} )} ); } -export function BlockNoteView( +export function BlockNoteView< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema +>( props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; theme?: | "light" | "dark" diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx index 1af9e4de01..65c5c068a8 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx @@ -1,16 +1,25 @@ -import { useCallback, useMemo, useState } from "react"; +import { + BlockNoteEditor, + BlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "@blocknote/core"; import { Menu } from "@mantine/core"; -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { useCallback, useMemo, useState } from "react"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; import { ColorIcon } from "../../../SharedComponents/ColorPicker/components/ColorIcon"; import { ColorPicker } from "../../../SharedComponents/ColorPicker/components/ColorPicker"; -import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; +import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; import { useEditorChange } from "../../../hooks/useEditorChange"; import { usePreventMenuOverflow } from "../../../hooks/usePreventMenuOverflow"; +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; export const ColorStyleButton = (props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor< + BSchema, + DefaultInlineContentSchema, + DefaultStyleSchema + >; }) => { const selectedBlocks = useSelectedBlocks(props.editor); diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx index b0a7e7d840..cf2dd29e2b 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx @@ -1,3 +1,4 @@ +import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core"; import { ChangeEvent, KeyboardEvent, @@ -6,17 +7,16 @@ import { useMemo, useState, } from "react"; -import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core"; import { RiText } from "react-icons/ri"; import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton"; import { ToolbarInputDropdown } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdown"; -import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; +import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton"; import { ToolbarInputDropdownItem } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownItem"; +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; export const ImageCaptionButton = (props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; }) => { const selectedBlocks = useSelectedBlocks(props.editor); @@ -28,17 +28,19 @@ export const ImageCaptionButton = (props: { selectedBlocks[0].type === "image" && // Checks if the block has a `caption` prop which can take any string // value. - "caption" in props.editor.schema["image"].propSchema && - typeof props.editor.schema["image"].propSchema.caption.default === + "caption" in props.editor.blockSchema["image"].propSchema && + typeof props.editor.blockSchema["image"].propSchema.caption.default === + "string" && + props.editor.blockSchema["image"].propSchema.caption.values === + undefined && + // Checks if the block has a `url` prop which can take any string value. + "url" in props.editor.blockSchema["image"].propSchema && + typeof props.editor.blockSchema["image"].propSchema.url.default === "string" && - props.editor.schema["image"].propSchema.caption.values === undefined && - // Checks if the block has a `src` prop which can take any string value. - "src" in props.editor.schema["image"].propSchema && - typeof props.editor.schema["image"].propSchema.src.default === "string" && - props.editor.schema["image"].propSchema.src.values === undefined && - // Checks if the `src` prop is not set to an empty string. - selectedBlocks[0].props.src !== "", - [props.editor.schema, selectedBlocks] + 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.blockSchema, selectedBlocks] ); const [currentCaption, setCurrentCaption] = useState( @@ -62,7 +64,7 @@ export const ImageCaptionButton = (props: { props: { caption: currentCaption, }, - } as PartialBlock); + } as PartialBlock); } }, [currentCaption, props.editor, selectedBlocks] diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx index cf89ecef97..c56c8ecc30 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx @@ -26,7 +26,7 @@ const icons: Record = { }; export const TextAlignButton = (props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; textAlignment: TextAlignment; }) => { const selectedBlocks = useSelectedBlocks(props.editor); @@ -48,7 +48,7 @@ export const TextAlignButton = (props: { for (const block of selectedBlocks) { props.editor.updateBlock(block, { props: { textAlignment: textAlignment }, - } as PartialBlock); + } as PartialBlock); } }, [props.editor, selectedBlocks] diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx index c71824e873..95895d32db 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx @@ -1,6 +1,9 @@ -import { BlockNoteEditor, BlockSchema, ToggledStyle } from "@blocknote/core"; +import { + BlockNoteEditor, + BlockSchema, + InlineContentSchema, +} from "@blocknote/core"; import { useMemo, useState } from "react"; -import { IconType } from "react-icons"; import { RiBold, RiCodeFill, @@ -9,12 +12,13 @@ import { RiUnderline, } from "react-icons/ri"; +import { StyleSchema } from "@blocknote/core"; import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; import { useEditorChange } from "../../../hooks/useEditorChange"; import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; import { formatKeyboardShortcut } from "../../../utils"; -const shortcuts: Record = { +const shortcuts = { bold: "Mod+B", italic: "Mod+I", underline: "Mod+U", @@ -22,7 +26,7 @@ const shortcuts: Record = { code: "", }; -const icons: Record = { +const icons = { bold: RiBold, italic: RiItalic, underline: RiUnderline, @@ -30,9 +34,13 @@ const icons: Record = { code: RiCodeFill, }; -export const ToggledStyleButton = (props: { - editor: BlockNoteEditor; - toggledStyle: ToggledStyle; +export const ToggledStyleButton = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(props: { + editor: BlockNoteEditor; + toggledStyle: keyof typeof shortcuts; }) => { const selectedBlocks = useSelectedBlocks(props.editor); @@ -44,9 +52,12 @@ export const ToggledStyleButton = (props: { setActive(props.toggledStyle in props.editor.getActiveStyles()); }); - const toggleStyle = (style: ToggledStyle) => { + const toggleStyle = (style: typeof props.toggledStyle) => { props.editor.focus(); - props.editor.toggleStyles({ [style]: true }); + if (props.editor.styleSchema[style].propSchema !== "boolean") { + throw new Error("can only toggle boolean styles"); + } + props.editor.toggleStyles({ [style]: true } as any); }; const show = useMemo(() => { diff --git a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx index 34868362bc..4d15de5be6 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx @@ -1,10 +1,5 @@ +import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { useMemo, useState } from "react"; -import { - Block, - BlockNoteEditor, - BlockSchema, - PartialBlock, -} from "@blocknote/core"; import { IconType } from "react-icons"; import { RiH1, @@ -25,7 +20,7 @@ export type BlockTypeDropdownItem = { type: string; props?: Record; icon: IconType; - isSelected: (block: Block) => boolean; + isSelected: (block: Block) => boolean; }; export const defaultBlockTypeDropdownItems: BlockTypeDropdownItem[] = [ @@ -92,13 +87,13 @@ export const BlockTypeDropdown = (props: { const filteredItems: BlockTypeDropdownItem[] = useMemo(() => { return (props.items || defaultBlockTypeDropdownItems).filter((item) => { // Checks if block type exists in the schema - if (!(item.type in props.editor.schema)) { + if (!(item.type in props.editor.blockSchema)) { return false; } // Checks if props for the block type are valid for (const [prop, value] of Object.entries(item.props || {})) { - const propSchema = props.editor.schema[item.type].propSchema; + const propSchema = props.editor.blockSchema[item.type].propSchema; // Checks if the prop exists for the block type if (!(prop in propSchema)) { @@ -129,9 +124,9 @@ export const BlockTypeDropdown = (props: { for (const block of selectedBlocks) { props.editor.updateBlock(block, { - type: item.type, - props: item.props, - } as PartialBlock); + type: item.type as any, + props: item.props as any, + }); } }; @@ -139,7 +134,7 @@ export const BlockTypeDropdown = (props: { text: item.name, icon: item.icon, onClick: () => onClick(item), - isSelected: item.isSelected(block as Block), + isSelected: item.isSelected(block as Block), })); }, [block, filteredItems, props.editor, selectedBlocks]); diff --git a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx index 891c5fc0d5..9441f5e5fa 100644 --- a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx +++ b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx @@ -8,8 +8,8 @@ import Tippy, { tippy } from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; import { sticky } from "tippy.js"; -import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar"; import { useEditorChange } from "../../hooks/useEditorChange"; +import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar"; const textAlignmentToPlacement = ( textAlignment: DefaultProps["textAlignment"] @@ -29,13 +29,13 @@ const textAlignmentToPlacement = ( export type FormattingToolbarProps< BSchema extends BlockSchema = DefaultBlockSchema > = { - editor: BlockNoteEditor; + editor: BlockNoteEditor; }; export const FormattingToolbarPositioner = < BSchema extends BlockSchema = DefaultBlockSchema >(props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; formattingToolbar?: FC>; }) => { const [show, setShow] = useState(false); diff --git a/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx b/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx index dc4a1a5f5f..269de59419 100644 --- a/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx +++ b/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx @@ -1,14 +1,19 @@ +import { BlockSchema, InlineContentSchema } from "@blocknote/core"; import { useRef, useState } from "react"; import { RiExternalLinkFill, RiLinkUnlink } from "react-icons/ri"; -import { BlockSchema } from "@blocknote/core"; -import { HyperlinkToolbarProps } from "./HyperlinkToolbarPositioner"; +import { StyleSchema } from "@blocknote/core"; import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; import { ToolbarButton } from "../../SharedComponents/Toolbar/components/ToolbarButton"; import { EditHyperlinkMenu } from "./EditHyperlinkMenu/components/EditHyperlinkMenu"; +import { HyperlinkToolbarProps } from "./HyperlinkToolbarPositioner"; -export const DefaultHyperlinkToolbar = ( - props: HyperlinkToolbarProps +export const DefaultHyperlinkToolbar = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + props: HyperlinkToolbarProps ) => { const [isEditing, setIsEditing] = useState(false); const editMenuRef = useRef(null); diff --git a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx index 66b76706ce..6890e5df61 100644 --- a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx +++ b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx @@ -3,25 +3,35 @@ import { BlockNoteEditor, BlockSchema, DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, HyperlinkToolbarProsemirrorPlugin, HyperlinkToolbarState, + InlineContentSchema, } from "@blocknote/core"; import Tippy from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; +import { StyleSchema } from "@blocknote/core"; import { DefaultHyperlinkToolbar } from "./DefaultHyperlinkToolbar"; -export type HyperlinkToolbarProps = Pick< - HyperlinkToolbarProsemirrorPlugin, +export type HyperlinkToolbarProps< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = Pick< + HyperlinkToolbarProsemirrorPlugin, "editHyperlink" | "deleteHyperlink" | "startHideTimer" | "stopHideTimer" > & Omit; export const HyperlinkToolbarPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema >(props: { - editor: BlockNoteEditor; - hyperlinkToolbar?: FC>; + editor: BlockNoteEditor; + hyperlinkToolbar?: FC>; }) => { const [show, setShow] = useState(false); const [url, setUrl] = useState(); diff --git a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx b/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx index d04226f116..49260e289a 100644 --- a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx +++ b/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx @@ -1,14 +1,5 @@ import { BlockSchema, PartialBlock } from "@blocknote/core"; -import { ImageToolbarProps } from "./ImageToolbarPositioner"; -import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; -import { - ChangeEvent, - KeyboardEvent, - useCallback, - useEffect, - useState, -} from "react"; import { Button, FileInput, @@ -17,9 +8,18 @@ import { Text, TextInput, } from "@mantine/core"; +import { + ChangeEvent, + KeyboardEvent, + useCallback, + useEffect, + useState, +} from "react"; +import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; +import { ImageToolbarProps } from "./ImageToolbarPositioner"; export const DefaultImageToolbar = ( - props: ImageToolbarProps + props: ImageToolbarProps ) => { const [openTab, setOpenTab] = useState<"upload" | "embed">( props.editor.uploadFile !== undefined ? "upload" : "embed" @@ -46,7 +46,7 @@ export const DefaultImageToolbar = ( props: { url: uploaded, }, - } as PartialBlock); + } as PartialBlock); } catch (e) { setUploadFailed(true); } finally { @@ -75,7 +75,7 @@ export const DefaultImageToolbar = ( props: { url: currentURL, }, - } as PartialBlock); + } as PartialBlock); } }, [currentURL, props.block, props.editor] @@ -87,7 +87,7 @@ export const DefaultImageToolbar = ( props: { url: currentURL, }, - } as PartialBlock); + } as PartialBlock); }, [currentURL, props.block, props.editor]); return ( diff --git a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx index 9400cf75d9..7bcfdd8615 100644 --- a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx +++ b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx @@ -2,9 +2,10 @@ import { BaseUiElementState, BlockNoteEditor, BlockSchema, - BlockSpec, DefaultBlockSchema, + DefaultInlineContentSchema, ImageToolbarState, + InlineContentSchema, SpecificBlock, } from "@blocknote/core"; import Tippy, { tippy } from "@tippyjs/react"; @@ -13,32 +14,21 @@ import { FC, useEffect, useMemo, useRef, useState } from "react"; import { DefaultImageToolbar } from "./DefaultImageToolbar"; export type ImageToolbarProps< - BSchema extends BlockSchema = DefaultBlockSchema -> = Omit & { - editor: BlockNoteEditor; + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema +> = Omit, keyof BaseUiElementState> & { + editor: BlockNoteEditor; }; export const ImageToolbarPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema >(props: { - editor: BlockNoteEditor; - imageToolbar?: FC>; + editor: BlockNoteEditor; + imageToolbar?: FC>; }) => { const [show, setShow] = useState(false); - const [block, setBlock] = useState< - SpecificBlock< - BlockSchema & { - image: BlockSpec< - "image", - { - src: { default: string }; - }, - false - >; - }, - "image" - > - >(); + const [block, setBlock] = useState>(); const referencePos = useRef(); diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index d748edfa4a..b5f48dc2df 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -1,17 +1,21 @@ import { - BlockConfig, - BlockNoteDOMAttributes, + BlockFromConfig, BlockNoteEditor, - BlockSchema, - BlockSpec, - blockStyles, + BlockSchemaWithBlock, camelToDataKebab, - createTipTapBlock, + createInternalBlockSpec, + createStronglyTypedTiptapNode, + CustomBlockConfig, + getBlockFromPos, + getParseRules, + inheritedProps, + InlineContentSchema, mergeCSSClasses, - parse, + PartialBlockFromConfig, + Props, PropSchema, propsToAttributes, - render, + StyleSchema, } from "@blocknote/core"; import { NodeViewContent, @@ -19,160 +23,185 @@ import { NodeViewWrapper, ReactNodeViewRenderer, } from "@tiptap/react"; -import { createContext, ElementType, FC, HTMLProps, useContext } from "react"; +import { FC } from "react"; +import { renderToDOMSpec } from "./ReactRenderUtil"; + +// this file is mostly analogoues to `customBlocks.ts`, but for React blocks // extend BlockConfig but use a React render function -export type ReactBlockConfig< - Type extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema -> = Omit< - BlockConfig, - "render" -> & { +export type ReactCustomBlockImplementation< + T extends CustomBlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = { render: FC<{ - block: Parameters< - BlockConfig["render"] - >[0]; - editor: Parameters< - BlockConfig["render"] - >[1]; + block: BlockFromConfig; + editor: BlockNoteEditor, I, S>; + contentRef: (node: HTMLElement | null) => void; + }>; + toExternalHTML?: FC<{ + block: BlockFromConfig; + editor: BlockNoteEditor, I, S>; + contentRef: (node: HTMLElement | null) => void; }>; + parse?: (el: HTMLElement) => PartialBlockFromConfig | undefined; }; -const BlockNoteDOMAttributesContext = createContext({}); - -export const InlineContent = ( - props: { as?: Tag } & HTMLProps -) => { - const inlineContentDOMAttributes = - useContext(BlockNoteDOMAttributesContext).inlineContent || {}; - - const classNames = mergeCSSClasses( - props.className || "", - blockStyles.inlineContent, - inlineContentDOMAttributes.class - ); - - return ( - ( + element: JSX.Element, + blockType: BType, + blockProps: Props, + propSchema: PSchema, + domAttributes?: Record +) { + return () => ( + // Creates `blockContent` element + key !== "class" - ) + Object.entries(domAttributes || {}).filter(([key]) => key !== "class") + )} + // Sets blockContent class + className={mergeCSSClasses( + "bn-block-content", + domAttributes?.class || "" )} - {...props} - className={classNames} - /> + // Sets content type attribute + data-content-type={blockType} + // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips + // props which are already added as HTML attributes to the parent + // `blockContent` element (inheritedProps) and props set to their default + // values + {...Object.fromEntries( + Object.entries(blockProps) + .filter( + ([prop, value]) => + !inheritedProps.includes(prop) && + 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 createReactBlockSpec< - BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema + T extends CustomBlockConfig, + I extends InlineContentSchema, + S extends StyleSchema >( - blockConfig: ReactBlockConfig -): BlockSpec { - const node = createTipTapBlock< - BType, - ContainsInlineContent, - { - editor: BlockNoteEditor; - domAttributes?: BlockNoteDOMAttributes; - } - >({ - name: blockConfig.type, - content: (blockConfig.containsInlineContent + blockConfig: T, + blockImplementation: ReactCustomBlockImplementation +) { + const node = createStronglyTypedTiptapNode({ + name: blockConfig.type as T["type"], + content: (blockConfig.content === "inline" ? "inline*" - : "") as ContainsInlineContent extends true ? "inline*" : "", + : "") as T["content"] extends "inline" ? "inline*" : "", + group: "blockContent", selectable: true, addAttributes() { - return propsToAttributes(blockConfig); + return propsToAttributes(blockConfig.propSchema); }, parseHTML() { - return parse(blockConfig); - }, - - renderHTML({ HTMLAttributes }) { - return render(blockConfig, HTMLAttributes); + return getParseRules(blockConfig, blockImplementation.parse); }, addNodeView() { - const BlockContent: FC = (props: NodeViewProps) => { - const Content = blockConfig.render; - - // Add custom HTML attributes - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - - // Add props as HTML attributes in kebab-case with "data-" prefix - const htmlAttributes: Record = {}; - for (const [attribute, value] of Object.entries(props.node.attrs)) { - if ( - attribute in blockConfig.propSchema && - value !== blockConfig.propSchema[attribute].default - ) { - htmlAttributes[camelToDataKebab(attribute)] = value; + return (props) => + ReactNodeViewRenderer( + (props: NodeViewProps) => { + // Gets the BlockNote editor instance + const editor = this.options.editor! as BlockNoteEditor; + // Gets the block + const block = getBlockFromPos( + props.getPos, + editor, + this.editor, + blockConfig.type + ) as any; + // Gets the custom HTML attributes for `blockContent` nodes + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; + + // 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, + blockContentDOMAttributes + ); + + return ; + }, + { + className: "bn-react-node-view-renderer", } - } - - // Gets BlockNote editor instance - const editor = this.options.editor! as BlockNoteEditor< - BSchema & { - [k in BType]: BlockSpec; - } - >; - // Gets position of the node - const pos = - typeof props.getPos === "function" ? props.getPos() : undefined; - // Gets TipTap editor instance - const tipTapEditor = editor._tiptapEditor; - // Gets parent blockContainer node - const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); - // Gets block identifier - const blockIdentifier = blockContainer.attrs.id; - // Get the block - const block = editor.getBlock(blockIdentifier)!; - if (block.type !== blockConfig.type) { - throw new Error("Block type does not match"); - } + )(props); + }, + }); - return ( - key !== "class" - ) - )} - className={mergeCSSClasses( - blockStyles.blockContent, - blockContentDOMAttributes.class - )} - data-content-type={blockConfig.type} - {...htmlAttributes}> - - - - + return createInternalBlockSpec(blockConfig, { + node: node, + toInternalHTML: (block, editor) => { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + const Content = blockImplementation.render; + + return renderToDOMSpec((refCB) => { + const BlockContent = reactWrapInBlockStructure( + , + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes ); - }; - - return ReactNodeViewRenderer(BlockContent, { - className: blockStyles.reactNodeViewRenderer, + return ; + }); + }, + toExternalHTML: (block, editor) => { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + const Content = + blockImplementation.toExternalHTML || blockImplementation.render; + + return renderToDOMSpec((refCB) => { + const BlockContent = reactWrapInBlockStructure( + , + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + return ; }); }, }); - - return { - node: node, - propSchema: blockConfig.propSchema, - }; } diff --git a/packages/react/src/ReactInlineContentSpec.tsx b/packages/react/src/ReactInlineContentSpec.tsx new file mode 100644 index 0000000000..6d598990e0 --- /dev/null +++ b/packages/react/src/ReactInlineContentSpec.tsx @@ -0,0 +1,171 @@ +import { + CustomInlineContentConfig, + InlineContentConfig, + InlineContentFromConfig, + PropSchema, + Props, + StyleSchema, + addInlineContentAttributes, + camelToDataKebab, + createInternalInlineContentSpec, + createStronglyTypedTiptapNode, + getInlineContentParseRules, + nodeToCustomInlineContent, + propsToAttributes, +} from "@blocknote/core"; +import { + NodeViewContent, + NodeViewProps, + NodeViewWrapper, + ReactNodeViewRenderer, +} from "@tiptap/react"; +// import { useReactNodeView } from "@tiptap/react/dist/packages/react/src/useReactNodeView"; +import { FC } from "react"; +import { renderToDOMSpec } from "./ReactRenderUtil"; + +// this file is mostly analogoues to `customBlocks.ts`, but for React blocks + +// extend BlockConfig but use a React render function +export type ReactInlineContentImplementation< + T extends InlineContentConfig, + // I extends InlineContentSchema, + S extends StyleSchema +> = { + render: FC<{ + inlineContent: InlineContentFromConfig; + contentRef: (node: HTMLElement | null) => void; + }>; + // TODO? + // toExternalHTML?: FC<{ + // block: BlockFromConfig; + // editor: BlockNoteEditor, I, S>; + // }>; +}; + +// Function that adds a wrapper with necessary classes and attributes to the +// component returned from a custom inline content's 'render' function, to +// ensure no data is lost on internal copy & paste. +export function reactWrapInInlineContentStructure< + IType extends string, + PSchema extends PropSchema +>( + element: JSX.Element, + inlineContentType: IType, + inlineContentProps: Props, + propSchema: PSchema +) { + return () => ( + // Creates inline content section element + value !== propSchema[prop].default) + .map(([prop, value]) => { + return [camelToDataKebab(prop), value]; + }) + )}> + {element} + + ); +} + +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function createReactInlineContentSpec< + T extends CustomInlineContentConfig, + // I extends InlineContentSchema, + S extends StyleSchema +>( + inlineContentConfig: T, + inlineContentImplementation: ReactInlineContentImplementation +) { + const node = createStronglyTypedTiptapNode({ + name: inlineContentConfig.type as T["type"], + inline: true, + group: "inline", + selectable: inlineContentConfig.content === "styled", + atom: inlineContentConfig.content === "none", + content: (inlineContentConfig.content === "styled" + ? "inline*" + : "") as T["content"] extends "styled" ? "inline*" : "", + + addAttributes() { + return propsToAttributes(inlineContentConfig.propSchema); + }, + + parseHTML() { + return getInlineContentParseRules(inlineContentConfig); + }, + + renderHTML({ node }) { + const editor = this.options.editor; + + const ic = nodeToCustomInlineContent( + node, + editor.inlineContentSchema, + editor.styleSchema + ) as any as InlineContentFromConfig; // TODO: fix cast + const Content = inlineContentImplementation.render; + const output = renderToDOMSpec((refCB) => ( + + )); + + return { + dom: addInlineContentAttributes( + output.dom, + inlineContentConfig.type, + node.attrs as Props, + inlineContentConfig.propSchema + ), + contentDOM: output.contentDOM, + }; + }, + + // TODO: needed? + addNodeView() { + const editor = this.options.editor; + + return (props) => + ReactNodeViewRenderer( + (props: NodeViewProps) => { + // hacky, should export `useReactNodeView` from tiptap to get access to ref + const ref = (NodeViewContent({}) as any).ref; + + const Content = inlineContentImplementation.render; + const FullContent = reactWrapInInlineContentStructure( + // TODO: fix cast + } + />, + inlineContentConfig.type, + props.node.attrs as Props, + inlineContentConfig.propSchema + ); + return ; + }, + { + className: "bn-ic-react-node-view-renderer", + as: "span", + // contentDOMElementTag: "span", (requires tt upgrade) + } + )(props); + }, + }); + + return createInternalInlineContentSpec(inlineContentConfig, { + node: node, + } as any); +} diff --git a/packages/react/src/ReactRenderUtil.ts b/packages/react/src/ReactRenderUtil.ts new file mode 100644 index 0000000000..36262e9392 --- /dev/null +++ b/packages/react/src/ReactRenderUtil.ts @@ -0,0 +1,37 @@ +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; + +export function renderToDOMSpec( + fc: (refCB: (ref: HTMLElement | null) => void) => React.ReactNode +) { + let contentDOM: HTMLElement | undefined; + const div = document.createElement("div"); + const root = createRoot(div); + flushSync(() => { + root.render(fc((el) => (contentDOM = el || undefined))); + }); + + if (!div.childElementCount) { + // TODO + console.warn("ReactInlineContentSpec: renderHTML() failed"); + return { + dom: document.createElement("span"), + }; + } + + // clone so we can unmount the react root + contentDOM?.setAttribute("data-tmp-find", "true"); + const cloneRoot = div.cloneNode(true) as HTMLElement; + const dom = cloneRoot.firstElementChild! as HTMLElement; + const contentDOMClone = cloneRoot.querySelector( + "[data-tmp-find]" + ) as HTMLElement | null; + contentDOMClone?.removeAttribute("data-tmp-find"); + + root.unmount(); + + return { + dom, + contentDOM: contentDOMClone || undefined, + }; +} diff --git a/packages/react/src/ReactStyleSpec.tsx b/packages/react/src/ReactStyleSpec.tsx new file mode 100644 index 0000000000..cb401850b7 --- /dev/null +++ b/packages/react/src/ReactStyleSpec.tsx @@ -0,0 +1,65 @@ +import { + addStyleAttributes, + createInternalStyleSpec, + getStyleParseRules, + StyleConfig, + stylePropsToAttributes, +} from "@blocknote/core"; +import { Mark } from "@tiptap/react"; +import { FC } from "react"; +import { renderToDOMSpec } from "./ReactRenderUtil"; + +// this file is mostly analogoues to `customBlocks.ts`, but for React blocks + +// extend BlockConfig but use a React render function +export type ReactCustomStyleImplementation = { + render: T["propSchema"] extends "boolean" + ? FC<{ contentRef: (el: HTMLElement | null) => void }> + : FC<{ contentRef: (el: HTMLElement | null) => void; value: string }>; +}; + +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function createReactStyleSpec( + styleConfig: T, + styleImplementation: ReactCustomStyleImplementation +) { + const mark = Mark.create({ + name: styleConfig.type, + + addAttributes() { + return stylePropsToAttributes(styleConfig.propSchema); + }, + + parseHTML() { + return getStyleParseRules(styleConfig); + }, + + renderHTML({ mark }) { + const props: any = {}; + + if (styleConfig.propSchema === "string") { + props.value = mark.attrs.stringValue; + } + + const Content = styleImplementation.render; + const renderResult = renderToDOMSpec((refCB) => ( + + )); + + return { + dom: addStyleAttributes( + renderResult.dom, + styleConfig.type, + mark.attrs.stringValue, + styleConfig.propSchema + ), + contentDOM: renderResult.contentDOM, + }; + }, + }); + + return createInternalStyleSpec(styleConfig, { + mark, + }); +} diff --git a/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx b/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx index aa4985d700..0dadb3aac2 100644 --- a/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx +++ b/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx @@ -1,10 +1,10 @@ +import { BlockSchema } from "@blocknote/core"; import { AiOutlinePlus } from "react-icons/ai"; import { SideMenuButton } from "../SideMenuButton"; import { SideMenuProps } from "../SideMenuPositioner"; -import { BlockSchema } from "@blocknote/core"; export const AddBlockButton = ( - props: SideMenuProps + props: SideMenuProps ) => ( ( - props: SideMenuProps + props: SideMenuProps ) => { const DragHandleMenu = props.dragHandleMenu || DefaultDragHandleMenu; diff --git a/packages/react/src/SideMenu/components/DefaultSideMenu.tsx b/packages/react/src/SideMenu/components/DefaultSideMenu.tsx index 46623a9c12..1ae3fcb99e 100644 --- a/packages/react/src/SideMenu/components/DefaultSideMenu.tsx +++ b/packages/react/src/SideMenu/components/DefaultSideMenu.tsx @@ -1,12 +1,17 @@ -import { BlockSchema } from "@blocknote/core"; +import { BlockSchema, InlineContentSchema } from "@blocknote/core"; -import { SideMenuProps } from "./SideMenuPositioner"; -import { SideMenu } from "./SideMenu"; +import { StyleSchema } from "@blocknote/core"; import { AddBlockButton } from "./DefaultButtons/AddBlockButton"; import { DragHandle } from "./DefaultButtons/DragHandle"; +import { SideMenu } from "./SideMenu"; +import { SideMenuProps } from "./SideMenuPositioner"; -export const DefaultSideMenu = ( - props: SideMenuProps +export const DefaultSideMenu = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + props: SideMenuProps ) => ( diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx index f8ea41fa56..e198ebf46e 100644 --- a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx @@ -1,21 +1,21 @@ -import { ReactNode, useCallback, useRef, useState } from "react"; +import { BlockSchema, PartialBlock } from "@blocknote/core"; import { Box, Menu } from "@mantine/core"; +import { ReactNode, useCallback, useRef, useState } from "react"; import { HiChevronRight } from "react-icons/hi"; -import { BlockSchema, PartialBlock } from "@blocknote/core"; -import { DragHandleMenuProps } from "../DragHandleMenu"; -import { DragHandleMenuItem } from "../DragHandleMenuItem"; import { ColorPicker } from "../../../../SharedComponents/ColorPicker/components/ColorPicker"; import { usePreventMenuOverflow } from "../../../../hooks/usePreventMenuOverflow"; +import { DragHandleMenuProps } from "../DragHandleMenu"; +import { DragHandleMenuItem } from "../DragHandleMenuItem"; export const BlockColorsButton = ( - props: DragHandleMenuProps & { children: ReactNode } + props: DragHandleMenuProps & { children: ReactNode } ) => { const [opened, setOpened] = useState(false); const { ref, updateMaxHeight } = usePreventMenuOverflow(); - const menuCloseTimer = useRef(); + const menuCloseTimer = useRef | undefined>(); const startMenuCloseTimer = useCallback(() => { if (menuCloseTimer.current) { @@ -73,7 +73,7 @@ export const BlockColorsButton = ( setColor: (color) => props.editor.updateBlock(props.block, { props: { textColor: color }, - } as PartialBlock), + } as PartialBlock), } : undefined } @@ -85,7 +85,7 @@ export const BlockColorsButton = ( setColor: (color) => props.editor.updateBlock(props.block, { props: { backgroundColor: color }, - } as PartialBlock), + } as PartialBlock), } : undefined } diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx index bbd5e2331c..1b05fff510 100644 --- a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx @@ -1,11 +1,11 @@ -import { ReactNode } from "react"; import { BlockSchema } from "@blocknote/core"; +import { ReactNode } from "react"; import { DragHandleMenuProps } from "../DragHandleMenu"; import { DragHandleMenuItem } from "../DragHandleMenuItem"; export const RemoveBlockButton = ( - props: DragHandleMenuProps & { children: ReactNode } + props: DragHandleMenuProps & { children: ReactNode } ) => { return ( ( - props: DragHandleMenuProps + props: DragHandleMenuProps ) => ( Delete diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx index b67dd98836..be806fd5a1 100644 --- a/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx @@ -1,10 +1,20 @@ +import { + Block, + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { Menu, createStyles } from "@mantine/core"; import { ReactNode } from "react"; -import { createStyles, Menu } from "@mantine/core"; -import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; -export type DragHandleMenuProps = { - editor: BlockNoteEditor; - block: Block; +export type DragHandleMenuProps< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + editor: BlockNoteEditor; + block: Block; }; export const DragHandleMenu = (props: { children: ReactNode }) => { diff --git a/packages/react/src/SideMenu/components/SideMenuPositioner.tsx b/packages/react/src/SideMenu/components/SideMenuPositioner.tsx index c77caa6cea..b0c94d7b71 100644 --- a/packages/react/src/SideMenu/components/SideMenuPositioner.tsx +++ b/packages/react/src/SideMenu/components/SideMenuPositioner.tsx @@ -3,36 +3,41 @@ import { BlockNoteEditor, BlockSchema, DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, SideMenuProsemirrorPlugin, } from "@blocknote/core"; import Tippy from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; +import { StyleSchema } from "@blocknote/core"; import { DefaultSideMenu } from "./DefaultSideMenu"; import { DragHandleMenuProps } from "./DragHandleMenu/DragHandleMenu"; -export type SideMenuProps = - Pick< - SideMenuProsemirrorPlugin, - | "blockDragStart" - | "blockDragEnd" - | "addBlock" - | "freezeMenu" - | "unfreezeMenu" - > & { - block: Block; - editor: BlockNoteEditor; - dragHandleMenu?: FC>; - }; +export type SideMenuProps< + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +> = Pick< + SideMenuProsemirrorPlugin, + "blockDragStart" | "blockDragEnd" | "addBlock" | "freezeMenu" | "unfreezeMenu" +> & { + block: Block; + editor: BlockNoteEditor; + dragHandleMenu?: FC>; +}; export const SideMenuPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema >(props: { - editor: BlockNoteEditor; - sideMenu?: FC>; + editor: BlockNoteEditor; + sideMenu?: FC>; }) => { const [show, setShow] = useState(false); - const [block, setBlock] = useState>(); + const [block, setBlock] = useState>(); const referencePos = useRef(); diff --git a/packages/react/src/SlashMenu/ReactSlashMenuItem.ts b/packages/react/src/SlashMenu/ReactSlashMenuItem.ts index b5e6f24091..65ceb044f7 100644 --- a/packages/react/src/SlashMenu/ReactSlashMenuItem.ts +++ b/packages/react/src/SlashMenu/ReactSlashMenuItem.ts @@ -2,11 +2,17 @@ import { BaseSlashMenuItem, BlockSchema, DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, } from "@blocknote/core"; export type ReactSlashMenuItem< - BSchema extends BlockSchema = DefaultBlockSchema -> = BaseSlashMenuItem & { + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +> = BaseSlashMenuItem & { group: string; icon: JSX.Element; hint?: string; diff --git a/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx b/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx index e3e005b68e..6c084ad245 100644 --- a/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx +++ b/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx @@ -8,12 +8,12 @@ import { import Tippy from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; +import { usePreventMenuOverflow } from "../../hooks/usePreventMenuOverflow"; import { ReactSlashMenuItem } from "../ReactSlashMenuItem"; import { DefaultSlashMenu } from "./DefaultSlashMenu"; -import { usePreventMenuOverflow } from "../../hooks/usePreventMenuOverflow"; export type SlashMenuProps = - Pick, "itemCallback"> & + Pick, "itemCallback"> & Pick< SuggestionsMenuState>, "filteredItems" | "keyboardHoveredItemIndex" @@ -22,7 +22,7 @@ export type SlashMenuProps = export const SlashMenuPositioner = < BSchema extends BlockSchema = DefaultBlockSchema >(props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; slashMenu?: FC>; }) => { const [show, setShow] = useState(false); diff --git a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx b/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx index b8b1fe6a18..66411dda62 100644 --- a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx +++ b/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx @@ -4,6 +4,8 @@ import { defaultBlockSchema, DefaultBlockSchema, getDefaultSlashMenuItems, + InlineContentSchema, + StyleSchema, } from "@blocknote/core"; import { RiH1, @@ -12,6 +14,7 @@ import { RiImage2Fill, RiListOrdered, RiListUnordered, + RiTable2, RiText, } from "react-icons/ri"; import { formatKeyboardShortcut } from "../utils"; @@ -21,7 +24,7 @@ const extraFields: Record< string, Omit< ReactSlashMenuItem, - keyof BaseSlashMenuItem + keyof BaseSlashMenuItem > > = { Heading: { @@ -60,6 +63,12 @@ const extraFields: Record< hint: "Used for the body of your document", shortcut: formatKeyboardShortcut("Mod-Alt-0"), }, + Table: { + group: "Advanced", + icon: , + hint: "Used for for tables", + // shortcut: formatKeyboardShortcut("Mod-Alt-0"), + }, Image: { group: "Media", icon: , @@ -67,14 +76,18 @@ const extraFields: Record< }, }; -export function getDefaultReactSlashMenuItems( +export function getDefaultReactSlashMenuItems< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( // This type casting is weird, but it's the best way of doing it, as it allows // the schema type to be automatically inferred if it is defined, or be // inferred as any if it is not defined. I don't think it's possible to make it // infer to DefaultBlockSchema if it is not defined. - schema: BSchema = defaultBlockSchema as unknown as BSchema -): ReactSlashMenuItem[] { - const slashMenuItems: BaseSlashMenuItem[] = + schema: BSchema = defaultBlockSchema as any as BSchema +): ReactSlashMenuItem[] { + const slashMenuItems: BaseSlashMenuItem[] = getDefaultSlashMenuItems(schema); return slashMenuItems.map((item) => ({ diff --git a/packages/react/src/TableHandles/components/DefaultTableHandle.tsx b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx new file mode 100644 index 0000000000..e7e98d4f1b --- /dev/null +++ b/packages/react/src/TableHandles/components/DefaultTableHandle.tsx @@ -0,0 +1,23 @@ +import { BlockSchemaWithBlock, DefaultBlockSchema } from "@blocknote/core"; +import { MdDragIndicator } from "react-icons/md"; +import { TableHandle } from "./TableHandle"; +import { TableHandleProps } from "./TableHandlePositioner"; + +export const DefaultTableHandle = < + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> +>( + props: TableHandleProps +) => ( + +
            + +
            +
            +); diff --git a/packages/react/src/TableHandles/components/TableHandle.tsx b/packages/react/src/TableHandles/components/TableHandle.tsx new file mode 100644 index 0000000000..aca6b9fbe5 --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandle.tsx @@ -0,0 +1,62 @@ +import { BlockSchemaWithBlock, DefaultBlockSchema } from "@blocknote/core"; +import { Menu, createStyles } from "@mantine/core"; +import { ReactNode, useState } from "react"; +import { DefaultTableHandleMenu } from "./TableHandleMenu/DefaultTableHandleMenu"; +import { TableHandleProps } from "./TableHandlePositioner"; + +export const TableHandle = < + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> +>( + props: TableHandleProps & { children: ReactNode } +) => { + const { classes } = createStyles({ root: {} })(undefined, { + name: "TableHandle", + }); + + const TableHandleMenu = props.tableHandleMenu || DefaultTableHandleMenu; + + const [isDragging, setIsDragging] = useState(false); + + return ( + { + props.freezeHandles(); + props.hideOtherSide(); + }} + onClose={() => { + props.unfreezeHandles(); + props.showOtherSide(); + }} + position={"right"}> + +
            { + setIsDragging(true); + props.dragStart(e); + }} + onDragEnd={() => { + props.dragEnd(); + setIsDragging(false); + }} + style={ + props.orientation === "column" + ? { transform: "rotate(0.25turn)" } + : undefined + }> +
            + {props.children} +
            +
            +
            + +
            + ); +}; diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx new file mode 100644 index 0000000000..46d587e0cb --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/AddButton.tsx @@ -0,0 +1,73 @@ +import { + DefaultBlockSchema, + PartialBlock, + TableContent, +} from "@blocknote/core"; +import { TableHandleMenuProps } from "../TableHandleMenu"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; + +export const AddRowButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & { side: "above" | "below" } +) => ( + { + const emptyCol = props.block.content.rows[props.index].cells.map( + () => [] + ); + const rows = [...props.block.content.rows]; + rows.splice(props.index + (props.side === "below" ? 1 : 0), 0, { + cells: emptyCol, + }); + + props.editor.updateBlock(props.block, { + type: "table", + content: { + rows, + }, + } as PartialBlock); + }}> + {`Add row ${props.side}`} + +); + +export const AddColumnButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & { side: "left" | "right" } +) => ( + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.map((row) => { + const cells = [...row.cells]; + cells.splice(props.index + (props.side === "right" ? 1 : 0), 0, []); + return { cells }; + }), + }; + + props.editor.updateBlock(props.block, { + type: "table", + content: content, + } as PartialBlock); + }}> + {`Add column ${props.side}`} + +); + +export const AddButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & + ( + | { orientation: "row"; side: "above" | "below" } + | { orientation: "column"; side: "left" | "right" } + ) +) => + props.orientation === "row" ? ( + + ) : ( + + ); diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx new file mode 100644 index 0000000000..2a8af93c37 --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultButtons/DeleteButton.tsx @@ -0,0 +1,64 @@ +import { + DefaultBlockSchema, + PartialBlock, + TableContent, +} from "@blocknote/core"; +import { TableHandleMenuProps } from "../TableHandleMenu"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; + +export const DeleteRowButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps +) => ( + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.filter( + (_, index) => index !== props.index + ), + }; + + props.editor.updateBlock(props.block, { + type: "table", + content, + } as PartialBlock); + }}> + Delete row + +); + +export const DeleteColumnButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps +) => ( + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.map((row) => ({ + cells: row.cells.filter((_, index) => index !== props.index), + })), + }; + + props.editor.updateBlock(props.block, { + type: "table", + content, + } as PartialBlock); + }}> + Delete column + +); + +export const DeleteButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & { orientation: "row" | "column" } +) => + props.orientation === "row" ? ( + + ) : ( + + ); diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/DefaultTableHandleMenu.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultTableHandleMenu.tsx new file mode 100644 index 0000000000..28b418abdd --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/DefaultTableHandleMenu.tsx @@ -0,0 +1,33 @@ +import { DefaultBlockSchema } from "@blocknote/core"; +import { TableHandleMenu, TableHandleMenuProps } from "./TableHandleMenu"; +import { AddButton } from "./DefaultButtons/AddButton"; +import { DeleteButton } from "./DefaultButtons/DeleteButton"; + +export const DefaultTableHandleMenu = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps +) => ( + + + + + +); diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx new file mode 100644 index 0000000000..24e649aa5c --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenu.tsx @@ -0,0 +1,33 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + SpecificBlock, +} from "@blocknote/core"; +import { Menu, createStyles } from "@mantine/core"; +import { ReactNode } from "react"; + +export type TableHandleMenuProps< + BSchema extends { table: DefaultBlockSchema["table"] } +> = { + orientation: "row" | "column"; + editor: BlockNoteEditor; + block: SpecificBlock< + { table: DefaultBlockSchema["table"] }, + "table", + any, + any + >; + index: number; +}; + +export const TableHandleMenu = (props: { children: ReactNode }) => { + const { classes } = createStyles({ root: {} })(undefined, { + name: "TableHandleMenu", + }); + + return ( + + {props.children} + + ); +}; diff --git a/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenuItem.tsx b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenuItem.tsx new file mode 100644 index 0000000000..05518f027a --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandleMenu/TableHandleMenuItem.tsx @@ -0,0 +1,9 @@ +import { Menu, MenuItemProps } from "@mantine/core"; +import { PolymorphicComponentProps } from "@mantine/utils"; + +export const TableHandleMenuItem = ( + props: PolymorphicComponentProps<"button"> & MenuItemProps +) => { + const { children, ...remainingProps } = props; + return {children}; +}; diff --git a/packages/react/src/TableHandles/components/TableHandlePositioner.tsx b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx new file mode 100644 index 0000000000..78f6102a36 --- /dev/null +++ b/packages/react/src/TableHandles/components/TableHandlePositioner.tsx @@ -0,0 +1,218 @@ +import { + BlockFromConfigNoChildren, + BlockNoteEditor, + BlockSchemaWithBlock, + DefaultBlockSchema, + InlineContentSchema, + StyleSchema, + TableHandlesProsemirrorPlugin, + TableHandlesState, +} from "@blocknote/core"; +import Tippy, { tippy } from "@tippyjs/react"; +import { DragEvent, FC, useEffect, useMemo, useRef, useState } from "react"; +import { DragHandleMenuProps } from "../../SideMenu/components/DragHandleMenu/DragHandleMenu"; +import { DefaultTableHandle } from "./DefaultTableHandle"; + +export type TableHandleProps< + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema +> = Pick< + TableHandlesProsemirrorPlugin, + "dragEnd" | "freezeHandles" | "unfreezeHandles" +> & + Omit< + TableHandlesState, + | "rowIndex" + | "colIndex" + | "referencePosCell" + | "referencePosTable" + | "show" + | "draggingState" + > & { + orientation: "row" | "column"; + editor: BlockNoteEditor< + BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> + >; + tableHandleMenu?: FC>; + dragStart: (e: DragEvent) => void; + index: number; + showOtherSide: () => void; + hideOtherSide: () => void; + }; + +export const TableHandlesPositioner = < + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema +>(props: { + editor: BlockNoteEditor; + tableHandle?: FC>; +}) => { + const [show, setShow] = useState(false); + const [hideRow, setHideRow] = useState(false); + const [hideCol, setHideCol] = useState(false); + const [block, setBlock] = + useState>(); + + const [rowIndex, setRowIndex] = useState(); + const [colIndex, setColIndex] = useState(); + + const [draggedCellOrientation, setDraggedCellOrientation] = useState< + "row" | "col" | undefined + >(undefined); + const [mousePos, setMousePos] = useState(); + + const [_, setForceUpdate] = useState(0); + + const referencePosCell = useRef(); + const referencePosTable = useRef(); + + useEffect(() => { + tippy.setDefaultProps({ maxWidth: "" }); + + return props.editor.tableHandles!.onUpdate((state) => { + // console.log("update", state.draggingState); + setShow(state.show); + setBlock(state.block); + setRowIndex(state.rowIndex); + setColIndex(state.colIndex); + + if (state.draggingState) { + setDraggedCellOrientation(state.draggingState.draggedCellOrientation); + setMousePos(state.draggingState.mousePos); + } else { + setDraggedCellOrientation(undefined); + setMousePos(undefined); + } + + setForceUpdate(Math.random()); + + referencePosCell.current = state.referencePosCell; + referencePosTable.current = state.referencePosTable; + }); + }, [props.editor]); + + const getReferenceClientRectRow = useMemo( + () => { + if (!referencePosCell.current || !referencePosTable.current) { + return undefined; + } + + if (draggedCellOrientation === "row") { + return () => + new DOMRect( + referencePosTable.current!.x, + mousePos!, + referencePosTable.current!.width, + 0 + ); + } + + return () => + new DOMRect( + referencePosTable.current!.x, + referencePosCell.current!.y, + referencePosTable.current!.width, + referencePosCell.current!.height + ); + }, + [referencePosTable.current, draggedCellOrientation, mousePos] // eslint-disable-line + ); + + const getReferenceClientRectColumn = useMemo( + () => { + if (!referencePosCell.current || !referencePosTable.current) { + return undefined; + } + + if (draggedCellOrientation === "col") { + return () => + new DOMRect( + mousePos!, + referencePosTable.current!.y, + 0, + referencePosTable.current!.height + ); + } + + return () => + new DOMRect( + referencePosCell.current!.x, + referencePosTable.current!.y, + referencePosCell.current!.width, + referencePosTable.current!.height + ); + }, + [referencePosTable.current, draggedCellOrientation, mousePos] // eslint-disable-line + ); + + const columnTableHandle = useMemo(() => { + const TableHandle = props.tableHandle || DefaultTableHandle; + + return ( + setHideRow(false)} + hideOtherSide={() => setHideRow(true)} + /> + ); + }, [block, props.editor, props.tableHandle, colIndex]); + + const rowTableHandle = useMemo(() => { + const TableHandle = props.tableHandle || DefaultTableHandle; + + return ( + setHideCol(false)} + hideOtherSide={() => setHideCol(true)} + /> + ); + }, [block, props.editor, props.tableHandle, rowIndex]); + + return ( + <> + + + + ); +}; + +const rowOffset: [number, number] = [0, -12]; +const columnOffset: [number, number] = [0, -12]; diff --git a/packages/react/src/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/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/test/__snapshots__/reactCustomParagraph/basic/external.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/external.html new file mode 100644 index 0000000000..2971f11056 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/external.html @@ -0,0 +1 @@ +

            Hello World

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

            React Custom Paragraph

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

            Hello World

            Hello World

            Hello World

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html 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/test/__snapshots__/reactCustomParagraph/styled/external.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/external.html new file mode 100644 index 0000000000..2971f11056 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/external.html @@ -0,0 +1 @@ +

            Hello World

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html 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/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html new file mode 100644 index 0000000000..ef4a1496c0 --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html @@ -0,0 +1 @@ +

            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/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html new file mode 100644 index 0000000000..fdc04d2f52 --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/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__/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 fc88926715..41e980486e 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -5,8 +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: { @@ -33,4 +47,4 @@ export default defineConfig({ }, }, }, -}); +})); diff --git a/packages/react/vitestSetup.ts b/packages/react/vitestSetup.ts new file mode 100644 index 0000000000..78f5b890bf --- /dev/null +++ b/packages/react/vitestSetup.ts @@ -0,0 +1,9 @@ +import { beforeEach, afterEach } from "vitest"; + +beforeEach(() => { + (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; +}); + +afterEach(() => { + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +}); 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, diff --git a/tests/end-to-end/copypaste/copypaste.test.ts b/tests/end-to-end/copypaste/copypaste.test.ts index ff7ced83c6..f4563a2c23 100644 --- a/tests/end-to-end/copypaste/copypaste.test.ts +++ b/tests/end-to-end/copypaste/copypaste.test.ts @@ -158,9 +158,11 @@ test.describe("Check Copy/Paste Functionality", () => { await page.click(`img`); - await page.waitForSelector(`[class*="resizeHandle"][style*="right"]`); + await page.waitForSelector( + `[class*="bn-image-resize-handle"][style*="right"]` + ); const resizeHandle = page.locator( - `[class*="resizeHandle"][style*="right"]` + `[class*="bn-image-resize-handle"][style*="right"]` ); const resizeHandleBoundingBox = await resizeHandle.boundingBox(); await page.mouse.move( diff --git a/tests/end-to-end/images/images.test.ts b/tests/end-to-end/images/images.test.ts index 8ab2b1cc4b..b4ed873c4d 100644 --- a/tests/end-to-end/images/images.test.ts +++ b/tests/end-to-end/images/images.test.ts @@ -70,9 +70,11 @@ test.describe("Check Image Block and Toolbar functionality", () => { await page.click(`img`); - await page.waitForSelector(`[class*="resizeHandle"][style*="right"]`); + await page.waitForSelector( + `[class*="bn-image-resize-handle"][style*="right"]` + ); const resizeHandle = page.locator( - `[class*="resizeHandle"][style*="right"]` + `[class*="bn-image-resize-handle"][style*="right"]` ); const resizeHandleBoundingBox = await resizeHandle.boundingBox(); await page.mouse.move( diff --git a/tests/end-to-end/images/images.test.ts-snapshots/createImage-chromium-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/createImage-chromium-linux.json index 3d3e6a4be4..0148fe1624 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/createImage-chromium-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/createImage-chromium-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/createImage-firefox-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/createImage-firefox-linux.json index 3d3e6a4be4..0148fe1624 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/createImage-firefox-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/createImage-firefox-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/createImage-webkit-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/createImage-webkit-linux.json index 3d3e6a4be4..0148fe1624 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/createImage-webkit-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/createImage-webkit-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-chromium-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/dragImage-chromium-linux.json index 3463182de8..3be5ef59cd 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-chromium-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/dragImage-chromium-linux.json @@ -39,7 +39,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-firefox-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/dragImage-firefox-linux.json index 3463182de8..3be5ef59cd 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-firefox-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/dragImage-firefox-linux.json @@ -39,7 +39,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-webkit-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/dragImage-webkit-linux.json index 3463182de8..3be5ef59cd 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-webkit-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/dragImage-webkit-linux.json @@ -39,7 +39,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-chromium-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/embed-image-chromium-linux.png index 37d7509161..421ebe7a6b 100644 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-chromium-linux.png and b/tests/end-to-end/images/images.test.ts-snapshots/embed-image-chromium-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-firefox-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/embed-image-firefox-linux.png index 02ef127097..6b79a453a9 100644 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-firefox-linux.png and b/tests/end-to-end/images/images.test.ts-snapshots/embed-image-firefox-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-webkit-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/embed-image-webkit-linux.png index b963f66e9e..aa9796c9d6 100644 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-webkit-linux.png and b/tests/end-to-end/images/images.test.ts-snapshots/embed-image-webkit-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-chromium-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/embedImage-chromium-linux.json index 8d66de1c8d..50458ba61e 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-chromium-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/embedImage-chromium-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-firefox-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/embedImage-firefox-linux.json index 8d66de1c8d..50458ba61e 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-firefox-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/embedImage-firefox-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-webkit-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/embedImage-webkit-linux.json index 8d66de1c8d..50458ba61e 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-webkit-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/embedImage-webkit-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-chromium-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/resize-image-chromium-linux.png index 0f1c17d87a..f90690e393 100644 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-chromium-linux.png and b/tests/end-to-end/images/images.test.ts-snapshots/resize-image-chromium-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-firefox-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/resize-image-firefox-linux.png index 86c38f7231..bf2ce7bf13 100644 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-firefox-linux.png and b/tests/end-to-end/images/images.test.ts-snapshots/resize-image-firefox-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-webkit-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/resize-image-webkit-linux.png index 16915dada8..7f8d760889 100644 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-webkit-linux.png and b/tests/end-to-end/images/images.test.ts-snapshots/resize-image-webkit-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-chromium-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-chromium-linux.json index 318cb5cca9..58d8752142 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-chromium-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-chromium-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 462 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-firefox-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-firefox-linux.json index 318cb5cca9..58d8752142 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-firefox-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-firefox-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 462 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-webkit-linux.json b/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-webkit-linux.json index 318cb5cca9..58d8752142 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-webkit-linux.json +++ b/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-webkit-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 462 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-chromium-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/upload-image-chromium-linux.png index 68d47fba75..acdb3a6d88 100644 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-chromium-linux.png and b/tests/end-to-end/images/images.test.ts-snapshots/upload-image-chromium-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-firefox-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/upload-image-firefox-linux.png index f24f9a043c..3e4a497c7c 100644 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-firefox-linux.png and b/tests/end-to-end/images/images.test.ts-snapshots/upload-image-firefox-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-webkit-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/upload-image-webkit-linux.png index 09de9b0b87..baa871c0bb 100644 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-webkit-linux.png and b/tests/end-to-end/images/images.test.ts-snapshots/upload-image-webkit-linux.png differ diff --git a/tests/utils/customblocks/Image.tsx b/tests/utils/customblocks/Image.tsx index 68fd3193b3..cf426d38e0 100644 --- a/tests/utils/customblocks/Image.tsx +++ b/tests/utils/customblocks/Image.tsx @@ -16,6 +16,7 @@ export const Image = createBlockSpec({ image.setAttribute("src", block.props.src); image.setAttribute("contenteditable", "false"); image.setAttribute("style", "width: 100%"); + image.setAttribute("alt", "Image"); const caption = document.createElement("div"); caption.setAttribute("style", "flex-grow: 1"); @@ -30,6 +31,18 @@ export const Image = createBlockSpec({ contentDOM: caption, }; }, + parse: (element) => { + if (element.hasAttribute("src")) { + return { + type: "image", + props: { + src: element.getAttribute("src")!, + }, + }; + } + + return; + }, }); export const insertImage = { diff --git a/tests/utils/customblocks/ReactImage.tsx b/tests/utils/customblocks/ReactImage.tsx index 6d18f6e97f..735f592b82 100644 --- a/tests/utils/customblocks/ReactImage.tsx +++ b/tests/utils/customblocks/ReactImage.tsx @@ -30,7 +30,7 @@ export const ReactImage = createReactBlockSpec({ alt={"Image"} contentEditable={false} /> - +

    • ); },