diff --git a/docs/content/docs/reference/editor/meta.json b/docs/content/docs/reference/editor/meta.json
index bce5349853..a82cdf3ea4 100644
--- a/docs/content/docs/reference/editor/meta.json
+++ b/docs/content/docs/reference/editor/meta.json
@@ -1,4 +1,4 @@
{
"title": "Editor",
- "pages": ["overview", "manipulating-content", "cursor-selections", "..."]
+ "pages": ["overview", "manipulating-content", "cursor-selections", "yjs-utilities", "..."]
}
diff --git a/docs/content/docs/reference/editor/overview.mdx b/docs/content/docs/reference/editor/overview.mdx
index 4cef63a606..5230aeff02 100644
--- a/docs/content/docs/reference/editor/overview.mdx
+++ b/docs/content/docs/reference/editor/overview.mdx
@@ -118,6 +118,12 @@ The editor can be configured with the following options when using `BlockNoteEdi
name="BlockNoteEditorOptions"
/>
+## YJS Utilities
+
+BlockNote provides utilities for working with YJS collaborative documents. These utilities allow you to convert between BlockNote blocks and YJS documents programmatically.
+
+To read more about YJS utilities, see the [YJS Utilities](/docs/reference/editor/yjs-utilities) reference.
+
## Related Documentation
For more detailed information about specific areas:
diff --git a/docs/content/docs/reference/editor/yjs-utilities.mdx b/docs/content/docs/reference/editor/yjs-utilities.mdx
new file mode 100644
index 0000000000..50ead8d1c6
--- /dev/null
+++ b/docs/content/docs/reference/editor/yjs-utilities.mdx
@@ -0,0 +1,259 @@
+---
+title: YJS Utilities
+description: Utilities for converting between BlockNote blocks and YJS collaborative documents
+imageTitle: YJS Utilities
+---
+
+# YJS Utilities
+
+The `@blocknote/core/yjs` export provides utilities for converting between BlockNote blocks and YJS collaborative documents. These utilities are useful when you need to work with YJS documents outside of the standard collaboration setup, such as importing existing content or working with YJS documents programmatically.
+
+
+ **Important:** This package is for advanced use cases where you need to
+ convert between BlockNote blocks and YJS documents programmatically. For most
+ use cases, you should use the [collaboration
+ features](/docs/features/collaboration) directly instead.
+
+
+## Import
+
+```typescript
+import {
+ blocksToYDoc,
+ blocksToYXmlFragment,
+ yDocToBlocks,
+ yXmlFragmentToBlocks,
+} from "@blocknote/core/yjs";
+```
+
+## Overview
+
+YJS utilities enable bidirectional conversion between:
+
+- **BlockNote blocks** ↔ **Y.Doc** (YJS document)
+- **BlockNote blocks** ↔ **Y.XmlFragment** (YJS XML fragment)
+
+These conversions are essential for:
+
+- Importing existing BlockNote content into a YJS document for collaboration
+- Exporting content from a YJS document back to BlockNote blocks
+- Working with YJS documents programmatically without an active editor instance
+
+## Converting Blocks to YJS Documents
+
+### `blocksToYDoc`
+
+Converts BlockNote blocks into a Y.Doc. This is useful when importing existing content to a Y.Doc for the first time.
+
+
+ **Important:** This should not be used to rehydrate a Y.Doc from a database
+ once collaboration has begun, as all history will be lost.
+
+
+```typescript
+function blocksToYDoc<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ blocks: PartialBlock[],
+ xmlFragment?: string,
+): Y.Doc;
+```
+
+**Parameters:**
+
+- `editor` - The BlockNote editor instance
+- `blocks` - Array of blocks to convert
+- `xmlFragment` - Optional XML fragment name (defaults to `"prosemirror"`)
+
+**Returns:** A new Y.Doc containing the converted blocks
+
+**Example:**
+
+```typescript
+import { BlockNoteEditor } from "@blocknote/core";
+import { blocksToYDoc } from "@blocknote/core/yjs";
+import * as Y from "yjs";
+
+const editor = BlockNoteEditor.create();
+
+const blocks = [
+ {
+ type: "paragraph",
+ content: "Hello, world!",
+ },
+ {
+ type: "heading",
+ props: { level: 1 },
+ content: "My Document",
+ },
+];
+
+// Convert blocks to Y.Doc
+const ydoc = blocksToYDoc(editor, blocks);
+
+// Now you can use this Y.Doc with a YJS provider for collaboration
+const provider = new WebrtcProvider("my-room", ydoc);
+```
+
+### `blocksToYXmlFragment`
+
+Converts BlockNote blocks into a Y.XmlFragment. This is useful when you want to work with a specific XML fragment within a Y.Doc.
+
+```typescript
+function blocksToYXmlFragment<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ blocks: Block[],
+ xmlFragment?: Y.XmlFragment,
+): Y.XmlFragment;
+```
+
+**Parameters:**
+
+- `editor` - The BlockNote editor instance
+- `blocks` - Array of blocks to convert
+- `xmlFragment` - Optional existing Y.XmlFragment to populate (creates new one if not provided)
+
+**Returns:** A Y.XmlFragment containing the converted blocks
+
+**Example:**
+
+```typescript
+import { BlockNoteEditor } from "@blocknote/core";
+import { blocksToYXmlFragment } from "@blocknote/core/yjs";
+import * as Y from "yjs";
+
+const editor = BlockNoteEditor.create();
+const doc = new Y.Doc();
+const fragment = doc.getXmlFragment("my-fragment");
+
+const blocks = [
+ {
+ type: "paragraph",
+ content: "Content for fragment",
+ },
+];
+
+// Convert blocks to the XML fragment
+blocksToYXmlFragment(editor, blocks, fragment);
+```
+
+## Converting YJS Documents to Blocks
+
+### `yDocToBlocks`
+
+Converts a Y.Doc back into BlockNote blocks. This is useful for reading content from a YJS document.
+
+```typescript
+function yDocToBlocks<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ ydoc: Y.Doc,
+ xmlFragment?: string,
+): Block[];
+```
+
+**Parameters:**
+
+- `editor` - The BlockNote editor instance
+- `ydoc` - The Y.Doc to convert
+- `xmlFragment` - Optional XML fragment name (defaults to `"prosemirror"`)
+
+**Returns:** Array of BlockNote blocks
+
+**Example:**
+
+```typescript
+import { BlockNoteEditor } from "@blocknote/core";
+import { yDocToBlocks } from "@blocknote/core/yjs";
+import * as Y from "yjs";
+
+const editor = BlockNoteEditor.create();
+const ydoc = new Y.Doc();
+
+// ... Y.Doc is populated through collaboration or other means ...
+
+// Convert Y.Doc back to blocks
+const blocks = yDocToBlocks(editor, ydoc);
+
+console.log(blocks); // Array of BlockNote blocks
+```
+
+### `yXmlFragmentToBlocks`
+
+Converts a Y.XmlFragment back into BlockNote blocks.
+
+```typescript
+function yXmlFragmentToBlocks<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ xmlFragment: Y.XmlFragment,
+): Block[];
+```
+
+**Parameters:**
+
+- `editor` - The BlockNote editor instance
+- `xmlFragment` - The Y.XmlFragment to convert
+
+**Returns:** Array of BlockNote blocks
+
+**Example:**
+
+```typescript
+import { BlockNoteEditor } from "@blocknote/core";
+import { yXmlFragmentToBlocks } from "@blocknote/core/yjs";
+import * as Y from "yjs";
+
+const editor = BlockNoteEditor.create();
+const doc = new Y.Doc();
+const fragment = doc.getXmlFragment("my-fragment");
+
+// ... Fragment is populated through collaboration or other means ...
+
+// Convert fragment back to blocks
+const blocks = yXmlFragmentToBlocks(editor, fragment);
+```
+
+## Round-trip Conversion
+
+All conversion functions support round-trip conversion, meaning you can convert blocks → YJS → blocks and get back the same content:
+
+```typescript
+import { BlockNoteEditor } from "@blocknote/core";
+import { blocksToYDoc, yDocToBlocks } from "@blocknote/core/yjs";
+
+const editor = BlockNoteEditor.create();
+
+const originalBlocks = [
+ {
+ type: "paragraph",
+ content: "Test content",
+ },
+];
+
+// Convert to Y.Doc and back
+const ydoc = blocksToYDoc(editor, originalBlocks);
+const convertedBlocks = yDocToBlocks(editor, ydoc);
+
+// originalBlocks and convertedBlocks are equivalent
+console.log(originalBlocks); // Same structure as convertedBlocks
+```
+
+## Related Documentation
+
+- [Real-time Collaboration](/docs/features/collaboration) - Learn how to set up collaboration in BlockNote
+- [Manipulating Content](/docs/reference/editor/manipulating-content) - Working with blocks and inline content
+- [Server Processing](/docs/features/server-processing) - Server-side processing for BlockNote (uses these YJS utilities internally)
diff --git a/packages/core/package.json b/packages/core/package.json
index 8779f7a6b3..604f08cb0f 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -66,6 +66,11 @@
"types": "./types/src/i18n/index.d.ts",
"import": "./dist/locales.js",
"require": "./dist/locales.cjs"
+ },
+ "./yjs": {
+ "types": "./types/src/yjs/index.d.ts",
+ "import": "./dist/yjs.js",
+ "require": "./dist/yjs.cjs"
}
},
"scripts": {
diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts
index 1f5d2c75d4..7ce9d39cf1 100644
--- a/packages/core/src/api/nodeConversions/nodeToBlock.ts
+++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts
@@ -23,6 +23,7 @@ import {
getBlockCache,
getBlockSchema,
getInlineContentSchema,
+ getPmSchema,
getStyleSchema,
} from "../pmUtil.js";
@@ -503,26 +504,28 @@ export function docToBlocks<
S extends StyleSchema,
>(
doc: Node,
- schema: Schema,
+ schema: Schema = getPmSchema(doc),
blockSchema: BSchema = getBlockSchema(schema) as BSchema,
inlineContentSchema: I = getInlineContentSchema(schema) as I,
styleSchema: S = getStyleSchema(schema) as S,
blockCache = getBlockCache(schema),
) {
const blocks: Block[] = [];
- doc.firstChild!.descendants((node) => {
- blocks.push(
- nodeToBlock(
- node,
- schema,
- blockSchema,
- inlineContentSchema,
- styleSchema,
- blockCache,
- ),
- );
- return false;
- });
+ if (doc.firstChild) {
+ doc.firstChild.descendants((node) => {
+ blocks.push(
+ nodeToBlock(
+ node,
+ schema,
+ blockSchema,
+ inlineContentSchema,
+ styleSchema,
+ blockCache,
+ ),
+ );
+ return false;
+ });
+ }
return blocks;
}
diff --git a/packages/core/src/yjs/index.ts b/packages/core/src/yjs/index.ts
new file mode 100644
index 0000000000..05c69e3c01
--- /dev/null
+++ b/packages/core/src/yjs/index.ts
@@ -0,0 +1 @@
+export * from "./utils.js";
diff --git a/packages/core/src/yjs/utils.test.ts b/packages/core/src/yjs/utils.test.ts
new file mode 100644
index 0000000000..d7af3eb1a6
--- /dev/null
+++ b/packages/core/src/yjs/utils.test.ts
@@ -0,0 +1,1023 @@
+import { Block, docToBlocks } from "../index.js";
+import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
+import { describe, expect, it } from "vitest";
+import * as Y from "yjs";
+import {
+ _blocksToProsemirrorNode,
+ blocksToYDoc,
+ blocksToYXmlFragment,
+ yDocToBlocks,
+ yXmlFragmentToBlocks,
+} from "./utils.js";
+
+describe("Test yjs utils", () => {
+ const editor = BlockNoteEditor.create();
+
+ const testConversion = (testName: string, blocks: Block[]) => {
+ it(`${testName} - converts to and from prosemirror (doc)`, () => {
+ const node = _blocksToProsemirrorNode(editor, blocks);
+ const blockOutput = docToBlocks(node);
+ expect(blockOutput).toEqual(blocks);
+ });
+
+ it(`${testName} - converts to and from yjs (doc)`, () => {
+ const ydoc = blocksToYDoc(editor, blocks);
+ const blockOutput = yDocToBlocks(editor, ydoc);
+ expect(blockOutput).toEqual(blocks);
+ });
+
+ it(`${testName} - converts to and from yjs (fragment)`, () => {
+ const doc = new Y.Doc();
+ const fragment = doc.getXmlFragment("test");
+ blocksToYXmlFragment(editor, blocks, fragment);
+
+ const blockOutput = yXmlFragmentToBlocks(editor, fragment);
+ expect(blockOutput).toEqual(blocks);
+ });
+ };
+
+ describe("Original test case", () => {
+ const blocks: Block[] = [
+ {
+ id: "1",
+ type: "heading",
+ props: {
+ backgroundColor: "blue",
+ textColor: "yellow",
+ textAlignment: "right",
+ level: 2,
+ isToggleable: false,
+ },
+ 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: [],
+ },
+ ],
+ },
+ {
+ id: "4",
+ type: "image",
+ props: {
+ backgroundColor: "default",
+ textAlignment: "left",
+ name: "Example",
+ url: "exampleURL",
+ caption: "Caption",
+ showPreview: true,
+ previewWidth: 256,
+ },
+ content: undefined,
+ children: [],
+ },
+ {
+ id: "5",
+ type: "image",
+ props: {
+ backgroundColor: "default",
+ textAlignment: "left",
+ name: "Example",
+ url: "exampleURL",
+ caption: "Caption",
+ showPreview: false,
+ previewWidth: 256,
+ },
+ content: undefined,
+ children: [],
+ },
+ ];
+
+ testConversion("original test case", blocks);
+ });
+
+ describe("Empty document", () => {
+ it("empty document - handles empty array", () => {
+ const blocks: Block[] = [];
+ const node = _blocksToProsemirrorNode(editor, blocks);
+ const blockOutput = docToBlocks(node);
+ expect(blockOutput).toEqual([]);
+ });
+
+ it("empty document - converts to and from yjs (doc)", () => {
+ const blocks: Block[] = [];
+ const ydoc = blocksToYDoc(editor, blocks);
+ const blockOutput = yDocToBlocks(editor, ydoc);
+ expect(blockOutput).toEqual([]);
+ });
+
+ it("empty document - converts to and from yjs (fragment)", () => {
+ const blocks: Block[] = [];
+ const doc = new Y.Doc();
+ const fragment = doc.getXmlFragment("test");
+ blocksToYXmlFragment(editor, blocks, fragment);
+
+ const blockOutput = yXmlFragmentToBlocks(editor, fragment);
+ expect(blockOutput).toEqual([]);
+ });
+ });
+
+ describe("Simple paragraphs", () => {
+ const blocks: Block[] = [
+ {
+ id: "1",
+ type: "paragraph",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "First paragraph",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "2",
+ type: "paragraph",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Second paragraph",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ];
+ testConversion("simple paragraphs", blocks);
+ });
+
+ describe("Deeply nested lists", () => {
+ const blocks: Block[] = [
+ {
+ id: "1",
+ type: "bulletListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Level 1",
+ styles: {},
+ },
+ ],
+ children: [
+ {
+ id: "2",
+ type: "bulletListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Level 2",
+ styles: {},
+ },
+ ],
+ children: [
+ {
+ id: "3",
+ type: "bulletListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Level 3",
+ styles: {},
+ },
+ ],
+ children: [
+ {
+ id: "4",
+ type: "bulletListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Level 4",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+ testConversion("deeply nested lists", blocks);
+ });
+
+ describe("Numbered lists", () => {
+ const blocks = [
+ {
+ id: "1",
+ type: "numberedListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "First item",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "2",
+ type: "numberedListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Second item",
+ styles: {},
+ },
+ ],
+ children: [
+ {
+ id: "3",
+ type: "numberedListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Nested item",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ],
+ },
+ ] as unknown as Block[];
+ testConversion("numbered lists", blocks);
+ });
+
+ describe("Checklists", () => {
+ const blocks: Block[] = [
+ {
+ id: "1",
+ type: "checkListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ checked: true,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Completed task",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "2",
+ type: "checkListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ checked: false,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Pending task",
+ styles: {},
+ },
+ ],
+ children: [
+ {
+ id: "3",
+ type: "checkListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ checked: false,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Subtask",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ],
+ },
+ ];
+ testConversion("checklists", blocks);
+ });
+
+ describe("Toggle lists", () => {
+ const blocks: Block[] = [
+ {
+ id: "1",
+ type: "toggleListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Toggle item",
+ styles: {},
+ },
+ ],
+ children: [
+ {
+ id: "2",
+ type: "paragraph",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Hidden content",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ],
+ },
+ ];
+ testConversion("toggle lists", blocks);
+ });
+
+ describe("Code blocks", () => {
+ const blocks: Block[] = [
+ {
+ id: "1",
+ type: "codeBlock",
+ props: {
+ language: "javascript",
+ },
+ content: [
+ {
+ type: "text",
+ text: 'console.log("Hello, world!");',
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "2",
+ type: "codeBlock",
+ props: {
+ language: "typescript",
+ },
+ content: [
+ {
+ type: "text",
+ text: "const x: number = 42;",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ];
+ testConversion("code blocks", blocks);
+ });
+
+ describe("Quotes", () => {
+ const blocks: Block[] = [
+ {
+ id: "1",
+ type: "quote",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ },
+ content: [
+ {
+ type: "text",
+ text: "This is a quote",
+ styles: {
+ italic: true,
+ },
+ },
+ ],
+ children: [
+ {
+ id: "2",
+ type: "paragraph",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Nested in quote",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ],
+ },
+ ];
+ testConversion("quotes", blocks);
+ });
+
+ describe("Headings with different levels", () => {
+ const blocks: Block[] = [
+ {
+ id: "1",
+ type: "heading",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ level: 1,
+ isToggleable: false,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Heading 1",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "2",
+ type: "heading",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ level: 2,
+ isToggleable: false,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Heading 2",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "3",
+ type: "heading",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ level: 3,
+ isToggleable: true,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Toggle Heading 3",
+ styles: {},
+ },
+ ],
+ children: [
+ {
+ id: "4",
+ type: "paragraph",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Content under toggle heading",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ],
+ },
+ ];
+ testConversion("headings with different levels", blocks);
+ });
+
+ describe("Inline styles and links", () => {
+ const blocks: Block[] = [
+ {
+ id: "1",
+ type: "paragraph",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Bold ",
+ styles: {
+ bold: true,
+ },
+ },
+ {
+ type: "text",
+ text: "italic ",
+ styles: {
+ italic: true,
+ },
+ },
+ {
+ type: "text",
+ text: "underline ",
+ styles: {
+ underline: true,
+ },
+ },
+ {
+ type: "text",
+ text: "strikethrough ",
+ styles: {
+ strike: true,
+ },
+ },
+ {
+ type: "text",
+ text: "code",
+ styles: {
+ code: true,
+ },
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "2",
+ type: "paragraph",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "link",
+ href: "https://example.com",
+ content: [
+ {
+ type: "text",
+ text: "Link text",
+ styles: {},
+ },
+ ],
+ },
+ ],
+ children: [],
+ },
+ ];
+ testConversion("inline styles and links", blocks);
+ });
+
+ describe("Tables", () => {
+ const blocks = [
+ {
+ id: "1",
+ type: "table",
+ props: {
+ textColor: "default",
+ },
+ content: {
+ type: "tableContent",
+ columnWidths: [100, 100, 100],
+ headerRows: 1,
+ headerCols: undefined,
+ rows: [
+ {
+ cells: [
+ {
+ type: "tableCell",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ colspan: 1,
+ rowspan: 1,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Header 1",
+ styles: {
+ bold: true,
+ },
+ },
+ ],
+ },
+ {
+ type: "tableCell",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ colspan: 1,
+ rowspan: 1,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Header 2",
+ styles: {
+ bold: true,
+ },
+ },
+ ],
+ },
+ {
+ type: "tableCell",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ colspan: 1,
+ rowspan: 1,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Header 3",
+ styles: {
+ bold: true,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ cells: [
+ {
+ type: "tableCell",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ colspan: 1,
+ rowspan: 1,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Cell 1",
+ styles: {},
+ },
+ ],
+ },
+ {
+ type: "tableCell",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ colspan: 1,
+ rowspan: 1,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Cell 2",
+ styles: {},
+ },
+ ],
+ },
+ {
+ type: "tableCell",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ colspan: 1,
+ rowspan: 1,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Cell 3",
+ styles: {},
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ children: [],
+ },
+ ] as unknown as Block[];
+ testConversion("tables", blocks);
+ });
+
+ describe("Divider", () => {
+ const blocks = [
+ {
+ id: "1",
+ type: "paragraph",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Before divider",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "2",
+ type: "divider",
+ props: {},
+ content: undefined,
+ children: [],
+ },
+ {
+ id: "3",
+ type: "paragraph",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "After divider",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ] as unknown as Block[];
+ testConversion("divider", blocks);
+ });
+
+ describe("Complex mixed document", () => {
+ const blocks: Block[] = [
+ {
+ id: "1",
+ type: "heading",
+ props: {
+ backgroundColor: "blue",
+ textColor: "yellow",
+ textAlignment: "center",
+ level: 1,
+ isToggleable: false,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Main Title",
+ styles: {
+ bold: true,
+ },
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "2",
+ type: "paragraph",
+ props: {
+ backgroundColor: "red",
+ textColor: "default",
+ textAlignment: "right",
+ },
+ content: [
+ {
+ type: "text",
+ text: "This is a paragraph with ",
+ styles: {},
+ },
+ {
+ type: "text",
+ text: "mixed",
+ styles: {
+ bold: true,
+ italic: true,
+ },
+ },
+ {
+ type: "text",
+ text: " styles and a ",
+ styles: {},
+ },
+ {
+ type: "link",
+ href: "https://example.com",
+ content: [
+ {
+ type: "text",
+ text: "link",
+ styles: {},
+ },
+ ],
+ },
+ {
+ type: "text",
+ text: ".",
+ styles: {},
+ },
+ ],
+ children: [
+ {
+ id: "3",
+ type: "bulletListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Nested list item",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ],
+ },
+ {
+ id: "4",
+ type: "quote",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Important quote",
+ styles: {
+ italic: true,
+ },
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "5",
+ type: "codeBlock",
+ props: {
+ language: "typescript",
+ },
+ content: [
+ {
+ type: "text",
+ text: "const example = () => {\n return 'code';\n};",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "6",
+ type: "checkListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ checked: true,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Completed checklist item",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "7",
+ type: "checkListItem",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ checked: false,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Pending checklist item",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ];
+ testConversion("complex mixed document", blocks);
+ });
+});
diff --git a/packages/core/src/yjs/utils.ts b/packages/core/src/yjs/utils.ts
new file mode 100644
index 0000000000..60930a5c9e
--- /dev/null
+++ b/packages/core/src/yjs/utils.ts
@@ -0,0 +1,150 @@
+import {
+ prosemirrorToYDoc,
+ prosemirrorToYXmlFragment,
+ yXmlFragmentToProseMirrorRootNode,
+} from "y-prosemirror";
+import * as Y from "yjs";
+
+import {
+ type Block,
+ type BlockNoteEditor,
+ type BlockSchema,
+ type InlineContentSchema,
+ type PartialBlock,
+ type StyleSchema,
+ blockToNode,
+ docToBlocks,
+} from "../index.js";
+
+/**
+ * Turn Prosemirror JSON to BlockNote style JSON
+ * @param editor BlockNote editor
+ * @param json Prosemirror JSON
+ * @returns BlockNote style JSON
+ */
+export function _prosemirrorJSONToBlocks<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(editor: BlockNoteEditor, json: any) {
+ // note: theoretically this should also be possible without creating prosemirror nodes,
+ // but this is definitely the easiest way
+ const doc = editor.pmSchema.nodeFromJSON(json);
+ return docToBlocks(doc);
+}
+
+/**
+ * Turn BlockNote JSON to Prosemirror node / state
+ * @param editor BlockNote editor
+ * @param blocks BlockNote blocks
+ * @returns Prosemirror root node
+ */
+export function _blocksToProsemirrorNode<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ blocks: PartialBlock[],
+) {
+ const pmNodes = blocks.map((b) => blockToNode(b, editor.pmSchema));
+
+ const doc = editor.pmSchema.topNodeType.create(
+ null,
+ 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)
+ * @param editor BlockNote editor
+ * @param xmlFragment Y.XmlFragment
+ * @returns BlockNote document (BlockNote style JSON of all blocks)
+ */
+export function yXmlFragmentToBlocks<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ xmlFragment: Y.XmlFragment,
+) {
+ const pmNode = yXmlFragmentToProseMirrorRootNode(
+ xmlFragment,
+ editor.pmSchema,
+ );
+ return docToBlocks(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 editor BlockNote editor
+ * @param blocks the blocks to convert
+ * @param xmlFragment XML fragment name
+ * @returns Y.XmlFragment
+ */
+export function blocksToYXmlFragment<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ blocks: Block[],
+ xmlFragment?: Y.XmlFragment,
+) {
+ return prosemirrorToYXmlFragment(
+ _blocksToProsemirrorNode(editor, blocks),
+ xmlFragment,
+ );
+}
+
+/**
+ * Turn a Y.Doc collaborative doc into a BlockNote document (BlockNote style JSON of all blocks)
+ * @param editor BlockNote editor
+ * @param ydoc Y.Doc
+ * @param xmlFragment XML fragment name
+ * @returns BlockNote document (BlockNote style JSON of all blocks)
+ */
+export function yDocToBlocks<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ ydoc: Y.Doc,
+ xmlFragment = "prosemirror",
+) {
+ return yXmlFragmentToBlocks(editor, 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 editor BlockNote editor
+ * @param blocks the blocks to convert
+ * @param xmlFragment XML fragment name
+ */
+export function blocksToYDoc<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ blocks: PartialBlock[],
+ xmlFragment = "prosemirror",
+) {
+ return prosemirrorToYDoc(
+ _blocksToProsemirrorNode(editor, blocks),
+ xmlFragment,
+ );
+}
diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts
index 66656bfd87..de4bd587d9 100644
--- a/packages/core/vite.config.ts
+++ b/packages/core/vite.config.ts
@@ -21,6 +21,7 @@ export default defineConfig({
comments: path.resolve(__dirname, "src/comments/index.ts"),
blocks: path.resolve(__dirname, "src/blocks/index.ts"),
locales: path.resolve(__dirname, "src/i18n/index.ts"),
+ yjs: path.resolve(__dirname, "src/yjs/index.ts"),
},
name: "blocknote",
formats: ["es", "cjs"],
diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.test.ts b/packages/server-util/src/context/ServerBlockNoteEditor.test.ts
index cc6818227e..f5729df9eb 100644
--- a/packages/server-util/src/context/ServerBlockNoteEditor.test.ts
+++ b/packages/server-util/src/context/ServerBlockNoteEditor.test.ts
@@ -1,6 +1,5 @@
import { Block } from "@blocknote/core";
import { describe, expect, it } from "vitest";
-import * as Y from "yjs";
import { ServerBlockNoteEditor } from "./ServerBlockNoteEditor.js";
describe("Test ServerBlockNoteEditor", () => {
@@ -104,27 +103,6 @@ describe("Test ServerBlockNoteEditor", () => {
},
];
- 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 doc = new Y.Doc();
- const fragment = doc.getXmlFragment("test");
- await editor.blocksToYXmlFragment(blocks, 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();
diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.ts b/packages/server-util/src/context/ServerBlockNoteEditor.ts
index 6757aad98e..3068bf2f39 100644
--- a/packages/server-util/src/context/ServerBlockNoteEditor.ts
+++ b/packages/server-util/src/context/ServerBlockNoteEditor.ts
@@ -9,12 +9,19 @@ import {
InlineContentSchema,
PartialBlock,
StyleSchema,
- blockToNode,
blocksToMarkdown,
createExternalHTMLExporter,
createInternalHTMLSerializer,
- nodeToBlock,
+ docToBlocks,
} from "@blocknote/core";
+import {
+ _blocksToProsemirrorNode as blocksToProsemirrorNodeUtil,
+ _prosemirrorJSONToBlocks as prosemirrorJSONToBlocksUtil,
+ blocksToYDoc as blocksToYDocUtil,
+ blocksToYXmlFragment as blocksToYXmlFragmentUtil,
+ yDocToBlocks as yDocToBlocksUtil,
+ yXmlFragmentToBlocks as yXmlFragmentToBlocksUtil,
+} from "@blocknote/core/yjs";
import { BlockNoteViewRaw } from "@blocknote/react";
import { Node } from "@tiptap/pm/model";
@@ -23,11 +30,6 @@ 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";
/**
@@ -100,16 +102,7 @@ export class ServerBlockNoteEditor<
* @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.pmSchema));
-
- return false;
- });
-
- return blocks;
+ return docToBlocks(pmNode);
}
/**
@@ -118,10 +111,7 @@ export class ServerBlockNoteEditor<
* @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);
+ return prosemirrorJSONToBlocksUtil(this.editor, json);
}
/**
@@ -132,14 +122,7 @@ export class ServerBlockNoteEditor<
public _blocksToProsemirrorNode(
blocks: PartialBlock[],
) {
- const pmSchema = this.editor.pmSchema;
- const pmNodes = blocks.map((b) => blockToNode(b, pmSchema));
-
- const doc = pmSchema.topNodeType.create(
- null,
- pmSchema.nodes["blockGroup"].create(null, pmNodes),
- );
- return doc;
+ return blocksToProsemirrorNodeUtil(this.editor, blocks);
}
/** YJS / BLOCKNOTE conversions */
@@ -149,11 +132,7 @@ export class ServerBlockNoteEditor<
* @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);
+ return yXmlFragmentToBlocksUtil(this.editor, xmlFragment);
}
/**
@@ -170,10 +149,7 @@ export class ServerBlockNoteEditor<
blocks: Block[],
xmlFragment?: Y.XmlFragment,
) {
- return prosemirrorToYXmlFragment(
- this._blocksToProsemirrorNode(blocks),
- xmlFragment,
- );
+ return blocksToYXmlFragmentUtil(this.editor, blocks, xmlFragment);
}
/**
@@ -181,7 +157,7 @@ export class ServerBlockNoteEditor<
* @returns BlockNote document (BlockNote style JSON of all blocks)
*/
public yDocToBlocks(ydoc: Y.Doc, xmlFragment = "prosemirror") {
- return this.yXmlFragmentToBlocks(ydoc.getXmlFragment(xmlFragment));
+ return yDocToBlocksUtil(this.editor, ydoc, xmlFragment);
}
/**
@@ -195,10 +171,7 @@ export class ServerBlockNoteEditor<
blocks: PartialBlock[],
xmlFragment = "prosemirror",
) {
- return prosemirrorToYDoc(
- this._blocksToProsemirrorNode(blocks),
- xmlFragment,
- );
+ return blocksToYDocUtil(this.editor, blocks, xmlFragment);
}
/** HTML / BLOCKNOTE conversions */
diff --git a/packages/server-util/tsconfig.json b/packages/server-util/tsconfig.json
index 2769eb7390..1e4b4e3f65 100644
--- a/packages/server-util/tsconfig.json
+++ b/packages/server-util/tsconfig.json
@@ -4,7 +4,7 @@
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
- "moduleResolution": "Node",
+ "moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"sourceMap": true,