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 = `
+
+
+
+

+ Heading 2 +

+
+
+
+
+
+

Paragraph

+
+
+
+
+
+
+

list item

+
+
+
+
+
+
+
+`; + + // 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`] = `"

Heading 2

Paragraph

list item

"`; + +exports[`Test ServerBlockNoteEditor > converts to HTML (blocksToFullHTML) 1`] = `"

Heading 2

Paragraph

list item

"`; + +exports[`Test ServerBlockNoteEditor > converts to and from HTML (blocksToHTMLLossy) 1`] = `"

Heading 2

Paragraph

  • list item

"`; + +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`] = `"
React Context Paragraph
"`; + +exports[`Test ServerBlockNoteEditor with React blocks > works for simple blocks 1`] = `"

React Custom Paragraph

"`; 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`;