diff --git a/docs/pages/docs/editor-api/_meta.json b/docs/pages/docs/editor-api/_meta.json
index 7d69575259..0ac558b794 100644
--- a/docs/pages/docs/editor-api/_meta.json
+++ b/docs/pages/docs/editor-api/_meta.json
@@ -1,6 +1,7 @@
{
- "manipulating-blocks":"",
- "manipulating-inline-content":"",
- "cursor-selections":"",
- "converting-blocks":""
+ "manipulating-blocks": "",
+ "manipulating-inline-content": "",
+ "cursor-selections": "",
+ "converting-blocks": "",
+ "server-processing": ""
}
diff --git a/docs/pages/docs/editor-api/converting-blocks.mdx b/docs/pages/docs/editor-api/converting-blocks.mdx
index 26f4b9fc95..44c9f5f3d4 100644
--- a/docs/pages/docs/editor-api/converting-blocks.mdx
+++ b/docs/pages/docs/editor-api/converting-blocks.mdx
@@ -63,9 +63,30 @@ Tries to create `Block` and `InlineContent` objects based on Markdown syntax, th
-## HTML
+## Export HTML (for static rendering)
-The editor exposes functions to convert Blocks to and from HTML. Converting Blocks to HTML will lose some information such as the nesting of nodes in order to export a simple HTML structure.
+Use `blocksToFullHTML` to export the entire document with all structure, styles and formatting.
+The exported HTML is the same as BlockNote would use to render the editor, and includes all structure for nested blocks.
+
+For example, you an use this for static rendering documents that have been created in the editor.
+Make sure to include the same stylesheets when you want to render the output HTML ([see example](/examples/backend/rendering-static-documents)).
+
+```typescript
+blocksToFullHTML(blocks?: Block[]): string;
+
+// Usage
+const HTMLFromBlocks = editor.blocksToFullHTML(blocks);
+```
+
+`blocks:` The blocks to convert. If not provided, the entire document (all top-level blocks) is used.
+
+`returns:` The blocks, exported to an HTML string.
+
+## HTML (for interoperability)
+
+The editor exposes functions to convert Blocks to and from HTML for interoperability with other applications.
+
+Converting Blocks to HTML this way will lose some information such as the nesting of nodes in order to export a simple HTML structure.
### Converting Blocks to HTML
diff --git a/docs/pages/docs/editor-api/server-processing.mdx b/docs/pages/docs/editor-api/server-processing.mdx
new file mode 100644
index 0000000000..b0a086fee8
--- /dev/null
+++ b/docs/pages/docs/editor-api/server-processing.mdx
@@ -0,0 +1,59 @@
+---
+title: Server-side processing
+description: Use `ServerBlockNoteEditor` to process Blocks on the server.
+imageTitle: Server-side processing
+path: /docs/server-side-processing
+---
+
+import { Example } from "@/components/example";
+import { Callout } from "nextra/components";
+
+# Server-side processing
+
+While you can use the `BlockNoteEditor` on the client side,
+you can also use `ServerBlockNoteEditor` from `@blocknote/server-util` to process BlockNote documents on the server.
+
+For example, use the following code to convert a BlockNote document to HTML on the server:
+
+```tsx
+import { ServerBlockNoteEditor } from "@blocknote/server-util";
+
+const editor = ServerBlockNoteEditor.create();
+const html = await editor.blocksToFullHTML(blocks);
+```
+
+`ServerBlockNoteEditor.create` takes the same BlockNoteEditorOptions as `useCreateBlockNote` and `BlockNoteEditor.create` ([see docs](/docs/editor-basics/setup)),
+so you can pass the same configuration (for example, your custom schema) to your server-side BlockNote editor as on the client.
+
+## Functions for converting blocks
+
+`ServerBlockNoteEditor` exposes the same functions for [converting blocks as the client side editor](/docs/converting-blocks):
+
+- `blocksToFullHTML`
+- `blocksToHTMLLossy` and `tryParseHTMLToBlocks`
+- `blocksToMarkdownLossy` and `tryParseMarkdownToBlocks`
+
+## Yjs processing
+
+Additionally, `ServerBlockNoteEditor` provides functions for processing Yjs documents in case you use Yjs collaboration:
+
+- `yDocToBlocks` or `yXmlFragmentToBlocks`: use this to convert a Yjs document or XML Fragment to BlockNote blocks
+- `blocksToYDoc` or `blocksToYXmlFragment`: use this to convert a BlockNote document (blocks) to a Yjs document or XML Fragment
+
+## React compatibility
+
+If you use [custom schemas in React](/docs/custom-schemas), you can use the same schema on the server side.
+Functions like `blocksToFullHTML` will use your custom React rendering functions to export blocks to HTML, similar to how these functions work on the client.
+However, it could be that your React components require access to a React context (e.g. a theme or localization context).
+
+For these use-cases, we provide a function `withReactContext` that allows you to pass a React context to the server-side editor.
+This example exports a BlockNote document to HTML within a React context `YourContext`, so that even Custom Blocks built in React that require `YourContext` will be exported correctly:
+
+```tsx
+const html = await editor.withReactContext(
+ ({ children }) => (
+ {children}
+ ),
+ async () => editor.blocksToFullHTML(blocks),
+);
+```
diff --git a/examples/02-backend/04-rendering-static-documents/.bnexample.json b/examples/02-backend/04-rendering-static-documents/.bnexample.json
new file mode 100644
index 0000000000..a4986d761b
--- /dev/null
+++ b/examples/02-backend/04-rendering-static-documents/.bnexample.json
@@ -0,0 +1,9 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "yousefed",
+ "tags": ["server"],
+ "dependencies": {
+ "@blocknote/server-util": "latest"
+ }
+}
diff --git a/examples/02-backend/04-rendering-static-documents/App.tsx b/examples/02-backend/04-rendering-static-documents/App.tsx
new file mode 100644
index 0000000000..d00bc691fb
--- /dev/null
+++ b/examples/02-backend/04-rendering-static-documents/App.tsx
@@ -0,0 +1,60 @@
+import "@blocknote/core/fonts/inter.css";
+import "@blocknote/core/style.css";
+
+/**
+ On Server Side, you can use the ServerBlockNoteEditor to render BlockNote documents to HTML. e.g.:
+
+ import { ServerBlockNoteEditor } from "@blocknote/server-util";
+
+ const editor = ServerBlockNoteEditor.create();
+ const html = await editor.blocksToFullHTML(document);
+
+You can then use render this HTML as a static page or send it to the client. Make sure to include the editor stylesheets:
+
+ import "@blocknote/core/fonts/inter.css";
+ import "@blocknote/core/style.css";
+
+This example has the HTML hard-coded, but shows at least how the document will be rendered when the appropriate style sheets are loaded.
+ */
+
+export default function App() {
+ // This HTML is generated by the ServerBlockNoteEditor.blocksToFullHTML method
+ const html = `
+`;
+
+ // Renders the editor instance using a React component.
+ return (
+
+ );
+}
diff --git a/examples/02-backend/04-rendering-static-documents/README.md b/examples/02-backend/04-rendering-static-documents/README.md
new file mode 100644
index 0000000000..f5a2967676
--- /dev/null
+++ b/examples/02-backend/04-rendering-static-documents/README.md
@@ -0,0 +1,7 @@
+# Rendering static documents
+
+This example shows how you can use HTML exported using the `blocksToFullHTML` and render it as a static document (a view-only document, without the editor). You can use this for example if you use BlockNote to edit blog posts in a CMS, but want to display non-editable static, published pages to end-users.
+
+**Relevant Docs:**
+
+- [Server-side processing](/docs/editor-api/server-processing)
diff --git a/examples/02-backend/04-rendering-static-documents/index.html b/examples/02-backend/04-rendering-static-documents/index.html
new file mode 100644
index 0000000000..b7b949fc86
--- /dev/null
+++ b/examples/02-backend/04-rendering-static-documents/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Rendering static documents
+
+
+
+
+
+
diff --git a/examples/02-backend/04-rendering-static-documents/main.tsx b/examples/02-backend/04-rendering-static-documents/main.tsx
new file mode 100644
index 0000000000..f88b490fbd
--- /dev/null
+++ b/examples/02-backend/04-rendering-static-documents/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+
+);
diff --git a/examples/02-backend/04-rendering-static-documents/package.json b/examples/02-backend/04-rendering-static-documents/package.json
new file mode 100644
index 0000000000..c345338a7c
--- /dev/null
+++ b/examples/02-backend/04-rendering-static-documents/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@blocknote/example-rendering-static-documents",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint . --max-warnings 0"
+ },
+ "dependencies": {
+ "@blocknote/core": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/ariakit": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/shadcn": "latest",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "@blocknote/server-util": "latest"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.9",
+ "@vitejs/plugin-react": "^4.0.4",
+ "eslint": "^8.10.0",
+ "vite": "^4.4.8"
+ },
+ "eslintConfig": {
+ "extends": [
+ "../../../.eslintrc.js"
+ ]
+ },
+ "eslintIgnore": [
+ "dist"
+ ]
+}
\ No newline at end of file
diff --git a/examples/02-backend/04-rendering-static-documents/tsconfig.json b/examples/02-backend/04-rendering-static-documents/tsconfig.json
new file mode 100644
index 0000000000..1bd8ab3c57
--- /dev/null
+++ b/examples/02-backend/04-rendering-static-documents/tsconfig.json
@@ -0,0 +1,36 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": [
+ "DOM",
+ "DOM.Iterable",
+ "ESNext"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true
+ },
+ "include": [
+ "."
+ ],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/examples/02-backend/04-rendering-static-documents/vite.config.ts b/examples/02-backend/04-rendering-static-documents/vite.config.ts
new file mode 100644
index 0000000000..f62ab20bc2
--- /dev/null
+++ b/examples/02-backend/04-rendering-static-documents/vite.config.ts
@@ -0,0 +1,32 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite";
+// import eslintPlugin from "vite-plugin-eslint";
+// https://vitejs.dev/config/
+export default defineConfig((conf) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ ? {}
+ : ({
+ // Comment out the lines below to load a built version of blocknote
+ // or, keep as is to load live from sources with live reload working
+ "@blocknote/core": path.resolve(
+ __dirname,
+ "../../packages/core/src/"
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../packages/react/src/"
+ ),
+ } as any),
+ },
+}));
diff --git a/package-lock.json b/package-lock.json
index 38d60815d5..1c9c1380b2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3366,6 +3366,10 @@
"resolved": "packages/react",
"link": true
},
+ "node_modules/@blocknote/server-util": {
+ "resolved": "packages/server-util",
+ "link": true
+ },
"node_modules/@blocknote/shadcn": {
"resolved": "packages/shadcn",
"link": true
@@ -9203,7 +9207,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
- "dev": true,
"engines": {
"node": ">= 10"
}
@@ -9358,6 +9361,17 @@
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="
},
+ "node_modules/@types/jsdom": {
+ "version": "21.1.7",
+ "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz",
+ "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/tough-cookie": "*",
+ "parse5": "^7.0.0"
+ }
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -9496,6 +9510,12 @@
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
+ "node_modules/@types/tough-cookie": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
+ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+ "dev": true
+ },
"node_modules/@types/unist": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
@@ -10471,8 +10491,7 @@
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
- "deprecated": "Use your platform's native atob() and btoa() methods instead",
- "dev": true
+ "deprecated": "Use your platform's native atob() and btoa() methods instead"
},
"node_modules/abbrev": {
"version": "1.1.1",
@@ -10495,7 +10514,6 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
"integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
- "dev": true,
"dependencies": {
"acorn": "^8.1.0",
"acorn-walk": "^8.0.2"
@@ -10536,7 +10554,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
- "dev": true,
"dependencies": {
"debug": "4"
},
@@ -10982,8 +10999,7 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "dev": true
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/at-least-node": {
"version": "1.0.0",
@@ -12096,7 +12112,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -12460,7 +12475,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz",
"integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==",
- "dev": true,
"dependencies": {
"rrweb-cssom": "^0.6.0"
},
@@ -12949,7 +12963,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz",
"integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==",
- "dev": true,
"dependencies": {
"abab": "^2.0.6",
"whatwg-mimetype": "^3.0.0",
@@ -12963,7 +12976,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
- "dev": true,
"dependencies": {
"punycode": "^2.3.0"
},
@@ -12975,7 +12987,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
- "dev": true,
"engines": {
"node": ">=12"
}
@@ -12984,7 +12995,6 @@
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz",
"integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==",
- "dev": true,
"dependencies": {
"tr46": "^4.1.1",
"webidl-conversions": "^7.0.0"
@@ -13126,8 +13136,7 @@
"node_modules/decimal.js": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
- "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
- "dev": true
+ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA=="
},
"node_modules/decode-named-character-reference": {
"version": "1.0.2",
@@ -13232,7 +13241,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -13353,7 +13361,6 @@
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
"integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
"deprecated": "Use your platform's native DOMException instead",
- "dev": true,
"dependencies": {
"webidl-conversions": "^7.0.0"
},
@@ -13365,7 +13372,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
- "dev": true,
"engines": {
"node": ">=12"
}
@@ -13779,7 +13785,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
- "dev": true,
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
@@ -14425,7 +14430,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
- "dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14803,7 +14807,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
- "dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -16514,7 +16517,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
"integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
- "dev": true,
"dependencies": {
"whatwg-encoding": "^2.0.0"
},
@@ -16555,7 +16557,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
- "dev": true,
"dependencies": {
"@tootallnate/once": "2",
"agent-base": "6",
@@ -16569,7 +16570,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
- "dev": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
@@ -17346,8 +17346,7 @@
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
- "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
- "dev": true
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
},
"node_modules/is-reference": {
"version": "3.0.2",
@@ -17719,7 +17718,6 @@
"version": "21.1.2",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-21.1.2.tgz",
"integrity": "sha512-sCpFmK2jv+1sjff4u7fzft+pUh2KSUbUrEHYHyfSIbGTIcmnjyp83qg6qLwdJ/I3LpTXx33ACxeRL7Lsyc6lGQ==",
- "dev": true,
"dependencies": {
"abab": "^2.0.6",
"acorn": "^8.8.2",
@@ -17764,7 +17762,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
- "dev": true,
"dependencies": {
"punycode": "^2.3.0"
},
@@ -17776,7 +17773,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
- "dev": true,
"engines": {
"node": ">=12"
}
@@ -17785,7 +17781,6 @@
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz",
"integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==",
- "dev": true,
"dependencies": {
"tr46": "^4.1.1",
"webidl-conversions": "^7.0.0"
@@ -20880,6 +20875,28 @@
}
}
},
+ "node_modules/node-fetch/node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "dev": true
+ },
+ "node_modules/node-fetch/node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "dev": true
+ },
+ "node_modules/node-fetch/node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "dev": true,
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
"node_modules/node-gyp": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz",
@@ -21348,8 +21365,7 @@
"node_modules/nwsapi": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",
- "integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==",
- "dev": true
+ "integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ=="
},
"node_modules/nx": {
"version": "15.9.7",
@@ -23546,8 +23562,7 @@
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
- "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
- "dev": true
+ "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag=="
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -23579,8 +23594,7 @@
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
- "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
- "dev": true
+ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"node_modules/queue-microtask": {
"version": "1.2.3",
@@ -24815,8 +24829,7 @@
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
- "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
- "dev": true
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/resolve": {
"version": "1.22.8",
@@ -25007,8 +25020,7 @@
"node_modules/rrweb-cssom": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz",
- "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==",
- "dev": true
+ "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw=="
},
"node_modules/run-async": {
"version": "2.4.1",
@@ -25154,7 +25166,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
- "dev": true,
"dependencies": {
"xmlchars": "^2.2.0"
},
@@ -25971,8 +25982,7 @@
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
- "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
- "dev": true
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
},
"node_modules/system-architecture": {
"version": "0.1.0",
@@ -26510,7 +26520,6 @@
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
- "dev": true,
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
@@ -26525,17 +26534,10 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
- "dev": true,
"engines": {
"node": ">= 4.0.0"
}
},
- "node_modules/tr46": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
- "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
- "dev": true
- },
"node_modules/treeverse": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/treeverse/-/treeverse-2.0.0.tgz",
@@ -27631,7 +27633,6 @@
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
- "dev": true,
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@@ -28129,7 +28130,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
"integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
- "dev": true,
"dependencies": {
"xml-name-validator": "^4.0.0"
},
@@ -28179,12 +28179,6 @@
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz",
"integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA=="
},
- "node_modules/webidl-conversions": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
- "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
- "dev": true
- },
"node_modules/webpack": {
"version": "5.92.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.0.tgz",
@@ -28299,7 +28293,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
"integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
- "dev": true,
"dependencies": {
"iconv-lite": "0.6.3"
},
@@ -28311,7 +28304,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
- "dev": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -28323,21 +28315,10 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
- "dev": true,
"engines": {
"node": ">=12"
}
},
- "node_modules/whatwg-url": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
- "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
- "dev": true,
- "dependencies": {
- "tr46": "~0.0.3",
- "webidl-conversions": "^3.0.0"
- }
- },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -28807,7 +28788,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
"integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
- "dev": true,
"engines": {
"node": ">=12"
}
@@ -28815,8 +28795,7 @@
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
- "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
- "dev": true
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
},
"node_modules/xtend": {
"version": "4.0.2",
@@ -29261,6 +29240,39 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "packages/server-util": {
+ "name": "@blocknote/server-util",
+ "version": "0.14.6",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@blocknote/core": "^0.14.2",
+ "@blocknote/react": "^0.14.2",
+ "@tiptap/core": "^2.4.0",
+ "@tiptap/pm": "^2.4.0",
+ "jsdom": "^21.1.0",
+ "react": "^18",
+ "react-dom": "^18",
+ "y-prosemirror": "1.2.5",
+ "y-protocols": "^1.0.6",
+ "yjs": "^13.6.15"
+ },
+ "devDependencies": {
+ "@types/jsdom": "^21.1.6",
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.9",
+ "eslint": "^8.10.0",
+ "prettier": "^2.7.1",
+ "rollup-plugin-webpack-stats": "^0.2.2",
+ "typescript": "^5.0.4",
+ "vite": "^4.4.8",
+ "vite-plugin-eslint": "^1.8.1",
+ "vitest": "^0.34.1"
+ },
+ "peerDependencies": {
+ "react": "^18",
+ "react-dom": "^18"
+ }
+ },
"packages/shadcn": {
"name": "@blocknote/shadcn",
"version": "0.14.6",
@@ -29337,6 +29349,7 @@
"@blocknote/core": "^0.14.5",
"@blocknote/mantine": "^0.14.6",
"@blocknote/react": "^0.14.6",
+ "@blocknote/server-util": "^0.14.6",
"@blocknote/shadcn": "^0.14.6",
"@liveblocks/client": "^1.10.0",
"@liveblocks/yjs": "^1.10.0",
diff --git a/packages/core/src/api/exporters/copyExtension.ts b/packages/core/src/api/exporters/copyExtension.ts
index e226767d12..6f0acedab4 100644
--- a/packages/core/src/api/exporters/copyExtension.ts
+++ b/packages/core/src/api/exporters/copyExtension.ts
@@ -1,13 +1,13 @@
import { Extension } from "@tiptap/core";
-import { NodeSelection, Plugin } from "prosemirror-state";
import { Node } from "prosemirror-model";
+import { NodeSelection, Plugin } from "prosemirror-state";
+import { EditorView } from "prosemirror-view";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor";
import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema";
import { createExternalHTMLExporter } from "./html/externalHTMLExporter";
import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer";
import { cleanHTMLToMarkdown } from "./markdown/markdownExporter";
-import { EditorView } from "prosemirror-view";
function selectedFragmentToHTML<
BSchema extends BlockSchema,
@@ -27,15 +27,19 @@ function selectedFragmentToHTML<
view.state.schema,
editor
);
- const internalHTML =
- internalHTMLSerializer.serializeProseMirrorFragment(selectedFragment);
+ const internalHTML = internalHTMLSerializer.serializeProseMirrorFragment(
+ selectedFragment,
+ {}
+ );
const externalHTMLExporter = createExternalHTMLExporter(
view.state.schema,
editor
);
- const externalHTML =
- externalHTMLExporter.exportProseMirrorFragment(selectedFragment);
+ const externalHTML = externalHTMLExporter.exportProseMirrorFragment(
+ selectedFragment,
+ {}
+ );
const plainText = cleanHTMLToMarkdown(externalHTML);
diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts
index 8e9ef52f44..cbeb0a83d6 100644
--- a/packages/core/src/api/exporters/html/externalHTMLExporter.ts
+++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts
@@ -37,8 +37,14 @@ export interface ExternalHTMLExporter<
I extends InlineContentSchema,
S extends StyleSchema
> {
- exportBlocks: (blocks: PartialBlock[]) => string;
- exportProseMirrorFragment: (fragment: Fragment) => string;
+ exportBlocks: (
+ blocks: PartialBlock[],
+ options: { document?: Document }
+ ) => string;
+ exportProseMirrorFragment: (
+ fragment: Fragment,
+ options: { document?: Document }
+ ) => string;
}
export const createExternalHTMLExporter = <
@@ -54,8 +60,14 @@ export const createExternalHTMLExporter = <
node: Node,
options: { document?: Document }
) => HTMLElement;
- exportProseMirrorFragment: (fragment: Fragment) => string;
- exportBlocks: (blocks: PartialBlock[]) => string;
+ exportProseMirrorFragment: (
+ fragment: Fragment,
+ options: { document?: Document }
+ ) => string;
+ exportBlocks: (
+ blocks: PartialBlock[],
+ options: { document?: Document }
+ ) => string;
};
serializer.serializeNodeInner = (
@@ -66,7 +78,7 @@ export const createExternalHTMLExporter = <
// 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) => {
+ serializer.exportProseMirrorFragment = (fragment, options) => {
const externalHTML = unified()
.use(rehypeParse, { fragment: true })
.use(simplifyBlocks, {
@@ -77,18 +89,24 @@ export const createExternalHTMLExporter = <
]),
})
.use(rehypeStringify)
- .processSync(serializeProseMirrorFragment(fragment, serializer));
+ .processSync(serializeProseMirrorFragment(fragment, serializer, options));
return externalHTML.value as string;
};
- serializer.exportBlocks = (blocks: PartialBlock[]) => {
+ serializer.exportBlocks = (
+ blocks: PartialBlock[],
+ options
+ ) => {
const nodes = blocks.map((block) =>
blockToNode(block, schema, editor.schema.styleSchema)
);
const blockGroup = schema.nodes["blockGroup"].create(null, nodes);
- return serializer.exportProseMirrorFragment(Fragment.from(blockGroup));
+ return serializer.exportProseMirrorFragment(
+ Fragment.from(blockGroup),
+ options
+ );
};
return serializer;
diff --git a/packages/core/src/api/exporters/html/htmlConversion.test.ts b/packages/core/src/api/exporters/html/htmlConversion.test.ts
index b3e9c1332b..4f9504e559 100644
--- a/packages/core/src/api/exporters/html/htmlConversion.test.ts
+++ b/packages/core/src/api/exporters/html/htmlConversion.test.ts
@@ -25,11 +25,8 @@ async function convertToHTMLAndCompareSnapshots<
snapshotName: string
) {
addIdsToBlocks(blocks);
- const serializer = createInternalHTMLSerializer(
- editor._tiptapEditor.schema,
- editor
- );
- const internalHTML = serializer.serializeBlocks(blocks);
+ const serializer = createInternalHTMLSerializer(editor.pmSchema, editor);
+ const internalHTML = serializer.serializeBlocks(blocks, {});
const internalHTMLSnapshotPath =
"./__snapshots__/" +
snapshotDirectory +
@@ -48,11 +45,8 @@ async function convertToHTMLAndCompareSnapshots<
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 exporter = createExternalHTMLExporter(editor.pmSchema, editor);
+ const externalHTML = exporter.exportBlocks(blocks, {});
const externalHTMLSnapshotPath =
"./__snapshots__/" +
snapshotDirectory +
@@ -76,6 +70,14 @@ describe("Test HTML conversion", () => {
const div = document.createElement("div");
beforeEach(() => {
editor = testCase.createEditor();
+
+ // Note that we don't necessarily need to mount a root
+ // Currently, we do mount to a root so that it reflects the "production" use-case more closely.
+
+ // However, it would be nice to increased converage and share the same set of tests for these cases:
+ // - does render to a root
+ // - does not render to a root
+ // - runs in server (jsdom) environment using server-util
editor.mount(div);
});
@@ -184,11 +186,11 @@ describe("Test ProseMirror fragment edge case conversion", () => {
const copiedFragment =
editor._tiptapEditor.state.selection.content().content;
- const exporter = createExternalHTMLExporter(
- editor._tiptapEditor.schema,
- editor
+ const exporter = createExternalHTMLExporter(editor.pmSchema, editor);
+ const externalHTML = exporter.exportProseMirrorFragment(
+ copiedFragment,
+ {}
);
- const externalHTML = exporter.exportProseMirrorFragment(copiedFragment);
expect(externalHTML).toMatchFileSnapshot(
"./__snapshots_fragment_edge_cases__/" +
"selectionWithinBlockChildren.html"
@@ -207,11 +209,11 @@ describe("Test ProseMirror fragment edge case conversion", () => {
const copiedFragment =
editor._tiptapEditor.state.selection.content().content;
- const exporter = createExternalHTMLExporter(
- editor._tiptapEditor.schema,
- editor
+ const exporter = createExternalHTMLExporter(editor.pmSchema, editor);
+ const externalHTML = exporter.exportProseMirrorFragment(
+ copiedFragment,
+ {}
);
- const externalHTML = exporter.exportProseMirrorFragment(copiedFragment);
expect(externalHTML).toMatchFileSnapshot(
"./__snapshots_fragment_edge_cases__/" +
"selectionLeavesBlockChildren.html"
@@ -229,11 +231,11 @@ describe("Test ProseMirror fragment edge case conversion", () => {
const copiedFragment =
editor._tiptapEditor.state.selection.content().content;
- const exporter = createExternalHTMLExporter(
- editor._tiptapEditor.schema,
- editor
+ const exporter = createExternalHTMLExporter(editor.pmSchema, editor);
+ const externalHTML = exporter.exportProseMirrorFragment(
+ copiedFragment,
+ {}
);
- const externalHTML = exporter.exportProseMirrorFragment(copiedFragment);
expect(externalHTML).toMatchFileSnapshot(
"./__snapshots_fragment_edge_cases__/" +
"selectionSpansBlocksChildren.html"
diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
index 7d7f409011..d1357774b8 100644
--- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
+++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
@@ -30,8 +30,14 @@ export interface InternalHTMLSerializer<
> {
// 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;
+ serializeProseMirrorFragment: (
+ fragment: Fragment,
+ options: { document?: Document }
+ ) => string;
+ serializeBlocks: (
+ blocks: PartialBlock[],
+ options: { document?: Document }
+ ) => string;
}
export const createInternalHTMLSerializer = <
@@ -47,7 +53,10 @@ export const createInternalHTMLSerializer = <
node: Node,
options: { document?: Document }
) => HTMLElement;
- serializeBlocks: (blocks: PartialBlock[]) => string;
+ serializeBlocks: (
+ blocks: PartialBlock[],
+ options: { document?: Document }
+ ) => string;
serializeProseMirrorFragment: (
fragment: Fragment,
options?: { document?: Document | undefined } | undefined,
@@ -60,16 +69,22 @@ export const createInternalHTMLSerializer = <
options: { document?: Document }
) => serializeNodeInner(node, options, serializer, editor, false);
- serializer.serializeProseMirrorFragment = (fragment: Fragment) =>
- serializeProseMirrorFragment(fragment, serializer);
+ serializer.serializeProseMirrorFragment = (fragment: Fragment, options) =>
+ serializeProseMirrorFragment(fragment, serializer, options);
- serializer.serializeBlocks = (blocks: PartialBlock[]) => {
+ serializer.serializeBlocks = (
+ blocks: PartialBlock[],
+ options
+ ) => {
const nodes = blocks.map((block) =>
blockToNode(block, schema, editor.schema.styleSchema)
);
const blockGroup = schema.nodes["blockGroup"].create(null, nodes);
- return serializer.serializeProseMirrorFragment(Fragment.from(blockGroup));
+ return serializer.serializeProseMirrorFragment(
+ Fragment.from(blockGroup),
+ options
+ );
};
return serializer;
diff --git a/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts
index 7a1d0748a0..3e0c6f9cf2 100644
--- a/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts
+++ b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts
@@ -119,9 +119,10 @@ export const serializeNodeInner = <
// returns a string instead, to make it easier to use.
export const serializeProseMirrorFragment = (
fragment: Fragment,
- serializer: DOMSerializer
+ serializer: DOMSerializer,
+ options?: { document?: Document }
) => {
- const internalHTML = serializer.serializeFragment(fragment);
+ const internalHTML = serializer.serializeFragment(fragment, options);
const parent = document.createElement("div");
parent.appendChild(internalHTML);
diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.ts b/packages/core/src/api/exporters/markdown/markdownExporter.ts
index 5e86c064f3..c9ec348c9e 100644
--- a/packages/core/src/api/exporters/markdown/markdownExporter.ts
+++ b/packages/core/src/api/exporters/markdown/markdownExporter.ts
@@ -4,7 +4,7 @@ import rehypeRemark from "rehype-remark";
import remarkGfm from "remark-gfm";
import remarkStringify from "remark-stringify";
import { unified } from "unified";
-import { Block } from "../../../blocks/defaultBlocks";
+import { PartialBlock } from "../../../blocks/defaultBlocks";
import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema";
import { createExternalHTMLExporter } from "../html/externalHTMLExporter";
@@ -29,12 +29,13 @@ export function blocksToMarkdown<
I extends InlineContentSchema,
S extends StyleSchema
>(
- blocks: Block[],
+ blocks: PartialBlock[],
schema: Schema,
- editor: BlockNoteEditor
+ editor: BlockNoteEditor,
+ options: { document?: Document }
): string {
const exporter = createExternalHTMLExporter(schema, editor);
- const externalHTML = exporter.exportBlocks(blocks);
+ const externalHTML = exporter.exportBlocks(blocks, options);
return cleanHTMLToMarkdown(externalHTML);
}
diff --git a/packages/core/src/api/nodeConversions/nodeConversions.test.ts b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
index d9478ff6bb..f07f7426e6 100644
--- a/packages/core/src/api/nodeConversions/nodeConversions.test.ts
+++ b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
@@ -3,10 +3,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor";
import { PartialBlock } from "../../blocks/defaultBlocks";
+import { customBlocksTestCases } from "../testUtil/cases/customBlocks";
import { customInlineContentTestCases } from "../testUtil/cases/customInlineContent";
import { customStylesTestCases } from "../testUtil/cases/customStyles";
import { defaultSchemaTestCases } from "../testUtil/cases/defaultSchema";
-import { customBlocksTestCases } from "../testUtil/cases/customBlocks";
import {
addIdsToBlock,
partialBlockToBlockForTesting,
@@ -18,11 +18,7 @@ function validateConversion(
editor: BlockNoteEditor
) {
addIdsToBlock(block);
- const node = blockToNode(
- block,
- editor._tiptapEditor.schema,
- editor.schema.styleSchema
- );
+ const node = blockToNode(block, editor.pmSchema, editor.schema.styleSchema);
expect(node).toMatchSnapshot();
@@ -56,6 +52,13 @@ describe("Test BlockNote-Prosemirror conversion", () => {
beforeEach(() => {
editor = testCase.createEditor();
+ // Note that we don't necessarily need to mount a root
+ // Currently, we do mount to a root so that it reflects the "production" use-case more closely.
+
+ // However, it would be nice to increased converage and share the same set of tests for these cases:
+ // - does render to a root
+ // - does not render to a root
+ // - runs in server (jsdom) environment using server-util
editor.mount(div);
});
diff --git a/packages/core/src/blocks/defaultBlockHelpers.ts b/packages/core/src/blocks/defaultBlockHelpers.ts
index 7519beed85..b85e161102 100644
--- a/packages/core/src/blocks/defaultBlockHelpers.ts
+++ b/packages/core/src/blocks/defaultBlockHelpers.ts
@@ -67,12 +67,9 @@ export const defaultBlockToHTML = <
dom: HTMLElement;
contentDOM?: HTMLElement;
} => {
- const node = blockToNode(
- block,
- editor._tiptapEditor.schema,
- editor.schema.styleSchema
- ).firstChild!;
- const toDOM = editor._tiptapEditor.schema.nodes[node.type.name].spec.toDOM;
+ const node = blockToNode(block, editor.pmSchema, editor.schema.styleSchema)
+ .firstChild!;
+ const toDOM = editor.pmSchema.nodes[node.type.name].spec.toDOM;
if (toDOM === undefined) {
throw new Error(
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index 8232db27d3..d74d4f7001 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -1,5 +1,5 @@
-import { EditorOptions, Extension } from "@tiptap/core";
-import { Node } from "prosemirror-model";
+import { EditorOptions, Extension, getSchema } from "@tiptap/core";
+import { Node, Schema } from "prosemirror-model";
// import "./blocknote.css";
import * as Y from "yjs";
import {
@@ -61,12 +61,12 @@ import {
BlockNoteTipTapEditorOptions,
} from "./BlockNoteTipTapEditor";
-// CSS
import { PlaceholderPlugin } from "../extensions/Placeholder/PlaceholderPlugin";
import { Dictionary } from "../i18n/dictionary";
import { en } from "../i18n/locales";
-import "./Block.css";
-import "./editor.css";
+
+import { createInternalHTMLSerializer } from "../api/exporters/html/internalHTMLSerializer";
+import "../style.css";
export type BlockNoteEditorOptions<
BSchema extends BlockSchema,
@@ -109,7 +109,11 @@ export type BlockNoteEditorOptions<
schema: BlockNoteSchema;
/**
- * A custom function to handle file uploads.
+ * The `uploadFile` method is what the editor uses when files need to be uploaded (for example when selecting an image to upload).
+ * This method should set when creating the editor as this is application-specific.
+ *
+ * `undefined` means the application doesn't support file uploads.
+ *
* @param file The file that should be uploaded.
* @returns The URL of the uploaded file OR an object containing props that should be set on the file block (such as an id)
*/
@@ -151,6 +155,15 @@ export type BlockNoteEditorOptions<
_tiptapOptions: Partial;
trailingBlock?: boolean;
+
+ /**
+ * Boolean indicating whether the editor is in headless mode.
+ * Headless mode means we can use features like importing / exporting blocks,
+ * but there's no underlying editor (UI) instantiated.
+ *
+ * You probably don't need to set this manually, but use the `server-util` package instead that uses this option internally
+ */
+ _headless: boolean;
};
const blockNoteTipTapOptions = {
@@ -164,12 +177,44 @@ export class BlockNoteEditor<
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
SSchema extends StyleSchema = DefaultStyleSchema
> {
- public readonly _tiptapEditor: BlockNoteTipTapEditor & {
- contentComponent: any;
- };
+ private readonly _pmSchema: Schema;
+
+ /**
+ * Boolean indicating whether the editor is in headless mode.
+ * Headless mode means we can use features like importing / exporting blocks,
+ * but there's no underlying editor (UI) instantiated.
+ *
+ * You probably don't need to set this manually, but use the `server-util` package instead that uses this option internally
+ */
+ public readonly headless: boolean = false;
+
+ public readonly _tiptapEditor:
+ | BlockNoteTipTapEditor & {
+ contentComponent: any;
+ } = undefined as any; // TODO: Type should actually reflect that it can be `undefined` in headless mode
+
+ /**
+ * Used by React to store a reference to an `ElementRenderer` helper utility to make sure we can render React elements
+ * in the correct context (used by `ReactRenderUtil`)
+ */
+ public elementRenderer: ((node: any, container: HTMLElement) => void) | null =
+ null;
+
+ /**
+ * Cache of all blocks. This makes sure we don't have to "recompute" blocks if underlying Prosemirror Nodes haven't changed.
+ * This is especially useful when we want to keep track of the same block across multiple operations,
+ * with this cache, blocks stay the same object reference (referential equality with ===).
+ */
public blockCache = new WeakMap>();
+
+ /**
+ * The dictionary contains translations for the editor.
+ */
public readonly dictionary: Dictionary;
+ /**
+ * The schema of the editor. The schema defines which Blocks, InlineContent, and Styles are available in the editor.
+ */
public readonly schema: BlockNoteSchema;
public readonly blockImplementations: BlockSpecs;
@@ -198,12 +243,25 @@ export class BlockNoteEditor<
SSchema
>;
+ /**
+ * The `uploadFile` method is what the editor uses when files need to be uploaded (for example when selecting an image to upload).
+ * This method should set when creating the editor as this is application-specific.
+ *
+ * `undefined` means the application doesn't support file uploads.
+ *
+ * @param file The file that should be uploaded.
+ * @returns The URL of the uploaded file OR an object containing props that should be set on the file block (such as an id)
+ */
public readonly uploadFile:
| ((file: File) => Promise>)
| undefined;
public readonly resolveFileUrl: (url: string) => Promise;
+ public get pmSchema() {
+ return this._pmSchema;
+ }
+
public static create<
BSchema extends BlockSchema = DefaultBlockSchema,
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
@@ -246,6 +304,7 @@ export class BlockNoteEditor<
const newOptions = {
defaultStyles: true,
schema: options.schema || BlockNoteSchema.create(),
+ _headless: false,
...options,
placeholders: {
...this.dictionary.placeholders,
@@ -272,7 +331,6 @@ export class BlockNoteEditor<
const extensions = getBlockNoteExtensions({
editor: this,
domAttributes: newOptions.domAttributes || {},
- blockSchema: this.schema.blockSchema,
blockSpecs: this.schema.blockSpecs,
styleSpecs: this.schema.styleSpecs,
inlineContentSpecs: this.schema.inlineContentSpecs,
@@ -300,6 +358,7 @@ export class BlockNoteEditor<
this.uploadFile = newOptions.uploadFile;
this.resolveFileUrl = newOptions.resolveFileUrl || (async (url) => url);
+ this.headless = newOptions._headless;
if (newOptions.collaboration && newOptions.initialContent) {
// eslint-disable-next-line no-console
@@ -354,22 +413,29 @@ export class BlockNoteEditor<
},
};
- this._tiptapEditor = new BlockNoteTipTapEditor(
- tiptapOptions,
- this.schema.styleSchema
- ) as BlockNoteTipTapEditor & {
- contentComponent: any;
- };
+ if (!this.headless) {
+ this._tiptapEditor = new BlockNoteTipTapEditor(
+ tiptapOptions,
+ this.schema.styleSchema
+ ) as BlockNoteTipTapEditor & {
+ contentComponent: any;
+ };
+ this._pmSchema = this._tiptapEditor.schema;
+ } else {
+ // In headless mode, we don't instantiate an underlying TipTap editor,
+ // but we still need the schema
+ this._pmSchema = getSchema(tiptapOptions.extensions!);
+ }
}
/**
* Mount the editor to a parent DOM element. Call mount(undefined) to clean up
*
- * @warning Not needed for React, use BlockNoteView to take care of this
+ * @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting
*/
- public mount(parentElement?: HTMLElement | null) {
+ public mount = (parentElement?: HTMLElement | null) => {
this._tiptapEditor.mount(parentElement);
- }
+ };
public get prosemirrorView() {
return this._tiptapEditor.view;
@@ -391,7 +457,7 @@ export class BlockNoteEditor<
* @deprecated, use `editor.document` instead
*/
public get topLevelBlocks(): Block[] {
- return this.topLevelBlocks;
+ return this.document;
}
/**
@@ -676,6 +742,12 @@ export class BlockNoteEditor<
* @returns True if the editor is editable, false otherwise.
*/
public get isEditable(): boolean {
+ if (!this._tiptapEditor) {
+ if (!this.headless) {
+ throw new Error("no editor, but also not headless?");
+ }
+ return false;
+ }
return this._tiptapEditor.isEditable;
}
@@ -684,6 +756,13 @@ export class BlockNoteEditor<
* @param editable True to make the editor editable, or false to lock it.
*/
public set isEditable(editable: boolean) {
+ if (!this._tiptapEditor) {
+ if (!this.headless) {
+ throw new Error("no editor, but also not headless?");
+ }
+ // not relevant on headless
+ return;
+ }
if (this._tiptapEditor.options.editable !== editable) {
this._tiptapEditor.setEditable(editable);
}
@@ -749,7 +828,7 @@ export class BlockNoteEditor<
public insertInlineContent(content: PartialInlineContent) {
const nodes = inlineContentToNodes(
content,
- this._tiptapEditor.schema,
+ this.pmSchema,
this.schema.styleSchema
);
@@ -873,7 +952,7 @@ export class BlockNoteEditor<
text = this._tiptapEditor.state.doc.textBetween(from, to);
}
- const mark = this._tiptapEditor.schema.mark("link", { href: url });
+ const mark = this.pmSchema.mark("link", { href: url });
this._tiptapEditor.view.dispatch(
this._tiptapEditor.view.state.tr
@@ -920,23 +999,35 @@ 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
+ * Exports blocks into a simplified HTML string. To better conform to HTML standards, children of blocks which aren't list
* items are un-nested in the output HTML.
+ *
* @param blocks An array of blocks that should be serialized into HTML.
* @returns The blocks, serialized as an HTML string.
*/
public async blocksToHTMLLossy(
- blocks: Block[] = this.document
+ blocks: PartialBlock[] = this.document
): Promise {
- const exporter = createExternalHTMLExporter(
- this._tiptapEditor.schema,
- this
- );
- return exporter.exportBlocks(blocks);
+ const exporter = createExternalHTMLExporter(this.pmSchema, this);
+ return exporter.exportBlocks(blocks, {});
}
+ /**
+ * Serializes blocks into an HTML string in the format that would normally be rendered by the editor.
+ *
+ * Use this method if you want to server-side render HTML (for example, a blog post that has been edited in BlockNote)
+ * and serve it to users without loading the editor on the client (i.e.: displaying the blog post)
+ *
+ * @param blocks An array of blocks that should be serialized into HTML.
+ * @returns The blocks, serialized as an HTML string.
+ */
+ public async blocksToFullHTML(
+ blocks: PartialBlock[]
+ ): Promise {
+ const exporter = createInternalHTMLSerializer(this.pmSchema, this);
+ return exporter.serializeBlocks(blocks, {});
+ }
/**
* Parses blocks from an HTML string. Tries to create `Block` objects out of any HTML block-level elements, and
* `InlineNode` objects from any HTML inline elements, though not all element types are recognized. If BlockNote
@@ -952,7 +1043,7 @@ export class BlockNoteEditor<
this.schema.blockSchema,
this.schema.inlineContentSchema,
this.schema.styleSchema,
- this._tiptapEditor.schema
+ this.pmSchema
);
}
@@ -963,9 +1054,9 @@ export class BlockNoteEditor<
* @returns The blocks, serialized as a Markdown string.
*/
public async blocksToMarkdownLossy(
- blocks: Block[] = this.document
+ blocks: PartialBlock[] = this.document
): Promise {
- return blocksToMarkdown(blocks, this._tiptapEditor.schema, this);
+ return blocksToMarkdown(blocks, this.pmSchema, this, {});
}
/**
@@ -983,7 +1074,7 @@ export class BlockNoteEditor<
this.schema.blockSchema,
this.schema.inlineContentSchema,
this.schema.styleSchema,
- this._tiptapEditor.schema
+ this.pmSchema
);
}
@@ -1008,6 +1099,11 @@ export class BlockNoteEditor<
public onChange(
callback: (editor: BlockNoteEditor) => void
) {
+ if (this.headless) {
+ // Note: would be nice if this is possible in headless mode as well
+ return;
+ }
+
const cb = () => {
callback(this);
};
@@ -1028,6 +1124,10 @@ export class BlockNoteEditor<
public onSelectionChange(
callback: (editor: BlockNoteEditor) => void
) {
+ if (this.headless) {
+ return;
+ }
+
const cb = () => {
callback(this);
};
diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts
index baff586aa7..b64eccdbe9 100644
--- a/packages/core/src/editor/BlockNoteExtensions.ts
+++ b/packages/core/src/editor/BlockNoteExtensions.ts
@@ -40,7 +40,6 @@ export const getBlockNoteExtensions = <
>(opts: {
editor: BlockNoteEditor;
domAttributes: Partial;
- blockSchema: BSchema;
blockSpecs: BlockSpecs;
inlineContentSpecs: InlineContentSpecs;
styleSpecs: StyleSpecs;
diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
index 7ee7eacb9e..2cb5cb9f3e 100644
--- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
+++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
@@ -225,16 +225,18 @@ function dragStart<
}
const selectedSlice = view.state.selection.content();
- const schema = editor._tiptapEditor.schema;
+ const schema = editor.pmSchema;
const internalHTMLSerializer = createInternalHTMLSerializer(schema, editor);
const internalHTML = internalHTMLSerializer.serializeProseMirrorFragment(
- selectedSlice.content
+ selectedSlice.content,
+ {}
);
const externalHTMLExporter = createExternalHTMLExporter(schema, editor);
const externalHTML = externalHTMLExporter.exportProseMirrorFragment(
- selectedSlice.content
+ selectedSlice.content,
+ {}
);
const plainText = cleanHTMLToMarkdown(externalHTML);
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 88b0f6186a..792a2e1b83 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -2,13 +2,14 @@ import * as locales from "./i18n/locales";
export * from "./api/exporters/html/externalHTMLExporter";
export * from "./api/exporters/html/internalHTMLSerializer";
export * from "./api/testUtil";
+export * from "./blocks/AudioBlockContent/AudioBlockContent";
export * from "./blocks/FileBlockContent/FileBlockContent";
export * from "./blocks/ImageBlockContent/ImageBlockContent";
export * from "./blocks/VideoBlockContent/VideoBlockContent";
-export * from "./blocks/AudioBlockContent/AudioBlockContent";
export * from "./blocks/FileBlockContent/fileBlockHelpers";
export * from "./blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
+export { parseImageElement } from "./blocks/ImageBlockContent/imageBlockHelpers";
export * from "./blocks/defaultBlockTypeGuards";
export * from "./blocks/defaultBlocks";
export * from "./blocks/defaultProps";
@@ -25,14 +26,20 @@ export * from "./extensions/SuggestionMenu/DefaultSuggestionItem";
export * from "./extensions/SuggestionMenu/SuggestionPlugin";
export * from "./extensions/SuggestionMenu/getDefaultSlashMenuItems";
export * from "./extensions/TableHandles/TableHandlesPlugin";
+export * from "./i18n/dictionary";
export * from "./schema";
export * from "./util/browser";
export * from "./util/string";
+export * from "./util/typescript";
+export { UnreachableCaseError, assertEmpty } from "./util/typescript";
+export { locales };
+
// for testing from react (TODO: move):
export * from "./api/nodeConversions/nodeConversions";
export * from "./api/testUtil/partialBlockTestUtil";
export * from "./extensions/UniqueID/UniqueID";
-export * from "./i18n/dictionary";
-export { UnreachableCaseError, assertEmpty } from "./util/typescript";
-export { locales };
-export { parseImageElement } from "./blocks/ImageBlockContent/imageBlockHelpers";
+
+// for server-util (TODO: maybe move):
+export * from "./api/exporters/markdown/markdownExporter";
+export * from "./api/parsers/html/parseHTML";
+export * from "./api/parsers/markdown/parseMarkdown";
diff --git a/packages/core/src/style.css b/packages/core/src/style.css
index 4214e795d4..8d073cf1e0 100644
--- a/packages/core/src/style.css
+++ b/packages/core/src/style.css
@@ -1,7 +1,2 @@
-/*
-This is an empty placeholder file, which should NOT contain CSS code.
-It's here so DEV environment doesn't show a 404
-
-- In DEV environment, examples/editor loads this stub file, but actual CSS is loaded from CSS modules directly
-- In PROD environment, the actual dist/style.css file is built from the CSS modules
-*/
+@import url("./editor/Block.css");
+@import url("./editor/editor.css");
diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx
index d76d37240f..414096bac9 100644
--- a/packages/react/src/editor/BlockNoteView.tsx
+++ b/packages/react/src/editor/BlockNoteView.tsx
@@ -11,6 +11,7 @@ import React, {
HTMLAttributes,
ReactNode,
Ref,
+ useCallback,
useEffect,
useMemo,
useState,
@@ -24,6 +25,7 @@ import {
BlockNoteDefaultUIProps,
} from "./BlockNoteDefaultUI";
import { EditorContent } from "./EditorContent";
+import { ElementRenderer } from "./ElementRenderer";
import "./styles.css";
const emptyFn = () => {
@@ -139,27 +141,37 @@ function BlockNoteViewComponent<
};
}, [existingContext, editor]);
+ const setElementRenderer = useCallback(
+ (ref: (typeof editor)["elementRenderer"]) => {
+ editor.elementRenderer = ref;
+ },
+ [editor]
+ );
+
return (
-
-
+
+ {!editor.headless && (
+
- {renderChildren}
-
-
+ className={mergeCSSClasses(
+ "bn-container",
+ editorColorScheme || "",
+ className || ""
+ )}
+ data-color-scheme={editorColorScheme}
+ {...rest}
+ ref={ref}>
+
+ {renderChildren}
+
+
+ )}
);
}
diff --git a/packages/react/src/editor/EditorContent.tsx b/packages/react/src/editor/EditorContent.tsx
index c44d92a3d4..1bd6c9accb 100644
--- a/packages/react/src/editor/EditorContent.tsx
+++ b/packages/react/src/editor/EditorContent.tsx
@@ -1,7 +1,7 @@
import { BlockNoteEditor } from "@blocknote/core";
import { ReactRenderer } from "@tiptap/react";
import { useEffect, useState } from "react";
-import { createPortal, flushSync } from "react-dom";
+import { createPortal } from "react-dom";
const Portals: React.FC<{ renderers: Record }> = ({
renderers,
@@ -26,7 +26,6 @@ export function EditorContent(props: {
children: any;
}) {
const [renderers, setRenderers] = useState>({});
- const [singleRenderData, setSingleRenderData] = useState();
useEffect(() => {
props.editor._tiptapEditor.contentComponent = {
@@ -49,18 +48,6 @@ export function EditorContent(props: {
return nextRenderers;
});
},
-
- /**
- * Render a single node to a container within the React context (used by BlockNote renderToDOMSpec)
- */
- renderToElement(node: React.ReactNode, container: HTMLElement) {
- flushSync(() => {
- setSingleRenderData({ node, container });
- });
-
- // clear after it's been rendered to `container`
- setSingleRenderData(undefined);
- },
};
// Without queueMicrotask, custom IC / styles will give a React FlushSync error
queueMicrotask(() => {
@@ -74,8 +61,6 @@ export function EditorContent(props: {
return (
<>
- {singleRenderData &&
- createPortal(singleRenderData.node, singleRenderData.container)}
{props.children}
>
);
diff --git a/packages/react/src/editor/ElementRenderer.tsx b/packages/react/src/editor/ElementRenderer.tsx
new file mode 100644
index 0000000000..aedcc55ed5
--- /dev/null
+++ b/packages/react/src/editor/ElementRenderer.tsx
@@ -0,0 +1,37 @@
+import { forwardRef, useImperativeHandle, useState } from "react";
+import { createPortal, flushSync } from "react-dom";
+
+/**
+ * A helper component to render a single element to a container so we can subsequently read the DOM / HTML contents
+ *
+ * This is useful so we can render arbitrary React elements (blocks) in the correct context (used by `ReactRenderUtil`)
+ */
+export const ElementRenderer = forwardRef<
+ (node: React.ReactNode, container: HTMLElement) => void
+>((_props, ref) => {
+ const [singleRenderData, setSingleRenderData] = useState<
+ { node: React.ReactNode; container: HTMLElement } | undefined
+ >();
+
+ useImperativeHandle(
+ ref,
+ () => {
+ return (node: React.ReactNode, container: HTMLElement) => {
+ flushSync(() => {
+ setSingleRenderData({ node, container });
+ });
+
+ // clear after it's been rendered to `container`
+ setSingleRenderData(undefined);
+ };
+ },
+ []
+ );
+
+ return (
+ <>
+ {singleRenderData &&
+ createPortal(singleRenderData.node, singleRenderData.container)}
+ >
+ );
+});
diff --git a/packages/react/src/schema/@util/ReactRenderUtil.ts b/packages/react/src/schema/@util/ReactRenderUtil.ts
index 83561b61d0..81c4e8bae9 100644
--- a/packages/react/src/schema/@util/ReactRenderUtil.ts
+++ b/packages/react/src/schema/@util/ReactRenderUtil.ts
@@ -10,12 +10,11 @@ export function renderToDOMSpec(
const div = document.createElement("div");
let root: Root | undefined;
- const { _tiptapEditor } = editor || {};
- if (_tiptapEditor?.contentComponent) {
- // Render temporarily using `EditorContent` (which is stored somewhat hacky on `_tiptapEditor.contentComponent`)
+ if (editor?.elementRenderer) {
+ // Render temporarily using `ElementRenderer`
// This way React Context will still work, as `fc` will be rendered inside the existing React tree
- _tiptapEditor.contentComponent.renderToElement(
+ editor.elementRenderer(
fc((el) => (contentDOM = el || undefined)),
div
);
diff --git a/packages/react/src/test/htmlConversion.test.tsx b/packages/react/src/test/htmlConversion.test.tsx
index 69d301d3d8..79408bb2dd 100644
--- a/packages/react/src/test/htmlConversion.test.tsx
+++ b/packages/react/src/test/htmlConversion.test.tsx
@@ -14,13 +14,13 @@ import {
import { flushSync } from "react-dom";
import { Root, createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { BlockNoteViewRaw } from "../editor/BlockNoteView";
import {
TestContext,
customReactBlockSchemaTestCases,
} from "./testCases/customReactBlocks";
import { customReactInlineContentTestCases } from "./testCases/customReactInlineContent";
import { customReactStylesTestCases } from "./testCases/customReactStyles";
-import { BlockNoteViewRaw } from "../editor/BlockNoteView";
// TODO: code same from @blocknote/core, maybe create separate test util package
async function convertToHTMLAndCompareSnapshots<
@@ -34,11 +34,8 @@ async function convertToHTMLAndCompareSnapshots<
snapshotName: string
) {
addIdsToBlocks(blocks);
- const serializer = createInternalHTMLSerializer(
- editor._tiptapEditor.schema,
- editor
- );
- const internalHTML = serializer.serializeBlocks(blocks);
+ const serializer = createInternalHTMLSerializer(editor.pmSchema, editor);
+ const internalHTML = serializer.serializeBlocks(blocks, {});
const internalHTMLSnapshotPath =
"./__snapshots__/" +
snapshotDirectory +
@@ -57,11 +54,8 @@ async function convertToHTMLAndCompareSnapshots<
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 exporter = createExternalHTMLExporter(editor.pmSchema, editor);
+ const externalHTML = exporter.exportBlocks(blocks, {});
const externalHTMLSnapshotPath =
"./__snapshots__/" +
snapshotDirectory +
@@ -81,7 +75,13 @@ describe("Test React HTML conversion", () => {
for (const testCase of testCases) {
describe("Case: " + testCase.name, () => {
let editor: BlockNoteEditor;
- // TODO: Why do we need to render for unit tests?
+ // Note that we don't necessarily need to mount a root (unless we need a React Context)
+ // Currently, we do mount to a root so that it reflects the "production" use-case more closely.
+
+ // However, it would be nice to increased converage and share the same set of tests for these cases:
+ // - does render to a root
+ // - does not render to a root
+ // - runs in server (jsdom) environment using server-util
let root: Root;
const div = document.createElement("div");
diff --git a/packages/react/src/test/nodeConversion.test.tsx b/packages/react/src/test/nodeConversion.test.tsx
index 347f7447cf..fd58496c4d 100644
--- a/packages/react/src/test/nodeConversion.test.tsx
+++ b/packages/react/src/test/nodeConversion.test.tsx
@@ -29,11 +29,7 @@ function validateConversion(
editor: BlockNoteEditor
) {
addIdsToBlock(block);
- const node = blockToNode(
- block,
- editor._tiptapEditor.schema,
- editor.schema.styleSchema
- );
+ const node = blockToNode(block, editor.pmSchema, editor.schema.styleSchema);
expect(node).toMatchSnapshot();
@@ -62,7 +58,13 @@ describe("Test React BlockNote-Prosemirror conversion", () => {
for (const testCase of testCases) {
describe("Case: " + testCase.name, () => {
let editor: BlockNoteEditor;
- // TODO: Why do we need to render for unit tests?
+ // Note that we don't necessarily need to mount a root (unless we need a React Context)
+ // Currently, we do mount to a root so that it reflects the "production" use-case more closely.
+
+ // However, it would be nice to increased converage and share the same set of tests for these cases:
+ // - does render to a root
+ // - does not render to a root
+ // - runs in server (jsdom) environment using server-util
let root: Root;
const div = document.createElement("div");
diff --git a/packages/react/src/test/testCases/customReactBlocks.tsx b/packages/react/src/test/testCases/customReactBlocks.tsx
index 53882ace31..fd2577f358 100644
--- a/packages/react/src/test/testCases/customReactBlocks.tsx
+++ b/packages/react/src/test/testCases/customReactBlocks.tsx
@@ -10,9 +10,9 @@ import {
} from "@blocknote/core";
import { createContext, useContext } from "react";
-import { createReactBlockSpec } from "../../schema/ReactBlockSpec";
import { ReactFileBlock } from "../../blocks/FileBlockContent/FileBlockContent";
import { ReactImageBlock } from "../../blocks/ImageBlockContent/ImageBlockContent";
+import { createReactBlockSpec } from "../../schema/ReactBlockSpec";
const ReactCustomParagraph = createReactBlockSpec(
{
diff --git a/packages/server-util/.gitignore b/packages/server-util/.gitignore
new file mode 100644
index 0000000000..a547bf36d8
--- /dev/null
+++ b/packages/server-util/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/packages/server-util/package.json b/packages/server-util/package.json
new file mode 100644
index 0000000000..42d5a6c61f
--- /dev/null
+++ b/packages/server-util/package.json
@@ -0,0 +1,87 @@
+{
+ "name": "@blocknote/server-util",
+ "homepage": "https://github.com/TypeCellOS/BlockNote",
+ "private": true,
+ "license": "MPL-2.0",
+ "version": "0.14.6",
+ "files": [
+ "dist",
+ "types",
+ "src"
+ ],
+ "keywords": [
+ "react",
+ "javascript",
+ "editor",
+ "typescript",
+ "prosemirror",
+ "wysiwyg",
+ "rich-text-editor",
+ "notion",
+ "yjs",
+ "block-based",
+ "tiptap"
+ ],
+ "description": "A \"Notion-style\" block-based extensible text editor built on top of Prosemirror and Tiptap.",
+ "type": "module",
+ "source": "src/index.ts",
+ "types": "./types/src/index.d.ts",
+ "main": "./dist/blocknote-server-util.umd.cjs",
+ "module": "./dist/blocknote-server-util.js",
+ "exports": {
+ ".": {
+ "types": "./types/src/index.d.ts",
+ "import": "./dist/blocknote-server-util.js",
+ "require": "./dist/blocknote-server-util.umd.cjs"
+ },
+ "./style.css": {
+ "import": "./dist/style.css",
+ "require": "./dist/style.css"
+ }
+ },
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint src --max-warnings 0",
+ "test": "vitest --run",
+ "test-watch": "vitest watch"
+ },
+ "dependencies": {
+ "@blocknote/core": "^0.14.2",
+ "@blocknote/react": "^0.14.2",
+ "@tiptap/pm": "^2.4.0",
+ "@tiptap/core": "^2.4.0",
+ "jsdom": "^21.1.0",
+ "y-prosemirror": "1.2.5",
+ "y-protocols": "^1.0.6",
+ "yjs": "^13.6.15",
+ "react": "^18",
+ "react-dom": "^18"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.9",
+ "@types/jsdom": "^21.1.6",
+ "eslint": "^8.10.0",
+ "prettier": "^2.7.1",
+ "rollup-plugin-webpack-stats": "^0.2.2",
+ "typescript": "^5.0.4",
+ "vite": "^4.4.8",
+ "vite-plugin-eslint": "^1.8.1",
+ "vitest": "^0.34.1"
+ },
+ "peerDependencies": {
+ "react": "^18",
+ "react-dom": "^18"
+ },
+ "eslintConfig": {
+ "extends": [
+ "../../.eslintrc.js"
+ ]
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "gitHead": "37614ab348dcc7faa830a9a88437b37197a2162d"
+}
diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.test.ts b/packages/server-util/src/context/ServerBlockNoteEditor.test.ts
new file mode 100644
index 0000000000..1b511594aa
--- /dev/null
+++ b/packages/server-util/src/context/ServerBlockNoteEditor.test.ts
@@ -0,0 +1,118 @@
+import { Block } from "@blocknote/core";
+import { describe, expect, it } from "vitest";
+import * as Y from "yjs";
+import { ServerBlockNoteEditor } from "./ServerBlockNoteEditor";
+
+describe("Test ServerBlockNoteEditor", () => {
+ const editor = ServerBlockNoteEditor.create();
+ const blocks: Block[] = [
+ {
+ id: "1",
+ 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: "2",
+ type: "paragraph",
+ props: {
+ backgroundColor: "red",
+ textAlignment: "left",
+ textColor: "default",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Paragraph",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "3",
+ type: "bulletListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "list item",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ],
+ },
+ ];
+
+ it("converts to and from prosemirror (doc)", async () => {
+ const node = await editor._blocksToProsemirrorNode(blocks);
+ const blockOutput = await editor._prosemirrorNodeToBlocks(node);
+ expect(blockOutput).toEqual(blocks);
+ });
+
+ it("converts to and from yjs (doc)", async () => {
+ const ydoc = await editor.blocksToYDoc(blocks);
+ const blockOutput = await editor.yDocToBlocks(ydoc);
+ expect(blockOutput).toEqual(blocks);
+ });
+
+ it("converts to and from yjs (fragment)", async () => {
+ const fragment = await editor.blocksToYXmlFragment(blocks);
+
+ // fragment needs to be part of a Y.Doc before we can use other operations on it
+ const doc = new Y.Doc();
+ doc.getMap().set("prosemirror", fragment);
+
+ const blockOutput = await editor.yXmlFragmentToBlocks(fragment);
+ expect(blockOutput).toEqual(blocks);
+ });
+
+ it("converts to and from HTML (blocksToHTMLLossy)", async () => {
+ const html = await editor.blocksToHTMLLossy(blocks);
+ expect(html).toMatchSnapshot();
+
+ const blockOutput = await editor.tryParseHTMLToBlocks(html);
+ expect(blockOutput).toMatchSnapshot();
+ });
+
+ it("converts to HTML (blocksToFullHTML)", async () => {
+ const html = await editor.blocksToFullHTML(blocks);
+ expect(html).toMatchSnapshot();
+ });
+
+ it("converts to and from markdown (blocksToMarkdownLossy)", async () => {
+ const md = await editor.blocksToMarkdownLossy(blocks);
+ expect(md).toMatchSnapshot();
+
+ const blockOutput = await editor.tryParseMarkdownToBlocks(md);
+ expect(blockOutput).toMatchSnapshot();
+ });
+});
diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.ts b/packages/server-util/src/context/ServerBlockNoteEditor.ts
new file mode 100644
index 0000000000..1308574520
--- /dev/null
+++ b/packages/server-util/src/context/ServerBlockNoteEditor.ts
@@ -0,0 +1,346 @@
+import {
+ Block,
+ BlockNoteEditor,
+ BlockNoteEditorOptions,
+ BlockSchema,
+ DefaultBlockSchema,
+ DefaultInlineContentSchema,
+ DefaultStyleSchema,
+ InlineContentSchema,
+ PartialBlock,
+ StyleSchema,
+ blockToNode,
+ blocksToMarkdown,
+ createExternalHTMLExporter,
+ createInternalHTMLSerializer,
+ nodeToBlock,
+} from "@blocknote/core";
+import { BlockNoteViewRaw } from "@blocknote/react";
+import { Node } from "@tiptap/pm/model";
+import * as jsdom from "jsdom";
+import * as React from "react";
+import { createElement } from "react";
+import { flushSync } from "react-dom";
+import { createRoot } from "react-dom/client";
+import {
+ prosemirrorToYDoc,
+ prosemirrorToYXmlFragment,
+ yXmlFragmentToProseMirrorRootNode,
+} from "y-prosemirror";
+import type * as Y from "yjs";
+
+/**
+ * Use the ServerBlockNoteEditor to interact with BlockNote documents in a server (nodejs) environment.
+ */
+export class ServerBlockNoteEditor<
+ BSchema extends BlockSchema = DefaultBlockSchema,
+ ISchema extends InlineContentSchema = DefaultInlineContentSchema,
+ SSchema extends StyleSchema = DefaultStyleSchema
+> {
+ /**
+ * Internal BlockNoteEditor (not recommended to use directly, use the methods of this class instead)
+ */
+ public readonly editor: BlockNoteEditor;
+
+ /**
+ * We currently use a JSDOM instance to mock document and window methods
+ *
+ * A possible improvement could be to make this:
+ * a) pluggable so other shims can be used as well
+ * b) obsolete, but for this all blocks should be React based and we need to remove all references to document / window
+ * from the core / react package. (and even then, it's likely some custom blocks would still use document / window methods)
+ */
+ private jsdom = new jsdom.JSDOM();
+
+ /**
+ * Calls a function with mocking window and document using JSDOM
+ *
+ * We could make this obsolete by passing in a document / window object to the render / serialize methods of Blocks
+ */
+ public async _withJSDOM(fn: () => Promise) {
+ const prevWindow = globalThis.window;
+ const prevDocument = globalThis.document;
+ globalThis.document = this.jsdom.window.document;
+ (globalThis as any).window = this.jsdom.window;
+ (globalThis as any).window.__TEST_OPTIONS = (
+ prevWindow as any
+ )?.__TEST_OPTIONS;
+ try {
+ return await fn();
+ } finally {
+ globalThis.document = prevDocument;
+ globalThis.window = prevWindow;
+ }
+ }
+
+ public static create<
+ BSchema extends BlockSchema = DefaultBlockSchema,
+ ISchema extends InlineContentSchema = DefaultInlineContentSchema,
+ SSchema extends StyleSchema = DefaultStyleSchema
+ >(options: Partial> = {}) {
+ return new ServerBlockNoteEditor(options) as ServerBlockNoteEditor<
+ BSchema,
+ ISchema,
+ SSchema
+ >;
+ }
+
+ private constructor(options: Partial>) {
+ this.editor = BlockNoteEditor.create({
+ ...options,
+ _headless: true,
+ });
+ }
+
+ /** PROSEMIRROR / BLOCKNOTE conversions */
+
+ /**
+ * Turn Prosemirror JSON to BlockNote style JSON
+ * @param json Prosemirror JSON
+ * @returns BlockNote style JSON
+ */
+ public _prosemirrorNodeToBlocks(pmNode: Node) {
+ const blocks: Block[] = [];
+
+ // note, this code is similar to editor.document
+ pmNode.firstChild!.descendants((node) => {
+ blocks.push(
+ nodeToBlock(
+ node,
+ this.editor.schema.blockSchema,
+ this.editor.schema.inlineContentSchema,
+ this.editor.schema.styleSchema
+ )
+ );
+
+ return false;
+ });
+
+ return blocks;
+ }
+
+ /**
+ * Turn Prosemirror JSON to BlockNote style JSON
+ * @param json Prosemirror JSON
+ * @returns BlockNote style JSON
+ */
+ public _prosemirrorJSONToBlocks(json: any) {
+ // note: theoretically this should also be possible without creating prosemirror nodes,
+ // but this is definitely the easiest way
+ const doc = this.editor.pmSchema.nodeFromJSON(json);
+ return this._prosemirrorNodeToBlocks(doc);
+ }
+
+ /**
+ * Turn BlockNote JSON to Prosemirror node / state
+ * @param blocks BlockNote blocks
+ * @returns Prosemirror root node
+ */
+ public _blocksToProsemirrorNode(
+ blocks: PartialBlock[]
+ ) {
+ const pmNodes = blocks.map((b) =>
+ blockToNode(b, this.editor.pmSchema, this.editor.schema.styleSchema)
+ );
+
+ const doc = this.editor.pmSchema.topNodeType.create(
+ null,
+ this.editor.pmSchema.nodes["blockGroup"].create(null, pmNodes)
+ );
+ return doc;
+ }
+
+ /** YJS / BLOCKNOTE conversions */
+
+ /**
+ * Turn a Y.XmlFragment collaborative doc into a BlockNote document (BlockNote style JSON of all blocks)
+ * @returns BlockNote document (BlockNote style JSON of all blocks)
+ */
+ public yXmlFragmentToBlocks(xmlFragment: Y.XmlFragment) {
+ const pmNode = yXmlFragmentToProseMirrorRootNode(
+ xmlFragment,
+ this.editor.pmSchema
+ );
+ return this._prosemirrorNodeToBlocks(pmNode);
+ }
+
+ /**
+ * Convert blocks to a Y.XmlFragment
+ *
+ * This can be used when importing existing content to Y.Doc for the first time,
+ * note that this should not be used to rehydrate a Y.Doc from a database once
+ * collaboration has begun as all history will be lost
+ *
+ * @param blocks the blocks to convert
+ * @returns Y.XmlFragment
+ */
+ public blocksToYXmlFragment(
+ blocks: Block[],
+ xmlFragment?: Y.XmlFragment
+ ) {
+ return prosemirrorToYXmlFragment(
+ this._blocksToProsemirrorNode(blocks),
+ xmlFragment
+ );
+ }
+
+ /**
+ * Turn a Y.Doc collaborative doc into a BlockNote document (BlockNote style JSON of all blocks)
+ * @returns BlockNote document (BlockNote style JSON of all blocks)
+ */
+ public yDocToBlocks(ydoc: Y.Doc, xmlFragment = "prosemirror") {
+ return this.yXmlFragmentToBlocks(ydoc.getXmlFragment(xmlFragment));
+ }
+
+ /**
+ * This can be used when importing existing content to Y.Doc for the first time,
+ * note that this should not be used to rehydrate a Y.Doc from a database once
+ * collaboration has begun as all history will be lost
+ *
+ * @param blocks
+ */
+ public blocksToYDoc(
+ blocks: PartialBlock[],
+ xmlFragment = "prosemirror"
+ ) {
+ return prosemirrorToYDoc(
+ this._blocksToProsemirrorNode(blocks),
+ xmlFragment
+ );
+ }
+
+ /** HTML / BLOCKNOTE conversions */
+
+ /**
+ * Exports blocks into a simplified HTML string. To better conform to HTML standards, children of blocks which aren't list
+ * items are un-nested in the output HTML.
+ *
+ * @param blocks An array of blocks that should be serialized into HTML.
+ * @returns The blocks, serialized as an HTML string.
+ */
+ public async blocksToHTMLLossy(
+ blocks: PartialBlock[]
+ ): Promise {
+ return this._withJSDOM(async () => {
+ const exporter = createExternalHTMLExporter(
+ this.editor.pmSchema,
+ this.editor
+ );
+
+ return exporter.exportBlocks(blocks, {
+ document: this.jsdom.window.document,
+ });
+ });
+ }
+
+ /**
+ * Serializes blocks into an HTML string in the format that would normally be rendered by the editor.
+ *
+ * Use this method if you want to server-side render HTML (for example, a blog post that has been edited in BlockNote)
+ * and serve it to users without loading the editor on the client (i.e.: displaying the blog post)
+ *
+ * @param blocks An array of blocks that should be serialized into HTML.
+ * @returns The blocks, serialized as an HTML string.
+ */
+ public async blocksToFullHTML(
+ blocks: PartialBlock[]
+ ): Promise {
+ return this._withJSDOM(async () => {
+ const exporter = createInternalHTMLSerializer(
+ this.editor.pmSchema,
+ this.editor
+ );
+
+ return exporter.serializeBlocks(blocks, {
+ document: this.jsdom.window.document,
+ });
+ });
+ }
+
+ /**
+ * Parses blocks from an HTML string. Tries to create `Block` objects out of any HTML block-level elements, and
+ * `InlineNode` objects from any HTML inline elements, though not all element types are recognized. If BlockNote
+ * doesn't recognize an HTML element's tag, it will parse it as a paragraph or plain text.
+ * @param html The HTML string to parse blocks from.
+ * @returns The blocks parsed from the HTML string.
+ */
+ public async tryParseHTMLToBlocks(
+ html: string
+ ): Promise[]> {
+ return this._withJSDOM(() => {
+ return this.editor.tryParseHTMLToBlocks(html);
+ });
+ }
+
+ /** MARKDOWN / BLOCKNOTE conversions */
+
+ /**
+ * Serializes blocks into a Markdown string. The output is simplified as Markdown does not support all features of
+ * BlockNote - children of blocks which aren't list items are un-nested and certain styles are removed.
+ * @param blocks An array of blocks that should be serialized into Markdown.
+ * @returns The blocks, serialized as a Markdown string.
+ */
+ public async blocksToMarkdownLossy(
+ blocks: PartialBlock[]
+ ): Promise {
+ return this._withJSDOM(async () => {
+ return blocksToMarkdown(blocks, this.editor.pmSchema, this.editor, {
+ document: this.jsdom.window.document,
+ });
+ });
+ }
+
+ /**
+ * Creates a list of blocks from a Markdown string. Tries to create `Block` and `InlineNode` objects based on
+ * Markdown syntax, though not all symbols are recognized. If BlockNote doesn't recognize a symbol, it will parse it
+ * as text.
+ * @param markdown The Markdown string to parse blocks from.
+ * @returns The blocks parsed from the Markdown string.
+ */
+ public async tryParseMarkdownToBlocks(
+ markdown: string
+ ): Promise[]> {
+ return this._withJSDOM(() => {
+ return this.editor.tryParseMarkdownToBlocks(markdown);
+ });
+ }
+
+ /**
+ * If you're using React Context in your blocks, you can use this method to wrap editor calls for importing / exporting / block manipulation
+ * with the React Context Provider.
+ *
+ * Example:
+ *
+ * ```tsx
+ const html = await editor.withReactContext(
+ ({ children }) => (
+ {children}
+ ),
+ async () => editor.blocksToFullHTML(blocks)
+ );
+ */
+ public async withReactContext(comp: React.FC, fn: () => Promise) {
+ return this._withJSDOM(async () => {
+ const tmpRoot = createRoot(
+ this.jsdom.window.document.createElement("div")
+ );
+
+ flushSync(() => {
+ tmpRoot.render(
+ createElement(
+ comp,
+ {},
+ createElement(BlockNoteViewRaw, {
+ editor: this.editor,
+ })
+ )
+ );
+ });
+ try {
+ return await fn();
+ } finally {
+ tmpRoot.unmount();
+ }
+ });
+ }
+}
diff --git a/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap b/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap
new file mode 100644
index 0000000000..66ac15b493
--- /dev/null
+++ b/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap
@@ -0,0 +1,149 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test ServerBlockNoteEditor > converts to HTML (blocksToBlockNoteStyleHTML) 1`] = `""`;
+
+exports[`Test ServerBlockNoteEditor > converts to HTML (blocksToFullHTML) 1`] = `""`;
+
+exports[`Test ServerBlockNoteEditor > converts to and from HTML (blocksToHTMLLossy) 1`] = `"Heading 2
Paragraph
"`;
+
+exports[`Test ServerBlockNoteEditor > converts to and from HTML (blocksToHTMLLossy) 2`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ "underline": true,
+ },
+ "text": "Heading ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ "strike": true,
+ },
+ "text": "2",
+ "type": "text",
+ },
+ ],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "level": 2,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "list item",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "bulletListItem",
+ },
+]
+`;
+
+exports[`Test ServerBlockNoteEditor > converts to and from markdown (blocksToMarkdownLossy) 1`] = `
+"## **Heading ***~~2~~*
+
+Paragraph
+
+* list item
+"
+`;
+
+exports[`Test ServerBlockNoteEditor > converts to and from markdown (blocksToMarkdownLossy) 2`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "**Heading **",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ "strike": true,
+ },
+ "text": "2",
+ "type": "text",
+ },
+ ],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "level": 2,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "list item",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "bulletListItem",
+ },
+]
+`;
diff --git a/packages/server-util/src/context/react/ReactServer.test.tsx b/packages/server-util/src/context/react/ReactServer.test.tsx
new file mode 100644
index 0000000000..d6613cf911
--- /dev/null
+++ b/packages/server-util/src/context/react/ReactServer.test.tsx
@@ -0,0 +1,88 @@
+import {
+ BlockNoteSchema,
+ defaultBlockSpecs,
+ defaultProps,
+} from "@blocknote/core";
+import { createReactBlockSpec } from "@blocknote/react";
+import { createContext, useContext } from "react";
+import { describe, expect, it } from "vitest";
+import { ServerBlockNoteEditor } from "../ServerBlockNoteEditor";
+
+const SimpleReactCustomParagraph = createReactBlockSpec(
+ {
+ type: "simpleReactCustomParagraph",
+ propSchema: defaultProps,
+ content: "inline",
+ },
+ {
+ render: (props) => (
+
+ ),
+ }
+);
+
+export const TestContext = createContext(undefined);
+
+const ReactContextParagraphComponent = (props: any) => {
+ const testData = useContext(TestContext);
+ if (testData === undefined) {
+ throw Error();
+ }
+
+ return ;
+};
+
+const ReactContextParagraph = createReactBlockSpec(
+ {
+ type: "reactContextParagraph",
+ propSchema: defaultProps,
+ content: "inline",
+ },
+ {
+ render: ReactContextParagraphComponent,
+ }
+);
+
+const schema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ simpleReactCustomParagraph: SimpleReactCustomParagraph,
+ reactContextParagraph: ReactContextParagraph,
+ },
+});
+
+describe("Test ServerBlockNoteEditor with React blocks", () => {
+ it("works for simple blocks", async () => {
+ const editor = ServerBlockNoteEditor.create({
+ schema,
+ });
+ const html = await editor.blocksToFullHTML([
+ {
+ type: "simpleReactCustomParagraph",
+ content: "React Custom Paragraph",
+ },
+ ]);
+ expect(html).toMatchSnapshot();
+ });
+
+ it("works for blocks with context", async () => {
+ const editor = ServerBlockNoteEditor.create({
+ schema,
+ });
+
+ const html = await editor.withReactContext(
+ ({ children }) => (
+ {children}
+ ),
+ async () =>
+ editor.blocksToFullHTML([
+ {
+ type: "reactContextParagraph",
+ content: "React Context Paragraph",
+ },
+ ])
+ );
+
+ expect(html).toMatchSnapshot();
+ });
+});
diff --git a/packages/server-util/src/context/react/__snapshots__/ReactServer.test.tsx.snap b/packages/server-util/src/context/react/__snapshots__/ReactServer.test.tsx.snap
new file mode 100644
index 0000000000..c864e23f1e
--- /dev/null
+++ b/packages/server-util/src/context/react/__snapshots__/ReactServer.test.tsx.snap
@@ -0,0 +1,5 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test ServerBlockNoteEditor with React blocks > works for blocks with context 1`] = `""`;
+
+exports[`Test ServerBlockNoteEditor with React blocks > works for simple blocks 1`] = `""`;
diff --git a/packages/server-util/src/index.ts b/packages/server-util/src/index.ts
new file mode 100644
index 0000000000..39dda4f815
--- /dev/null
+++ b/packages/server-util/src/index.ts
@@ -0,0 +1 @@
+export * from "./context/ServerBlockNoteEditor";
diff --git a/packages/server-util/tsconfig.json b/packages/server-util/tsconfig.json
new file mode 100644
index 0000000000..399d4ee6e2
--- /dev/null
+++ b/packages/server-util/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ESNext", "DOM"],
+ "moduleResolution": "Node",
+ "jsx": "react-jsx",
+ "strict": true,
+ "sourceMap": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "noEmit": false,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "outDir": "dist",
+ "declaration": true,
+ "declarationDir": "types",
+ "composite": true,
+ "skipLibCheck": true
+ },
+ "include": ["src"],
+ "references": [
+ {
+ "path": "../core"
+ },
+ {
+ "path": "../react"
+ }
+ ]
+}
diff --git a/packages/server-util/vite.config.ts b/packages/server-util/vite.config.ts
new file mode 100644
index 0000000000..940749ef1c
--- /dev/null
+++ b/packages/server-util/vite.config.ts
@@ -0,0 +1,50 @@
+import * as path from "path";
+import { webpackStats } from "rollup-plugin-webpack-stats";
+import { defineConfig } from "vite";
+import pkg from "./package.json";
+// import eslintPlugin from "vite-plugin-eslint";
+
+const deps = Object.keys(pkg.dependencies);
+
+// https://vitejs.dev/config/
+export default defineConfig((conf) => ({
+ test: {
+ setupFiles: ["./vitestSetup.ts"],
+ },
+ plugins: [webpackStats() as any],
+ // 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/"),
+ "@blocknote/react": path.resolve(__dirname, "../react/src/"),
+ } as Record),
+ },
+ build: {
+ sourcemap: true,
+ lib: {
+ entry: path.resolve(__dirname, "src/index.ts"),
+ name: "blocknote-server-util",
+ fileName: "blocknote-server-util",
+ },
+ rollupOptions: {
+ // make sure to externalize deps that shouldn't be bundled
+ // into your library
+ external: (source: string) => {
+ if (deps.includes(source)) {
+ return true;
+ }
+ return source.startsWith("prosemirror-");
+ },
+ output: {
+ // Provide global variables to use in the UMD build
+ // for externalized deps
+ globals: {},
+ interop: "compat", // https://rollupjs.org/migration/#changed-defaults
+ },
+ },
+ },
+}));
diff --git a/packages/server-util/vitestSetup.ts b/packages/server-util/vitestSetup.ts
new file mode 100644
index 0000000000..a946b5fc3a
--- /dev/null
+++ b/packages/server-util/vitestSetup.ts
@@ -0,0 +1,10 @@
+import { afterEach, beforeEach } from "vitest";
+
+beforeEach(() => {
+ globalThis.window = globalThis.window || ({} as any);
+ (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {};
+});
+
+afterEach(() => {
+ delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
+});
diff --git a/playground/package.json b/playground/package.json
index 6c99ca02c2..2a079e3814 100644
--- a/playground/package.json
+++ b/playground/package.json
@@ -15,6 +15,7 @@
"@blocknote/mantine": "^0.14.6",
"@blocknote/react": "^0.14.6",
"@blocknote/shadcn": "^0.14.6",
+ "@blocknote/server-util": "^0.14.6",
"@aws-sdk/client-s3": "^3.609.0",
"@aws-sdk/s3-request-presigner": "^3.609.0",
"@liveblocks/client": "^1.10.0",
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 845bb98e20..200a6c1cf6 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -256,6 +256,27 @@
"pathFromRoot": "examples/02-backend",
"slug": "backend"
}
+ },
+ {
+ "projectSlug": "rendering-static-documents",
+ "fullSlug": "backend/rendering-static-documents",
+ "pathFromRoot": "examples/02-backend/04-rendering-static-documents",
+ "config": {
+ "playground": true,
+ "docs": true,
+ "author": "yousefed",
+ "tags": [
+ "server"
+ ],
+ "dependencies": {
+ "@blocknote/server-util": "latest"
+ } as any
+ },
+ "title": "Rendering static documents",
+ "group": {
+ "pathFromRoot": "examples/02-backend",
+ "slug": "backend"
+ }
}
]
},
diff --git a/tests/package.json b/tests/package.json
index fc4975601e..293afa742a 100644
--- a/tests/package.json
+++ b/tests/package.json
@@ -6,6 +6,7 @@
"build": "tsc",
"lint": "eslint src --max-warnings 0",
"playwright": "npx playwright test",
+ "playwright:ui": "npx playwright test --ui",
"test:updateSnaps": "docker run --rm -e RUN_IN_DOCKER=true --network host -v $(pwd)/..:/work/ -w /work/tests -it mcr.microsoft.com/playwright:v1.44.1-focal npx playwright test",
"test-ct": "playwright test -c playwright-ct.config.ts --headed",
"test-ct:updateSnaps": "docker run --rm -e RUN_IN_DOCKER=true --network host -v $(pwd)/..:/work/ -w /work/tests -it mcr.microsoft.com/playwright:v1.35.1-focal npm install && playwright test -c playwright-ct.config.ts -u",
diff --git a/tests/src/end-to-end/static/static.test.ts b/tests/src/end-to-end/static/static.test.ts
new file mode 100644
index 0000000000..7d39bd1d01
--- /dev/null
+++ b/tests/src/end-to-end/static/static.test.ts
@@ -0,0 +1,14 @@
+import { expect } from "@playwright/test";
+import { test } from "../../setup/setupScript";
+import { STATIC_URL } from "../../utils/const";
+
+test.beforeEach(async ({ page }) => {
+ await page.goto(STATIC_URL);
+});
+
+test.describe("Check static rendering", () => {
+ test("Check screenshot", async ({ page }) => {
+ await page.waitForTimeout(500);
+ expect(await page.screenshot()).toMatchSnapshot("static-rendering.png");
+ });
+});
diff --git a/tests/src/end-to-end/static/static.test.ts-snapshots/static-rendering-chromium-linux.png b/tests/src/end-to-end/static/static.test.ts-snapshots/static-rendering-chromium-linux.png
new file mode 100644
index 0000000000..5f601a1bae
Binary files /dev/null and b/tests/src/end-to-end/static/static.test.ts-snapshots/static-rendering-chromium-linux.png differ
diff --git a/tests/src/end-to-end/static/static.test.ts-snapshots/static-rendering-firefox-linux.png b/tests/src/end-to-end/static/static.test.ts-snapshots/static-rendering-firefox-linux.png
new file mode 100644
index 0000000000..6fa9716ead
Binary files /dev/null and b/tests/src/end-to-end/static/static.test.ts-snapshots/static-rendering-firefox-linux.png differ
diff --git a/tests/src/end-to-end/static/static.test.ts-snapshots/static-rendering-webkit-linux.png b/tests/src/end-to-end/static/static.test.ts-snapshots/static-rendering-webkit-linux.png
new file mode 100644
index 0000000000..771352cd98
Binary files /dev/null and b/tests/src/end-to-end/static/static.test.ts-snapshots/static-rendering-webkit-linux.png differ
diff --git a/tests/src/utils/const.ts b/tests/src/utils/const.ts
index 1dbf90ca21..12464d18b8 100644
--- a/tests/src/utils/const.ts
+++ b/tests/src/utils/const.ts
@@ -11,6 +11,10 @@ export const ARIAKIT_URL = !process.env.RUN_IN_DOCKER
? `http://localhost:${PORT}/basic/ariakit?hideMenu`
: `http://host.docker.internal:${PORT}/basic/ariakit?hideMenu`;
+export const STATIC_URL = !process.env.RUN_IN_DOCKER
+ ? `http://localhost:${PORT}/backend/rendering-static-documents?hideMenu`
+ : `http://host.docker.internal:${PORT}/backend/rendering-static-documents?hideMenu`;
+
export const PASTE_ZONE_SELECTOR = "#pasteZone";
export const EDITOR_SELECTOR = `.bn-editor`;