/| / ). The
+ // span must be `display: contents` so it doesn't inject an inline box into
+ // the table layout (which triggers the browser's anonymous-table fixup and
+ // breaks the table). Such a span has no box to paint a background on, so
+ // the `.bn-suggestion-node` rule highlights its children (the wrapped
+ // nodes) instead.
+ contentDOM.style.display = "contents";
+ contentDOM.className =
+ type === "delete"
+ ? "bn-suggestion-node bn-suggestion-node--delete"
+ : "bn-suggestion-node";
+ if (type === "delete") {
+ // A deleted block shows a localized "Deleted" badge via a `::before`
+ // (see Block.css). The badge text is passed down as a CSS string token
+ // in `--deleted-label` so the stylesheet stays locale-agnostic; the
+ // wrapper is `display: contents` and can't paint a pseudo-element of its
+ // own, so the rule renders the badge on the wrapped node instead, which
+ // inherits this custom property.
+ const label = editor?.dictionary.suggestion_changes.deleted;
+ if (label) {
+ contentDOM.style.setProperty(
+ "--deleted-label",
+ JSON.stringify(label),
+ );
+ }
+ }
+ }
+ dom.appendChild(contentDOM);
+
+ return {
+ dom,
+ contentDOM,
+ };
+ };
+
export const SuggestionAddMark = Mark.create({
- name: "insertion",
+ name: "y-attributed-insert",
inclusive: false,
- excludes: "deletion modification insertion",
+ // excludes: "", TODO: what's desired?
addAttributes() {
return {
- id: { default: null, validate: "number" }, // note: validate is supported in prosemirror but not in tiptap, so this doesn't actually work (considered not critical)
+ userIds: { default: null },
+ "user-color-light": { default: null, validate: "string" },
+ "user-color-dark": { default: null, validate: "string" },
};
},
+ addMarkView() {
+ return createAttributionMarkView("insert");
+ },
extendMarkSchema(extension) {
- if (extension.name !== "insertion") {
+ if (extension.name !== "y-attributed-insert") {
return {};
}
return {
blocknoteIgnore: true,
inclusive: false,
-
- toDOM(mark, inline) {
- return [
- "ins",
- {
- "data-id": String(mark.attrs["id"]),
- "data-inline": String(inline),
- ...(!inline && { style: "display: contents" }), // changed to "contents" to make this work for table rows
- },
- 0,
- ];
- },
parseDOM: [
{
tag: "ins",
getAttrs(node) {
- if (!node.dataset["id"]) {
+ if (!node.dataset["userIds"]) {
return false;
}
return {
- id: parseInt(node.dataset["id"], 10),
+ userIds: JSON.parse(node.dataset["userIds"]),
+ "user-color-light": node.dataset["userColorLight"],
+ "user-color-dark": node.dataset["userColorDark"],
};
},
},
@@ -51,46 +138,45 @@ export const SuggestionAddMark = Mark.create({
},
});
-export const SuggestionDeleteMark = Mark.create({
- name: "deletion",
+export const SuggestionDeleteMark = Mark.create<{
+ editor?: BlockNoteEditor;
+}>({
+ name: "y-attributed-delete",
inclusive: false,
- excludes: "insertion modification deletion",
+ // excludes: "", TODO: what's desired?
+ addOptions() {
+ return {
+ editor: undefined,
+ };
+ },
addAttributes() {
return {
- id: { default: null, validate: "number" }, // note: validate is supported in prosemirror but not in tiptap
+ userIds: { default: null },
+ "user-color-light": { default: null, validate: "string" },
+ "user-color-dark": { default: null, validate: "string" },
};
},
+ addMarkView() {
+ return createAttributionMarkView("delete", this.options.editor);
+ },
extendMarkSchema(extension) {
- if (extension.name !== "deletion") {
+ if (extension.name !== "y-attributed-delete") {
return {};
}
return {
blocknoteIgnore: true,
inclusive: false,
-
- // attrs: {
- // id: { validate: "number" },
- // },
- toDOM(mark, inline) {
- return [
- "del",
- {
- "data-id": String(mark.attrs["id"]),
- "data-inline": String(inline),
- ...(!inline && { style: "display: contents" }), // changed to "contents" to make this work for table rows
- },
- 0,
- ];
- },
parseDOM: [
{
tag: "del",
getAttrs(node) {
- if (!node.dataset["id"]) {
+ if (!node.dataset["userIds"]) {
return false;
}
return {
- id: parseInt(node.dataset["id"], 10),
+ userIds: JSON.parse(node.dataset["userIds"]),
+ "user-color-light": node.dataset["userColorLight"],
+ "user-color-dark": node.dataset["userColorDark"],
};
},
},
@@ -100,72 +186,57 @@ export const SuggestionDeleteMark = Mark.create({
});
export const SuggestionModificationMark = Mark.create({
- name: "modification",
+ name: "y-attributed-format",
inclusive: false,
- excludes: "deletion insertion",
+ // excludes: "", TODO: what's desired?
addAttributes() {
- // note: validate is supported in prosemirror but not in tiptap
return {
- id: { default: null, validate: "number" },
- type: { validate: "string" },
- attrName: { default: null, validate: "string|null" },
- previousValue: { default: null },
- newValue: { default: null },
+ userIds: { default: null },
+ format: { default: null },
+ "user-color-light": { default: null, validate: "string" },
+ "user-color-dark": { default: null, validate: "string" },
};
},
+ addMarkView() {
+ return createAttributionMarkView("modification");
+ },
extendMarkSchema(extension) {
- if (extension.name !== "modification") {
+ if (extension.name !== "y-attributed-format") {
return {};
}
return {
blocknoteIgnore: true,
inclusive: false,
- // attrs: {
- // id: { validate: "number" },
- // type: { validate: "string" },
- // attrName: { default: null, validate: "string|null" },
- // previousValue: { default: null },
- // newValue: { default: null },
- // },
- toDOM(mark, inline) {
- return [
- inline ? "span" : "div",
- {
- "data-type": "modification",
- "data-id": String(mark.attrs["id"]),
- "data-mod-type": mark.attrs["type"] as string,
- "data-mod-prev-val": JSON.stringify(mark.attrs["previousValue"]),
- // TODO: Try to serialize marks with toJSON?
- "data-mod-new-val": JSON.stringify(mark.attrs["newValue"]),
- },
- 0,
- ];
- },
parseDOM: [
{
tag: "span[data-type='modification']",
getAttrs(node) {
- if (!node.dataset["id"]) {
+ if (!node.dataset["userIds"]) {
return false;
}
return {
- id: parseInt(node.dataset["id"], 10),
- type: node.dataset["modType"],
- previousValue: node.dataset["modPrevVal"],
- newValue: node.dataset["modNewVal"],
+ userIds: JSON.parse(node.dataset["userIds"]),
+ format: node.dataset["format"]
+ ? JSON.parse(node.dataset["format"])
+ : null,
+ "user-color-light": node.dataset["userColorLight"],
+ "user-color-dark": node.dataset["userColorDark"],
};
},
},
{
tag: "div[data-type='modification']",
getAttrs(node) {
- if (!node.dataset["id"]) {
+ if (!node.dataset["userIds"]) {
return false;
}
return {
- id: parseInt(node.dataset["id"], 10),
- type: node.dataset["modType"],
- previousValue: node.dataset["modPrevVal"],
+ userIds: JSON.parse(node.dataset["userIds"]),
+ format: node.dataset["format"]
+ ? JSON.parse(node.dataset["format"])
+ : null,
+ "user-color-light": node.dataset["userColorLight"],
+ "user-color-dark": node.dataset["userColorDark"],
};
},
},
diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts
new file mode 100644
index 0000000000..c8ba85ba06
--- /dev/null
+++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts
@@ -0,0 +1,182 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { Node } from "prosemirror-model";
+import { afterEach, beforeAll, describe, expect, it } from "vite-plus/test";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+
+// Track editors created in each test so we can unmount them in afterEach —
+// otherwise prosemirror-view's DOMObserver leaves a setTimeout alive that
+// fires after vitest tears down jsdom, throwing
+// `ReferenceError: document is not defined` and failing the run.
+const activeEditors: BlockNoteEditor[] = [];
+
+afterEach(() => {
+ while (activeEditors.length) {
+ activeEditors.pop()!.unmount();
+ }
+});
+
+/**
+ * The UniqueID extension's `appendTransaction` hook assigns a fresh id to any
+ * newly-inserted node whose id duplicates an existing one. The one exception is
+ * suggested-deletion nodes (carrying a `y-attributed-delete` mark): in
+ * suggestion mode, Yjs keeps the deleted node in the document with the SAME id
+ * as the surviving node, and rewriting that id would corrupt the suggestion.
+ * These tests exercise both branches.
+ */
+
+function createEditor() {
+ const editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
+ activeEditors.push(editor);
+ editor.replaceBlocks(editor.document, [
+ { id: "block-a", type: "paragraph", content: "A" },
+ { id: "block-b", type: "paragraph", content: "B" },
+ ]);
+ return editor;
+}
+
+/**
+ * Builds a `blockContainer` node holding a single paragraph with the given
+ * block `id`, optionally carrying a `y-attributed-delete` mark to simulate a
+ * suggested deletion.
+ */
+function makeBlockContainer(
+ editor: BlockNoteEditor,
+ id: string,
+ text: string,
+ suggestedDelete: boolean,
+) {
+ const schema = editor.pmSchema;
+ const paragraph = schema.nodes["paragraph"].createChecked(
+ {},
+ schema.text(text),
+ );
+ const marks = suggestedDelete
+ ? [schema.marks["y-attributed-delete"].create({ id: 1 })]
+ : undefined;
+
+ return schema.nodes["blockContainer"].createChecked({ id }, paragraph, marks);
+}
+
+/** Returns the ids of all blockContainer nodes in document order. */
+function getBlockIds(doc: Node) {
+ const ids: (string | null)[] = [];
+ doc.descendants((node) => {
+ if (node.type.name === "blockContainer") {
+ ids.push(node.attrs.id);
+ }
+ return true;
+ });
+ return ids;
+}
+
+describe("UniqueID: duplicate id handling", () => {
+ let editor: BlockNoteEditor;
+
+ beforeAll(() => {
+ // Reset the mock id counter so generated ids are deterministic.
+ (window as any).__TEST_OPTIONS = {};
+ });
+
+ it("assigns a fresh id to a newly-inserted plain block that duplicates another new block", () => {
+ editor = createEditor();
+ const view = editor._tiptapEditor.view;
+
+ // Insert TWO new blocks sharing the same id "dup" in a single transaction.
+ // Both land in the same changed range, so UniqueID detects the duplicate
+ // and rewrites one of them with a fresh generated id.
+ const dup1 = makeBlockContainer(editor, "dup", "Dup 1", false);
+ const dup2 = makeBlockContainer(editor, "dup", "Dup 2", false);
+
+ // Position at the boundary between the first block and the second block
+ // inside the blockGroup.
+ const firstBlock = view.state.doc.firstChild!.firstChild!;
+ const insertPos = firstBlock.nodeSize + 1;
+
+ view.dispatch(view.state.tr.insert(insertPos, [dup1, dup2]));
+
+ const ids = getBlockIds(view.state.doc);
+
+ // Four blocks now exist, and UniqueID has resolved the duplicate so that
+ // all ids are distinct and non-null.
+ expect(ids).toHaveLength(4);
+ expect(ids.every((id) => id !== null)).toBe(true);
+ expect(new Set(ids).size).toBe(4);
+ });
+
+ it("preserves the duplicate id of a suggested-deletion block while still rewriting the plain duplicate", () => {
+ editor = createEditor();
+ const view = editor._tiptapEditor.view;
+
+ // Insert two new blocks sharing the id "dup" in a single transaction: a
+ // plain (live) one and a suggested-deletion one (y-attributed-delete mark).
+ // The plain block's id is rewritten, but the suggested-deletion block MUST
+ // keep its "dup" id, because in suggestion mode it intentionally shares the
+ // id with the surviving node.
+ const liveDup = makeBlockContainer(editor, "dup", "Live dup", false);
+ const deletedDup = makeBlockContainer(editor, "dup", "Deleted dup", true);
+
+ const firstBlock = view.state.doc.firstChild!.firstChild!;
+ const insertPos = firstBlock.nodeSize + 1;
+
+ // Insert the live block first, then the suggested-deletion block after it.
+ view.dispatch(view.state.tr.insert(insertPos, [liveDup, deletedDup]));
+
+ const ids = getBlockIds(view.state.doc);
+
+ expect(ids).toHaveLength(4);
+ // The suggested-deletion block keeps "dup".
+ const dupCount = ids.filter((id) => id === "dup").length;
+ expect(dupCount).toBe(1);
+
+ // Confirm it is specifically the suggested-deletion node that kept "dup".
+ let suggestedDeletionId: string | null = null;
+ view.state.doc.descendants((node) => {
+ if (
+ node.type.name === "blockContainer" &&
+ node.marks.some((m) => m.type.name === "y-attributed-delete")
+ ) {
+ suggestedDeletionId = node.attrs.id;
+ }
+ return true;
+ });
+ expect(suggestedDeletionId).toBe("dup");
+ });
+
+ it("exposes distinct ids in editor.document even though two ProseMirror nodes share the same id", () => {
+ editor = createEditor();
+ const view = editor._tiptapEditor.view;
+
+ // Insert a suggested-deletion copy of the FIRST block, sharing its id
+ // "block-a". This mirrors suggestion mode: Yjs keeps the deleted node in
+ // the document with the same id as the surviving node, and UniqueID leaves
+ // that duplicate id untouched.
+ const deletedCopy = makeBlockContainer(
+ editor,
+ "block-a",
+ "A deleted copy",
+ true,
+ );
+
+ const firstBlock = view.state.doc.firstChild!.firstChild!;
+ const insertPos = firstBlock.nodeSize + 1;
+
+ view.dispatch(view.state.tr.insert(insertPos, deletedCopy));
+
+ // At the ProseMirror level, two nodes now share the id "block-a": the live
+ // one and the suggested-deletion one.
+ const pmIds = getBlockIds(view.state.doc);
+ expect(pmIds.filter((id) => id === "block-a")).toHaveLength(2);
+
+ // But editor.document disambiguates them via getNodeId: the suggested
+ // deletion node is reported as "block-a-1", so all block ids are distinct.
+ const docIds = editor.document.map((block) => block.id);
+ expect(docIds).toContain("block-a");
+ expect(docIds).toContain("block-a-1");
+ expect(new Set(docIds).size).toBe(docIds.length);
+ });
+});
diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts
index 54cb8b7340..7ab30b78aa 100644
--- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts
+++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts
@@ -4,9 +4,10 @@ import {
findChildrenInRange,
getChangedRanges,
} from "@tiptap/core";
-import { Fragment, Slice } from "prosemirror-model";
-import { Plugin, PluginKey } from "prosemirror-state";
import { uuidv4 } from "lib0/random";
+import { Fragment, Node, Slice } from "prosemirror-model";
+import { Plugin, PluginKey } from "prosemirror-state";
+import { isSuggestedDeletionNode } from "../../../api/getBlockInfoFromPos.js";
/**
* Code from Tiptap UniqueID extension (https://tiptap.dev/api/extensions/unique-id)
@@ -41,6 +42,20 @@ function findDuplicates(items: any) {
return duplicates;
}
+/**
+ * Whether a node is marked as deleted by a suggestion (carries the
+ * `y-attributed-delete` node mark).
+ *
+ * Under the suggestion/matchNodes binding, changing a block's content type
+ * renders the block as a deleted copy (this mark) next to its inserted
+ * replacement - and both copies share the same `id`. The deleted copy must be
+ * ignored by the uniqueness logic, otherwise its `id` looks like a duplicate
+ * and we'd regenerate the `id` on the surviving block.
+ */
+function isMarkedDeleted(node: Node) {
+ return node.marks.some((mark) => mark.type.name === "y-attributed-delete");
+}
+
const UniqueID = Extension.create({
name: "uniqueID",
// we’ll set a very high priority to make sure this runs first
@@ -48,7 +63,6 @@ const UniqueID = Extension.create({
priority: 10000,
addOptions() {
return {
- attributeName: "id",
types: [] as string[],
setIdAttribute: false,
isWithinEditor: undefined as ((element: Element) => boolean) | undefined,
@@ -74,19 +88,17 @@ const UniqueID = Extension.create({
{
types: this.options.types,
attributes: {
- [this.options.attributeName]: {
+ id: {
default: null,
- parseHTML: (element) =>
- element.getAttribute(`data-${this.options.attributeName}`),
+ parseHTML: (element) => element.getAttribute(`data-id`),
renderHTML: (attributes) => {
const defaultIdAttributes = {
- [`data-${this.options.attributeName}`]:
- attributes[this.options.attributeName],
+ [`data-id`]: attributes.id,
};
if (this.options.setIdAttribute) {
return {
...defaultIdAttributes,
- id: attributes[this.options.attributeName],
+ id: attributes.id,
};
} else {
return defaultIdAttributes;
@@ -142,7 +154,7 @@ const UniqueID = Extension.create({
return;
}
const { tr } = newState;
- const { types, attributeName, generateID } = this.options;
+ const { types, generateID } = this.options;
const transform = combineTransactionSteps(
oldState.doc,
transactions as any,
@@ -160,16 +172,20 @@ const UniqueID = Extension.create({
},
);
const newIds = newNodes
- .map(({ node }) => node.attrs[attributeName])
+ .map(({ node }) => node.attrs.id)
.filter((id) => id !== null);
const duplicatedNewIds = findDuplicates(newIds);
newNodes.forEach(({ node, pos }) => {
+ // ignore ids on blocks marked as deleted (see above).
+ if (isMarkedDeleted(node)) {
+ return;
+ }
// instead of checking `node.attrs[attributeName]` directly
// we look at the current state of the node within `tr.doc`.
// this helps to prevent adding new ids to the same node
// if the node changed multiple times within one transaction
- const id = tr.doc.nodeAt(pos)?.attrs[attributeName];
+ const id = tr.doc.nodeAt(pos)?.attrs.id;
if (id === null) {
// edge case, when using collaboration, yjs will set the id to null in `_forceRerender`
@@ -193,7 +209,7 @@ const UniqueID = Extension.create({
// yes, apply the fix
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
- [attributeName]: "initialBlockId",
+ id: "initialBlockId",
});
return;
}
@@ -201,17 +217,18 @@ const UniqueID = Extension.create({
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
- [attributeName]: generateID(),
+ id: generateID(),
});
return;
}
// check if the node doesn’t exist in the old state
const { deleted } = mapping.invert().mapResult(pos);
const newNode = deleted && duplicatedNewIds.includes(id);
- if (newNode) {
+ // purposefully skip rewriting ids for suggested deletion nodes, to avoid modifying them
+ if (newNode && !isSuggestedDeletionNode(node)) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
- [attributeName]: generateID(),
+ id: generateID(),
});
}
});
@@ -275,7 +292,7 @@ const UniqueID = Extension.create({
if (!transformPasted) {
return slice;
}
- const { types, attributeName } = this.options;
+ const { types } = this.options;
const removeId = (fragment: any) => {
const list: any[] = [];
fragment.forEach((node: any) => {
@@ -293,7 +310,7 @@ const UniqueID = Extension.create({
const nodeWithoutId = node.type.create(
{
...node.attrs,
- [attributeName]: null,
+ id: null,
},
removeId(node.content),
node.marks,
diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts
index 37abc3e30b..83280d4932 100644
--- a/packages/core/src/i18n/locales/ar.ts
+++ b/packages/core/src/i18n/locales/ar.ts
@@ -386,6 +386,10 @@ export const ar: Dictionary = {
more_replies: (count) => `${count} ردود أخرى`,
},
},
+ suggestion_changes: {
+ formatting_change: "تغيير التنسيق",
+ deleted: "محذوف",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts
index 40944212b3..c0f237487d 100644
--- a/packages/core/src/i18n/locales/de.ts
+++ b/packages/core/src/i18n/locales/de.ts
@@ -420,6 +420,10 @@ export const de: Dictionary = {
more_replies: (count) => `${count} weitere Antworten`,
},
},
+ suggestion_changes: {
+ formatting_change: "Formatierungsänderung",
+ deleted: "Gelöscht",
+ },
generic: {
ctrl_shortcut: "Strg",
},
diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts
index 5a9968eab2..dd1ac31468 100644
--- a/packages/core/src/i18n/locales/en.ts
+++ b/packages/core/src/i18n/locales/en.ts
@@ -401,6 +401,10 @@ export const en = {
more_replies: (count: number) => `${count} more replies`,
},
},
+ suggestion_changes: {
+ formatting_change: "Formatting Change",
+ deleted: "Deleted",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts
index 4757d9784f..bb21856681 100644
--- a/packages/core/src/i18n/locales/es.ts
+++ b/packages/core/src/i18n/locales/es.ts
@@ -399,6 +399,10 @@ export const es: Dictionary = {
more_replies: (count) => `${count} respuestas más`,
},
},
+ suggestion_changes: {
+ formatting_change: "Cambio de formato",
+ deleted: "Eliminado",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts
index c9c67c1fee..63032da22c 100644
--- a/packages/core/src/i18n/locales/fa.ts
+++ b/packages/core/src/i18n/locales/fa.ts
@@ -369,6 +369,10 @@ export const fa = {
more_replies: (count: number) => `${count} پاسخ دیگر`,
},
},
+ suggestion_changes: {
+ formatting_change: "تغییر قالببندی",
+ deleted: "حذف\u200cشده",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts
index b05d346409..a8de8bdc88 100644
--- a/packages/core/src/i18n/locales/fr.ts
+++ b/packages/core/src/i18n/locales/fr.ts
@@ -447,6 +447,10 @@ export const fr: Dictionary = {
more_replies: (count) => `${count} réponses de plus`,
},
},
+ suggestion_changes: {
+ formatting_change: "Modification de mise en forme",
+ deleted: "Supprimé",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts
index 797831460c..1dec27197b 100644
--- a/packages/core/src/i18n/locales/he.ts
+++ b/packages/core/src/i18n/locales/he.ts
@@ -401,6 +401,10 @@ export const he: Dictionary = {
more_replies: (count: number) => `${count} תגובות נוספות`,
},
},
+ suggestion_changes: {
+ formatting_change: "שינוי עיצוב",
+ deleted: "נמחק",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts
index c2081599cc..369cddd7f0 100644
--- a/packages/core/src/i18n/locales/hr.ts
+++ b/packages/core/src/i18n/locales/hr.ts
@@ -414,6 +414,10 @@ export const hr: Dictionary = {
more_replies: (count) => `${count} dodatnih odgovora`,
},
},
+ suggestion_changes: {
+ formatting_change: "Promjena oblikovanja",
+ deleted: "Izbrisano",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts
index fcde471e56..6bc30768b8 100644
--- a/packages/core/src/i18n/locales/is.ts
+++ b/packages/core/src/i18n/locales/is.ts
@@ -414,6 +414,10 @@ export const is: Dictionary = {
more_replies: (count) => `${count} fleiri svör`,
},
},
+ suggestion_changes: {
+ formatting_change: "Sniðbreyting",
+ deleted: "Eytt",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts
index 4053581107..4abfa880d3 100644
--- a/packages/core/src/i18n/locales/it.ts
+++ b/packages/core/src/i18n/locales/it.ts
@@ -423,6 +423,10 @@ export const it: Dictionary = {
more_replies: (count) => `${count} altre risposte`,
},
},
+ suggestion_changes: {
+ formatting_change: "Modifica formattazione",
+ deleted: "Eliminato",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts
index ce5ba87a77..0a00d68094 100644
--- a/packages/core/src/i18n/locales/ja.ts
+++ b/packages/core/src/i18n/locales/ja.ts
@@ -441,6 +441,10 @@ export const ja: Dictionary = {
more_replies: (count) => `${count} 件の追加返信`,
},
},
+ suggestion_changes: {
+ formatting_change: "書式の変更",
+ deleted: "削除済み",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts
index 53a5def39e..0b3bc6b301 100644
--- a/packages/core/src/i18n/locales/ko.ts
+++ b/packages/core/src/i18n/locales/ko.ts
@@ -414,6 +414,10 @@ export const ko: Dictionary = {
more_replies: (count) => `${count}개의 추가 답글`,
},
},
+ suggestion_changes: {
+ formatting_change: "서식 변경",
+ deleted: "삭제됨",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts
index a1bff3fc6b..31ee2da98a 100644
--- a/packages/core/src/i18n/locales/nl.ts
+++ b/packages/core/src/i18n/locales/nl.ts
@@ -401,6 +401,10 @@ export const nl: Dictionary = {
more_replies: (count) => `${count} extra reacties`,
},
},
+ suggestion_changes: {
+ formatting_change: "Opmaakwijziging",
+ deleted: "Verwijderd",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts
index 5d518d116b..41df4fcf03 100644
--- a/packages/core/src/i18n/locales/no.ts
+++ b/packages/core/src/i18n/locales/no.ts
@@ -418,6 +418,10 @@ export const no: Dictionary = {
more_replies: (count) => `${count} flere svar`,
},
},
+ suggestion_changes: {
+ formatting_change: "Formateringsendring",
+ deleted: "Slettet",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts
index 614f64e9f2..0552978235 100644
--- a/packages/core/src/i18n/locales/pl.ts
+++ b/packages/core/src/i18n/locales/pl.ts
@@ -392,6 +392,10 @@ export const pl: Dictionary = {
more_replies: (count) => `${count} więcej odpowiedzi`,
},
},
+ suggestion_changes: {
+ formatting_change: "Zmiana formatowania",
+ deleted: "Usunięto",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts
index c12c94012e..8cadd6c527 100644
--- a/packages/core/src/i18n/locales/pt.ts
+++ b/packages/core/src/i18n/locales/pt.ts
@@ -393,6 +393,10 @@ export const pt: Dictionary = {
more_replies: (count) => `${count} respostas a mais`,
},
},
+ suggestion_changes: {
+ formatting_change: "Alteração de formatação",
+ deleted: "Excluído",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts
index 2982c8f5f6..3375eb5395 100644
--- a/packages/core/src/i18n/locales/ru.ts
+++ b/packages/core/src/i18n/locales/ru.ts
@@ -444,6 +444,10 @@ export const ru: Dictionary = {
more_replies: (count) => `${count} дополнительных ответов`,
},
},
+ suggestion_changes: {
+ formatting_change: "Изменение форматирования",
+ deleted: "Удалено",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts
index c24974f392..08a2762d70 100644
--- a/packages/core/src/i18n/locales/sk.ts
+++ b/packages/core/src/i18n/locales/sk.ts
@@ -399,6 +399,10 @@ export const sk = {
more_replies: (count: number) => `${count} ďalších odpovedí`,
},
},
+ suggestion_changes: {
+ formatting_change: "Zmena formátovania",
+ deleted: "Odstránené",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts
index a5d7d8f9af..f26a811080 100644
--- a/packages/core/src/i18n/locales/uk.ts
+++ b/packages/core/src/i18n/locales/uk.ts
@@ -425,6 +425,10 @@ export const uk: Dictionary = {
more_replies: (count) => `${count} додаткових відповідей`,
},
},
+ suggestion_changes: {
+ formatting_change: "Зміна форматування",
+ deleted: "Видалено",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts
index ffc8d04ac6..4863ae8048 100644
--- a/packages/core/src/i18n/locales/uz.ts
+++ b/packages/core/src/i18n/locales/uz.ts
@@ -435,6 +435,10 @@ export const uz: Dictionary = {
},
},
+ suggestion_changes: {
+ formatting_change: "Formatlash o'zgarishi",
+ deleted: "O'chirildi",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts
index cbe0e5e628..004f2c9534 100644
--- a/packages/core/src/i18n/locales/vi.ts
+++ b/packages/core/src/i18n/locales/vi.ts
@@ -400,6 +400,10 @@ export const vi: Dictionary = {
more_replies: (count) => `${count} câu trả lời nữa`,
},
},
+ suggestion_changes: {
+ formatting_change: "Thay đổi định dạng",
+ deleted: "Đã xóa",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts
index b64912255f..35f86c3401 100644
--- a/packages/core/src/i18n/locales/zh-tw.ts
+++ b/packages/core/src/i18n/locales/zh-tw.ts
@@ -442,6 +442,10 @@ export const zhTW: Dictionary = {
more_replies: (count) => `還有 ${count} 則回覆`,
},
},
+ suggestion_changes: {
+ formatting_change: "格式變更",
+ deleted: "已刪除",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts
index ba5a2fe73b..18f5658b65 100644
--- a/packages/core/src/i18n/locales/zh.ts
+++ b/packages/core/src/i18n/locales/zh.ts
@@ -442,6 +442,10 @@ export const zh: Dictionary = {
more_replies: (count) => `还有 ${count} 条回复`,
},
},
+ suggestion_changes: {
+ formatting_change: "格式更改",
+ deleted: "已删除",
+ },
generic: {
ctrl_shortcut: "Ctrl",
},
diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts
index 065c1e8c2f..819ef2404b 100644
--- a/packages/core/src/pm-nodes/BlockContainer.ts
+++ b/packages/core/src/pm-nodes/BlockContainer.ts
@@ -27,7 +27,7 @@ export const BlockContainer = Node.create<{
// Ensures content-specific keyboard handlers trigger first.
priority: 50,
defining: true,
- marks: "insertion modification deletion",
+ marks: "y-attributed-insert y-attributed-format y-attributed-delete",
parseHTML() {
return [
{
diff --git a/packages/core/src/pm-nodes/BlockGroup.ts b/packages/core/src/pm-nodes/BlockGroup.ts
index d98163310d..5ea809b03a 100644
--- a/packages/core/src/pm-nodes/BlockGroup.ts
+++ b/packages/core/src/pm-nodes/BlockGroup.ts
@@ -8,7 +8,7 @@ export const BlockGroup = Node.create<{
name: "blockGroup",
group: "childContainer",
content: "blockGroupChild+",
- marks: "deletion insertion modification",
+ marks: "y-attributed-insert y-attributed-format y-attributed-delete",
parseHTML() {
return [
{
diff --git a/packages/core/src/pm-nodes/Doc.ts b/packages/core/src/pm-nodes/Doc.ts
index 40af17b7fa..3eead6722b 100644
--- a/packages/core/src/pm-nodes/Doc.ts
+++ b/packages/core/src/pm-nodes/Doc.ts
@@ -4,5 +4,5 @@ export const Doc = Node.create({
name: "doc",
topNode: true,
content: "blockGroup",
- marks: "insertion modification deletion",
+ marks: "y-attributed-insert y-attributed-format y-attributed-delete",
});
diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts
index 6df3e68aa4..958661d734 100644
--- a/packages/core/src/schema/blocks/createSpec.ts
+++ b/packages/core/src/schema/blocks/createSpec.ts
@@ -195,12 +195,7 @@ export function addNodeAndExtensionsToSpec<
// Gets the BlockNote editor instance
const editor = this.options.editor;
// Gets the block
- const block = getBlockFromPos(
- props.getPos,
- editor,
- this.editor,
- blockConfig.type,
- );
+ const block = getBlockFromPos(props.getPos, props.view.state.doc);
// Gets the custom HTML attributes for `blockContent` nodes
const blockContentDOMAttributes =
this.options.domAttributes?.blockContent || {};
diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts
index eed8cf9fa3..210910eb99 100644
--- a/packages/core/src/schema/blocks/internal.ts
+++ b/packages/core/src/schema/blocks/internal.ts
@@ -1,18 +1,12 @@
-import { Attribute, Attributes, Editor, Node } from "@tiptap/core";
+import { Attribute, Attributes, Node } from "@tiptap/core";
+import type { Node as PMNode } from "prosemirror-model";
+import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js";
import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers.js";
-import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.js";
import { mergeCSSClasses } from "../../util/browser.js";
import { camelToDataKebab } from "../../util/string.js";
-import { InlineContentSchema } from "../inlineContent/types.js";
import { PropSchema, Props } from "../propTypes.js";
-import { StyleSchema } from "../styles/types.js";
-import {
- BlockConfig,
- BlockSchemaWithBlock,
- LooseBlockSpec,
- SpecificBlock,
-} from "./types.js";
+import { LooseBlockSpec } from "./types.js";
// Function that uses the 'propSchema' of a blockConfig to create a TipTap
// node's `addAttributes` property.
@@ -82,43 +76,20 @@ export function propsToAttributes(propSchema: PropSchema): Attributes {
// Used to figure out which block should be rendered. This block is then used to
// create the node view.
-export function getBlockFromPos<
- BType extends string,
- Config extends BlockConfig,
- BSchema extends BlockSchemaWithBlock,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- getPos: () => number | undefined,
- editor: BlockNoteEditor,
- tipTapEditor: Editor,
- type: BType,
-) {
+export function getBlockFromPos(getPos: () => number | undefined, doc: PMNode) {
+ // TODO is there a cleaner implementation of this? Probably...
const pos = getPos();
// Gets position of the node
if (pos === undefined) {
throw new Error("Cannot find node position");
}
- // Gets parent blockContainer node
- const blockContainer = tipTapEditor.state.doc.resolve(pos!).node();
- // Gets block identifier
- const blockIdentifier = blockContainer.attrs.id;
- if (!blockIdentifier) {
- throw new Error("Block doesn't have id");
- }
-
- // Gets the block
- const block = editor.getBlock(blockIdentifier)! as SpecificBlock<
- BSchema,
- BType,
- I,
- S
- >;
- if (block.type !== type) {
- throw new Error("Block type does not match");
+ // Gets parent blockContainer node
+ const blockContainer = doc.resolve(pos).node();
+ if (!blockContainer) {
+ throw new Error("Cannot find block container");
}
-
+ const block = nodeToBlock(blockContainer, doc);
return block;
}
diff --git a/packages/core/src/y/README.md b/packages/core/src/y/README.md
new file mode 100644
index 0000000000..0a69f74ba9
--- /dev/null
+++ b/packages/core/src/y/README.md
@@ -0,0 +1,5 @@
+# @blocknote/core/y
+
+This package contains integrations for Yjs (v14) with BlockNote (based on `@y/y` & `@y/prosemirror`). Given that we are going to support both Yjs v13 & v14, we need to have a way to support both versions independently.
+
+If you want to use Yjs v13, you can use the `@blocknote/core/yjs` package instead which will use the `yjs` & `y-prosemirror` packages.
diff --git a/packages/core/src/y/comments/RESTYjsThreadStore.ts b/packages/core/src/y/comments/RESTYjsThreadStore.ts
new file mode 100644
index 0000000000..7841f453f4
--- /dev/null
+++ b/packages/core/src/y/comments/RESTYjsThreadStore.ts
@@ -0,0 +1,138 @@
+import * as Y from "@y/y";
+import type { CommentBody } from "../../comments/types.js";
+import type { ThreadStoreAuth } from "../../comments/threadstore/ThreadStoreAuth.js";
+import { YjsThreadStoreBase } from "./YjsThreadStoreBase.js";
+
+/**
+ * This is a REST-based implementation of the YjsThreadStoreBase for @y/y (v14).
+ * It Reads data directly from the underlying document (same as YjsThreadStore),
+ * but for Writes, it sends data to a REST API that should:
+ * - check the user has the correct permissions to make the desired changes
+ * - apply the updates to the underlying Yjs document
+ *
+ * (see https://github.com/TypeCellOS/BlockNote-demo-nextjs-hocuspocus)
+ *
+ * The reason we still use the Yjs document as underlying storage is that it makes it easy to
+ * sync updates in real-time to other collaborators.
+ * (but technically, you could also implement a different storage altogether
+ * and not store the thread related data in the Yjs document)
+ */
+export class RESTYjsThreadStore extends YjsThreadStoreBase {
+ constructor(
+ private readonly BASE_URL: string,
+ private readonly headers: Record,
+ threadsYType: Y.Type,
+ auth: ThreadStoreAuth,
+ ) {
+ super(threadsYType, auth);
+ }
+
+ private doRequest = async (path: string, method: string, body?: any) => {
+ const response = await fetch(`${this.BASE_URL}${path}`, {
+ method,
+ body: JSON.stringify(body),
+ headers: {
+ "Content-Type": "application/json",
+ ...this.headers,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to ${method} ${path}: ${response.statusText}`);
+ }
+
+ return response.json();
+ };
+
+ public addThreadToDocument = async (options: {
+ threadId: string;
+ selection: {
+ head: number;
+ anchor: number;
+ };
+ }) => {
+ const { threadId, ...rest } = options;
+ return this.doRequest(`/${threadId}/addToDocument`, "POST", rest);
+ };
+
+ public createThread = async (options: {
+ initialComment: {
+ body: CommentBody;
+ metadata?: any;
+ };
+ metadata?: any;
+ }) => {
+ return this.doRequest("", "POST", options);
+ };
+
+ public addComment = (options: {
+ comment: {
+ body: CommentBody;
+ metadata?: any;
+ };
+ threadId: string;
+ }) => {
+ const { threadId, ...rest } = options;
+ return this.doRequest(`/${threadId}/comments`, "POST", rest);
+ };
+
+ public updateComment = (options: {
+ comment: {
+ body: CommentBody;
+ metadata?: any;
+ };
+ threadId: string;
+ commentId: string;
+ }) => {
+ const { threadId, commentId, ...rest } = options;
+ return this.doRequest(`/${threadId}/comments/${commentId}`, "PUT", rest);
+ };
+
+ public deleteComment = (options: {
+ threadId: string;
+ commentId: string;
+ softDelete?: boolean;
+ }) => {
+ const { threadId, commentId, ...rest } = options;
+ return this.doRequest(
+ `/${threadId}/comments/${commentId}?soft=${!!rest.softDelete}`,
+ "DELETE",
+ );
+ };
+
+ public deleteThread = (options: { threadId: string }) => {
+ return this.doRequest(`/${options.threadId}`, "DELETE");
+ };
+
+ public resolveThread = (options: { threadId: string }) => {
+ return this.doRequest(`/${options.threadId}/resolve`, "POST");
+ };
+
+ public unresolveThread = (options: { threadId: string }) => {
+ return this.doRequest(`/${options.threadId}/unresolve`, "POST");
+ };
+
+ public addReaction = (options: {
+ threadId: string;
+ commentId: string;
+ emoji: string;
+ }) => {
+ const { threadId, commentId, ...rest } = options;
+ return this.doRequest(
+ `/${threadId}/comments/${commentId}/reactions`,
+ "POST",
+ rest,
+ );
+ };
+
+ public deleteReaction = (options: {
+ threadId: string;
+ commentId: string;
+ emoji: string;
+ }) => {
+ return this.doRequest(
+ `/${options.threadId}/comments/${options.commentId}/reactions/${options.emoji}`,
+ "DELETE",
+ );
+ };
+}
diff --git a/packages/core/src/y/comments/YjsThreadStore.test.ts b/packages/core/src/y/comments/YjsThreadStore.test.ts
new file mode 100644
index 0000000000..84ce8c47f4
--- /dev/null
+++ b/packages/core/src/y/comments/YjsThreadStore.test.ts
@@ -0,0 +1,295 @@
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import * as Y from "@y/y";
+import type { CommentBody } from "../../comments/types.js";
+import { DefaultThreadStoreAuth } from "../../comments/threadstore/DefaultThreadStoreAuth.js";
+import { YjsThreadStore } from "./YjsThreadStore.js";
+
+// Mock UUID to generate sequential IDs
+let mockUuidCounter = 0;
+vi.mock("lib0/random", async (importOriginal) => ({
+ ...(await importOriginal()),
+ uuidv4: () => `mocked-uuid-${++mockUuidCounter}`,
+}));
+
+describe("YjsThreadStore (@y/y v14)", () => {
+ let store: YjsThreadStore;
+ let doc: Y.Doc;
+ let threadsYType: Y.Type;
+
+ beforeEach(() => {
+ // Reset mocks and create fresh instances
+ vi.clearAllMocks();
+ mockUuidCounter = 0;
+ doc = new Y.Doc();
+ threadsYType = doc.get("threads");
+
+ store = new YjsThreadStore(
+ "test-user",
+ threadsYType,
+ new DefaultThreadStoreAuth("test-user", "editor"),
+ );
+ });
+
+ describe("createThread", () => {
+ it("creates a thread with initial comment", async () => {
+ const initialComment = {
+ body: "Test comment" as CommentBody,
+ metadata: { extra: "metadatacomment" },
+ };
+
+ const thread = await store.createThread({
+ initialComment,
+ metadata: { extra: "metadatathread" },
+ });
+
+ expect(thread).toMatchObject({
+ type: "thread",
+ id: "mocked-uuid-2",
+ resolved: false,
+ metadata: { extra: "metadatathread" },
+ comments: [
+ {
+ type: "comment",
+ id: "mocked-uuid-1",
+ userId: "test-user",
+ body: "Test comment",
+ metadata: { extra: "metadatacomment" },
+ reactions: [],
+ },
+ ],
+ });
+ });
+ });
+
+ describe("addComment", () => {
+ it("adds a comment to existing thread", async () => {
+ // First create a thread
+ const thread = await store.createThread({
+ initialComment: {
+ body: "Initial comment" as CommentBody,
+ },
+ });
+
+ // Add new comment
+ const comment = await store.addComment({
+ threadId: thread.id,
+ comment: {
+ body: "New comment" as CommentBody,
+ metadata: { test: "metadata" },
+ },
+ });
+
+ expect(comment).toMatchObject({
+ type: "comment",
+ id: "mocked-uuid-3",
+ userId: "test-user",
+ body: "New comment",
+ metadata: { test: "metadata" },
+ reactions: [],
+ });
+
+ // Verify thread has both comments
+ const updatedThread = store.getThread(thread.id);
+ expect(updatedThread.comments).toHaveLength(2);
+ });
+
+ it("throws error for non-existent thread", async () => {
+ await expect(
+ store.addComment({
+ threadId: "non-existent",
+ comment: {
+ body: "Test comment" as CommentBody,
+ },
+ }),
+ ).rejects.toThrow("Thread not found");
+ });
+ });
+
+ describe("updateComment", () => {
+ it("updates existing comment", async () => {
+ const thread = await store.createThread({
+ initialComment: {
+ body: "Initial comment" as CommentBody,
+ },
+ });
+
+ await store.updateComment({
+ threadId: thread.id,
+ commentId: thread.comments[0].id,
+ comment: {
+ body: "Updated comment" as CommentBody,
+ metadata: { updatedMetadata: true },
+ },
+ });
+
+ const updatedThread = store.getThread(thread.id);
+ expect(updatedThread.comments[0]).toMatchObject({
+ body: "Updated comment",
+ metadata: { updatedMetadata: true },
+ });
+ });
+ });
+
+ describe("deleteComment", () => {
+ it("soft deletes a comment", async () => {
+ const thread = await store.createThread({
+ initialComment: {
+ body: "Test comment" as CommentBody,
+ },
+ });
+
+ await store.deleteComment({
+ threadId: thread.id,
+ commentId: thread.comments[0].id,
+ softDelete: true,
+ });
+
+ const updatedThread = store.getThread(thread.id);
+ expect(updatedThread.comments[0].deletedAt).toBeDefined();
+ expect(updatedThread.comments[0].body).toBeUndefined();
+ });
+
+ it("hard deletes a comment (deletes thread)", async () => {
+ const thread = await store.createThread({
+ initialComment: {
+ body: "Test comment" as CommentBody,
+ },
+ });
+
+ await store.deleteComment({
+ threadId: thread.id,
+ commentId: thread.comments[0].id,
+ softDelete: false,
+ });
+
+ // Thread should be deleted since it was the only comment
+ expect(() => store.getThread(thread.id)).toThrow("Thread not found");
+ });
+ });
+
+ describe("resolveThread", () => {
+ it("resolves a thread", async () => {
+ const thread = await store.createThread({
+ initialComment: {
+ body: "Test comment" as CommentBody,
+ },
+ });
+
+ await store.resolveThread({ threadId: thread.id });
+
+ const updatedThread = store.getThread(thread.id);
+ expect(updatedThread.resolved).toBe(true);
+ expect(updatedThread.resolvedUpdatedAt).toBeDefined();
+ });
+ });
+
+ describe("unresolveThread", () => {
+ it("unresolves a thread", async () => {
+ const thread = await store.createThread({
+ initialComment: {
+ body: "Test comment" as CommentBody,
+ },
+ });
+
+ await store.resolveThread({ threadId: thread.id });
+ await store.unresolveThread({ threadId: thread.id });
+
+ const updatedThread = store.getThread(thread.id);
+ expect(updatedThread.resolved).toBe(false);
+ expect(updatedThread.resolvedUpdatedAt).toBeDefined();
+ });
+ });
+
+ describe("getThreads", () => {
+ it("returns all threads", async () => {
+ await store.createThread({
+ initialComment: {
+ body: "Thread 1" as CommentBody,
+ },
+ });
+
+ await store.createThread({
+ initialComment: {
+ body: "Thread 2" as CommentBody,
+ },
+ });
+
+ const threads = store.getThreads();
+ expect(threads.size).toBe(2);
+ });
+ });
+
+ describe("deleteThread", () => {
+ it("deletes an entire thread", async () => {
+ const thread = await store.createThread({
+ initialComment: {
+ body: "Test comment" as CommentBody,
+ },
+ });
+
+ await store.deleteThread({ threadId: thread.id });
+
+ // Verify thread is deleted
+ expect(() => store.getThread(thread.id)).toThrow("Thread not found");
+ });
+ });
+
+ describe("reactions", () => {
+ it("adds a reaction to a comment", async () => {
+ const thread = await store.createThread({
+ initialComment: {
+ body: "Test comment" as CommentBody,
+ },
+ });
+
+ await store.addReaction({
+ threadId: thread.id,
+ commentId: thread.comments[0].id,
+ emoji: "👍",
+ });
+
+ expect(store.getThread(thread.id).comments[0].reactions).toHaveLength(1);
+ });
+
+ it("deletes a reaction from a comment", async () => {
+ const thread = await store.createThread({
+ initialComment: {
+ body: "Test comment" as CommentBody,
+ },
+ });
+
+ await store.addReaction({
+ threadId: thread.id,
+ commentId: thread.comments[0].id,
+ emoji: "👍",
+ });
+
+ expect(store.getThread(thread.id).comments[0].reactions).toHaveLength(1);
+
+ await store.deleteReaction({
+ threadId: thread.id,
+ commentId: thread.comments[0].id,
+ emoji: "👍",
+ });
+
+ expect(store.getThread(thread.id).comments[0].reactions).toHaveLength(0);
+ });
+ });
+
+ describe("subscribe", () => {
+ it("calls callback when threads change", async () => {
+ const callback = vi.fn();
+ const unsubscribe = store.subscribe(callback);
+
+ await store.createThread({
+ initialComment: {
+ body: "Test comment" as CommentBody,
+ },
+ });
+
+ expect(callback).toHaveBeenCalled();
+
+ unsubscribe();
+ });
+ });
+});
diff --git a/packages/core/src/y/comments/YjsThreadStore.ts b/packages/core/src/y/comments/YjsThreadStore.ts
new file mode 100644
index 0000000000..0a9b09a676
--- /dev/null
+++ b/packages/core/src/y/comments/YjsThreadStore.ts
@@ -0,0 +1,358 @@
+import { uuidv4 } from "lib0/random";
+import * as Y from "@y/y";
+import type {
+ CommentBody,
+ CommentData,
+ ThreadData,
+} from "../../comments/types.js";
+import type { ThreadStoreAuth } from "../../comments/threadstore/ThreadStoreAuth.js";
+import { YjsThreadStoreBase } from "./YjsThreadStoreBase.js";
+import {
+ commentToYType,
+ threadToYType,
+ yTypeToComment,
+ yTypeToThread,
+} from "./yjsHelpers.js";
+
+/**
+ * This is a @y/y (v14)-based implementation of the ThreadStore interface.
+ *
+ * It reads and writes thread / comments information directly to the underlying Yjs Document.
+ *
+ * @important While this is the easiest to add to your app, there are two challenges:
+ * - The user needs to be able to write to the Yjs document to store the information.
+ * So a user without write access to the Yjs document cannot leave any comments.
+ * - Even with write access, the operations are not secure. Unless your Yjs server
+ * guards against malicious operations, it's technically possible for one user to make changes to another user's comments, etc.
+ * (even though these options are not visible in the UI, a malicious user can make unauthorized changes to the underlying Yjs document)
+ */
+export class YjsThreadStore extends YjsThreadStoreBase {
+ constructor(
+ private readonly userId: string,
+ threadsYType: Y.Type,
+ auth: ThreadStoreAuth,
+ ) {
+ super(threadsYType, auth);
+ }
+
+ private transact = (
+ fn: (options: T) => R,
+ ): ((options: T) => Promise) => {
+ return async (options: T) => {
+ return this.threadsYType.doc!.transact(() => {
+ return fn(options);
+ });
+ };
+ };
+
+ public createThread = this.transact(
+ (options: {
+ initialComment: {
+ body: CommentBody;
+ metadata?: any;
+ };
+ metadata?: any;
+ }) => {
+ if (!this.auth.canCreateThread()) {
+ throw new Error("Not authorized");
+ }
+
+ const date = new Date();
+
+ const comment: CommentData = {
+ type: "comment",
+ id: uuidv4(),
+ userId: this.userId,
+ createdAt: date,
+ updatedAt: date,
+ reactions: [],
+ metadata: options.initialComment.metadata,
+ body: options.initialComment.body,
+ };
+
+ const thread: ThreadData = {
+ type: "thread",
+ id: uuidv4(),
+ createdAt: date,
+ updatedAt: date,
+ comments: [comment],
+ resolved: false,
+ metadata: options.metadata,
+ };
+
+ this.threadsYType.setAttr(thread.id, threadToYType(thread));
+
+ return thread;
+ },
+ );
+
+ // YjsThreadStore does not support addThreadToDocument
+ public addThreadToDocument = undefined;
+
+ public addComment = this.transact(
+ (options: {
+ comment: {
+ body: CommentBody;
+ metadata?: any;
+ };
+ threadId: string;
+ }) => {
+ const yThread = this.threadsYType.getAttr(options.threadId) as
+ | Y.Type
+ | undefined;
+ if (!yThread) {
+ throw new Error("Thread not found");
+ }
+
+ if (!this.auth.canAddComment(yTypeToThread(yThread))) {
+ throw new Error("Not authorized");
+ }
+
+ const date = new Date();
+ const comment: CommentData = {
+ type: "comment",
+ id: uuidv4(),
+ userId: this.userId,
+ createdAt: date,
+ updatedAt: date,
+ deletedAt: undefined,
+ reactions: [],
+ metadata: options.comment.metadata,
+ body: options.comment.body,
+ };
+
+ (yThread.getAttr("comments") as Y.Type).push([commentToYType(comment)]);
+
+ yThread.setAttr("updatedAt", new Date().getTime());
+ return comment;
+ },
+ );
+
+ public updateComment = this.transact(
+ (options: {
+ comment: {
+ body: CommentBody;
+ metadata?: any;
+ };
+ threadId: string;
+ commentId: string;
+ }) => {
+ const yThread = this.threadsYType.getAttr(options.threadId) as
+ | Y.Type
+ | undefined;
+ if (!yThread) {
+ throw new Error("Thread not found");
+ }
+
+ const commentsType = yThread.getAttr("comments") as Y.Type;
+ const yCommentIndex = yTypeFindIndex(
+ commentsType,
+ (comment) => (comment as Y.Type).getAttr("id") === options.commentId,
+ );
+
+ if (yCommentIndex === -1) {
+ throw new Error("Comment not found");
+ }
+
+ const yComment = commentsType.get(yCommentIndex) as Y.Type;
+
+ if (!this.auth.canUpdateComment(yTypeToComment(yComment))) {
+ throw new Error("Not authorized");
+ }
+
+ yComment.setAttr("body", options.comment.body);
+ yComment.setAttr("updatedAt", new Date().getTime());
+ yComment.setAttr("metadata", options.comment.metadata);
+ },
+ );
+
+ public deleteComment = this.transact(
+ (options: {
+ threadId: string;
+ commentId: string;
+ softDelete?: boolean;
+ }) => {
+ const yThread = this.threadsYType.getAttr(options.threadId) as
+ | Y.Type
+ | undefined;
+ if (!yThread) {
+ throw new Error("Thread not found");
+ }
+
+ const commentsType = yThread.getAttr("comments") as Y.Type;
+ const yCommentIndex = yTypeFindIndex(
+ commentsType,
+ (comment) => (comment as Y.Type).getAttr("id") === options.commentId,
+ );
+
+ if (yCommentIndex === -1) {
+ throw new Error("Comment not found");
+ }
+
+ const yComment = commentsType.get(yCommentIndex) as Y.Type;
+
+ if (!this.auth.canDeleteComment(yTypeToComment(yComment))) {
+ throw new Error("Not authorized");
+ }
+
+ if (yComment.getAttr("deletedAt")) {
+ throw new Error("Comment already deleted");
+ }
+
+ if (options.softDelete) {
+ yComment.setAttr("deletedAt", new Date().getTime());
+ yComment.setAttr("body", undefined);
+ } else {
+ commentsType.delete(yCommentIndex);
+ }
+
+ if (
+ commentsType
+ .toArray()
+ .every((comment) => (comment as Y.Type).getAttr("deletedAt"))
+ ) {
+ // all comments deleted
+ if (options.softDelete) {
+ yThread.setAttr("deletedAt", new Date().getTime());
+ } else {
+ this.threadsYType.deleteAttr(options.threadId);
+ }
+ }
+
+ yThread.setAttr("updatedAt", new Date().getTime());
+ },
+ );
+
+ public deleteThread = this.transact((options: { threadId: string }) => {
+ if (
+ !this.auth.canDeleteThread(
+ yTypeToThread(this.threadsYType.getAttr(options.threadId) as Y.Type),
+ )
+ ) {
+ throw new Error("Not authorized");
+ }
+
+ this.threadsYType.deleteAttr(options.threadId);
+ });
+
+ public resolveThread = this.transact((options: { threadId: string }) => {
+ const yThread = this.threadsYType.getAttr(options.threadId) as
+ | Y.Type
+ | undefined;
+ if (!yThread) {
+ throw new Error("Thread not found");
+ }
+
+ if (!this.auth.canResolveThread(yTypeToThread(yThread))) {
+ throw new Error("Not authorized");
+ }
+
+ yThread.setAttr("resolved", true);
+ yThread.setAttr("resolvedUpdatedAt", new Date().getTime());
+ yThread.setAttr("resolvedBy", this.userId);
+ });
+
+ public unresolveThread = this.transact((options: { threadId: string }) => {
+ const yThread = this.threadsYType.getAttr(options.threadId) as
+ | Y.Type
+ | undefined;
+ if (!yThread) {
+ throw new Error("Thread not found");
+ }
+
+ if (!this.auth.canUnresolveThread(yTypeToThread(yThread))) {
+ throw new Error("Not authorized");
+ }
+
+ yThread.setAttr("resolved", false);
+ yThread.setAttr("resolvedUpdatedAt", new Date().getTime());
+ });
+
+ public addReaction = this.transact(
+ (options: { threadId: string; commentId: string; emoji: string }) => {
+ const yThread = this.threadsYType.getAttr(options.threadId) as
+ | Y.Type
+ | undefined;
+ if (!yThread) {
+ throw new Error("Thread not found");
+ }
+
+ const commentsType = yThread.getAttr("comments") as Y.Type;
+ const yCommentIndex = yTypeFindIndex(
+ commentsType,
+ (comment) => (comment as Y.Type).getAttr("id") === options.commentId,
+ );
+
+ if (yCommentIndex === -1) {
+ throw new Error("Comment not found");
+ }
+
+ const yComment = commentsType.get(yCommentIndex) as Y.Type;
+
+ if (!this.auth.canAddReaction(yTypeToComment(yComment), options.emoji)) {
+ throw new Error("Not authorized");
+ }
+
+ const date = new Date();
+
+ const key = `${this.userId}-${options.emoji}`;
+
+ const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Type;
+
+ if (reactionsByUser.hasAttr(key)) {
+ // already exists
+ return;
+ } else {
+ const reaction = new Y.Type();
+ reaction.setAttr("emoji", options.emoji);
+ reaction.setAttr("createdAt", date.getTime());
+ reaction.setAttr("userId", this.userId);
+ reactionsByUser.setAttr(key, reaction);
+ }
+ },
+ );
+
+ public deleteReaction = this.transact(
+ (options: { threadId: string; commentId: string; emoji: string }) => {
+ const yThread = this.threadsYType.getAttr(options.threadId) as
+ | Y.Type
+ | undefined;
+ if (!yThread) {
+ throw new Error("Thread not found");
+ }
+
+ const commentsType = yThread.getAttr("comments") as Y.Type;
+ const yCommentIndex = yTypeFindIndex(
+ commentsType,
+ (comment) => (comment as Y.Type).getAttr("id") === options.commentId,
+ );
+
+ if (yCommentIndex === -1) {
+ throw new Error("Comment not found");
+ }
+
+ const yComment = commentsType.get(yCommentIndex) as Y.Type;
+
+ if (
+ !this.auth.canDeleteReaction(yTypeToComment(yComment), options.emoji)
+ ) {
+ throw new Error("Not authorized");
+ }
+
+ const key = `${this.userId}-${options.emoji}`;
+
+ const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Type;
+
+ reactionsByUser.deleteAttr(key);
+ },
+ );
+}
+
+function yTypeFindIndex(yType: Y.Type, predicate: (item: any) => boolean) {
+ for (let i = 0; i < yType.length; i++) {
+ if (predicate(yType.get(i))) {
+ return i;
+ }
+ }
+ return -1;
+}
diff --git a/packages/core/src/y/comments/YjsThreadStoreBase.ts b/packages/core/src/y/comments/YjsThreadStoreBase.ts
new file mode 100644
index 0000000000..b62c2e1811
--- /dev/null
+++ b/packages/core/src/y/comments/YjsThreadStoreBase.ts
@@ -0,0 +1,50 @@
+import * as Y from "@y/y";
+import type { ThreadData } from "../../comments/types.js";
+import { ThreadStore } from "../../comments/threadstore/ThreadStore.js";
+import type { ThreadStoreAuth } from "../../comments/threadstore/ThreadStoreAuth.js";
+import { yTypeToThread } from "./yjsHelpers.js";
+
+/**
+ * This is an abstract class that only implements the READ methods required by the ThreadStore interface.
+ * The data is read from a @y/y Type used as a map (via attributes).
+ */
+export abstract class YjsThreadStoreBase extends ThreadStore {
+ constructor(
+ protected readonly threadsYType: Y.Type,
+ auth: ThreadStoreAuth,
+ ) {
+ super(auth);
+ }
+
+ // TODO: async / reactive interface?
+ public getThread(threadId: string) {
+ const yThread = this.threadsYType.getAttr(threadId);
+ if (!yThread) {
+ throw new Error("Thread not found");
+ }
+ const thread = yTypeToThread(yThread);
+ return thread;
+ }
+
+ public getThreads(): Map {
+ const threadMap = new Map();
+ this.threadsYType.forEachAttr((yThread: any, id: string | number) => {
+ if (yThread instanceof Y.Type) {
+ threadMap.set(String(id), yTypeToThread(yThread));
+ }
+ });
+ return threadMap;
+ }
+
+ public subscribe(cb: (threads: Map) => void) {
+ const observer = () => {
+ cb(this.getThreads());
+ };
+
+ this.threadsYType.observeDeep(observer);
+
+ return () => {
+ this.threadsYType.unobserveDeep(observer);
+ };
+ }
+}
diff --git a/packages/core/src/y/comments/index.ts b/packages/core/src/y/comments/index.ts
new file mode 100644
index 0000000000..69e9f87de3
--- /dev/null
+++ b/packages/core/src/y/comments/index.ts
@@ -0,0 +1,3 @@
+export * from "./RESTYjsThreadStore.js";
+export * from "./YjsThreadStore.js";
+export * from "./YjsThreadStoreBase.js";
diff --git a/packages/core/src/y/comments/yjsHelpers.ts b/packages/core/src/y/comments/yjsHelpers.ts
new file mode 100644
index 0000000000..1ed4ff492f
--- /dev/null
+++ b/packages/core/src/y/comments/yjsHelpers.ts
@@ -0,0 +1,125 @@
+import * as Y from "@y/y";
+import type {
+ CommentData,
+ CommentReactionData,
+ ThreadData,
+} from "../../comments/types.js";
+
+export function commentToYType(comment: CommentData) {
+ const yType = new Y.Type();
+ yType.setAttr("id", comment.id);
+ yType.setAttr("userId", comment.userId);
+ yType.setAttr("createdAt", comment.createdAt.getTime());
+ yType.setAttr("updatedAt", comment.updatedAt.getTime());
+ if (comment.deletedAt) {
+ yType.setAttr("deletedAt", comment.deletedAt.getTime());
+ yType.setAttr("body", undefined);
+ } else {
+ yType.setAttr("body", comment.body);
+ }
+ if (comment.reactions.length > 0) {
+ throw new Error("Reactions should be empty in commentToYType");
+ }
+
+ /**
+ * Reactions are stored in a map keyed by {userId-emoji},
+ * this makes it easy to add / remove reactions and in a way that works local-first.
+ * The cost is that "reading" the reactions is a bit more complex (see yTypeToReactions).
+ */
+ yType.setAttr("reactionsByUser", new Y.Type());
+ yType.setAttr("metadata", comment.metadata);
+
+ return yType;
+}
+
+export function threadToYType(thread: ThreadData) {
+ const yType = new Y.Type();
+ yType.setAttr("id", thread.id);
+ yType.setAttr("createdAt", thread.createdAt.getTime());
+ yType.setAttr("updatedAt", thread.updatedAt.getTime());
+ const commentsType = new Y.Type();
+
+ commentsType.push(thread.comments.map((comment) => commentToYType(comment)));
+
+ yType.setAttr("comments", commentsType);
+ yType.setAttr("resolved", thread.resolved);
+ yType.setAttr("resolvedUpdatedAt", thread.resolvedUpdatedAt?.getTime());
+ yType.setAttr("resolvedBy", thread.resolvedBy);
+ yType.setAttr("metadata", thread.metadata);
+ return yType;
+}
+
+type SingleUserCommentReactionData = {
+ emoji: string;
+ createdAt: Date;
+ userId: string;
+};
+
+export function yTypeToReaction(yType: Y.Type): SingleUserCommentReactionData {
+ return {
+ emoji: yType.getAttr("emoji"),
+ createdAt: new Date(yType.getAttr("createdAt")),
+ userId: yType.getAttr("userId"),
+ };
+}
+
+function yTypeToReactions(yType: Y.Type): CommentReactionData[] {
+ const flatReactions = [...yType.attrValues()].map((reaction: Y.Type) =>
+ yTypeToReaction(reaction),
+ );
+ // combine reactions by the same emoji
+ return flatReactions.reduce(
+ (acc: CommentReactionData[], reaction: SingleUserCommentReactionData) => {
+ const existingReaction = acc.find((r) => r.emoji === reaction.emoji);
+ if (existingReaction) {
+ existingReaction.userIds.push(reaction.userId);
+ existingReaction.createdAt = new Date(
+ Math.min(
+ existingReaction.createdAt.getTime(),
+ reaction.createdAt.getTime(),
+ ),
+ );
+ } else {
+ acc.push({
+ emoji: reaction.emoji,
+ createdAt: reaction.createdAt,
+ userIds: [reaction.userId],
+ });
+ }
+ return acc;
+ },
+ [] as CommentReactionData[],
+ );
+}
+
+export function yTypeToComment(yType: Y.Type): CommentData {
+ return {
+ type: "comment",
+ id: yType.getAttr("id"),
+ userId: yType.getAttr("userId"),
+ createdAt: new Date(yType.getAttr("createdAt")),
+ updatedAt: new Date(yType.getAttr("updatedAt")),
+ deletedAt: yType.getAttr("deletedAt")
+ ? new Date(yType.getAttr("deletedAt"))
+ : undefined,
+ reactions: yTypeToReactions(yType.getAttr("reactionsByUser")),
+ metadata: yType.getAttr("metadata"),
+ body: yType.getAttr("body"),
+ };
+}
+
+export function yTypeToThread(yType: Y.Type): ThreadData {
+ return {
+ type: "thread",
+ id: yType.getAttr("id"),
+ createdAt: new Date(yType.getAttr("createdAt")),
+ updatedAt: new Date(yType.getAttr("updatedAt")),
+ comments: ((yType.getAttr("comments") as Y.Type)?.toArray() || []).map(
+ (comment) => yTypeToComment(comment as Y.Type),
+ ),
+ resolved: yType.getAttr("resolved"),
+ resolvedUpdatedAt: new Date(yType.getAttr("resolvedUpdatedAt")),
+ resolvedBy: yType.getAttr("resolvedBy"),
+ metadata: yType.getAttr("metadata"),
+ };
+}
diff --git a/packages/core/src/y/extensions/ForkYDoc.test.ts b/packages/core/src/y/extensions/ForkYDoc.test.ts
new file mode 100644
index 0000000000..e155088e3e
--- /dev/null
+++ b/packages/core/src/y/extensions/ForkYDoc.test.ts
@@ -0,0 +1,253 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { afterEach, describe, expect, it } from "vite-plus/test";
+import * as Y from "@y/y";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { ForkYDocExtension } from "./ForkYDoc.js";
+import { withCollaboration } from "./index.js";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function createCollabEditor() {
+ const doc = new Y.Doc();
+ const fragment = doc.get("doc");
+
+ const collabOptions = {
+ fragment,
+ user: { name: "Test User", color: "#FF0000" },
+ provider: undefined,
+ };
+
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: collabOptions,
+ // Register ForkYDocExtension alongside the collaboration extensions
+ extensions: [ForkYDocExtension(collabOptions)],
+ }),
+ );
+ const div = document.createElement("div");
+ editor.mount(div);
+
+ return { editor, doc, fragment };
+}
+
+function getEditorText(editor: BlockNoteEditor): string {
+ return editor.prosemirrorState.doc.textContent;
+}
+
+function setEditorText(editor: BlockNoteEditor, text: string) {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "paragraph",
+ content: [{ text, styles: {}, type: "text" }],
+ },
+ ]);
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+let ctx: ReturnType;
+
+afterEach(() => {
+ ctx?.editor.unmount();
+ ctx?.doc.destroy();
+});
+
+describe("ForkYDocExtension (v14)", () => {
+ it("forks the document — edits do not affect the original fragment", () => {
+ ctx = createCollabEditor();
+ setEditorText(ctx.editor, "Original");
+
+ const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
+ forkYDoc.fork();
+
+ // Edit while forked
+ setEditorText(ctx.editor, "Forked edit");
+
+ // The editor shows the forked content
+ expect(getEditorText(ctx.editor)).toBe("Forked edit");
+
+ // Merge without keeping changes to verify the original is intact
+ forkYDoc.merge({ keepChanges: false });
+ expect(getEditorText(ctx.editor)).toBe("Original");
+ });
+
+ it("merge({ keepChanges: false }) discards forked edits", () => {
+ ctx = createCollabEditor();
+ setEditorText(ctx.editor, "Original");
+
+ const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
+ forkYDoc.fork();
+ setEditorText(ctx.editor, "Forked edit");
+
+ forkYDoc.merge({ keepChanges: false });
+
+ expect(getEditorText(ctx.editor)).toBe("Original");
+ });
+
+ it("merge({ keepChanges: true }) applies forked edits to the original doc", () => {
+ ctx = createCollabEditor();
+ setEditorText(ctx.editor, "Original");
+
+ const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
+ forkYDoc.fork();
+ setEditorText(ctx.editor, "Forked edit");
+
+ forkYDoc.merge({ keepChanges: true });
+
+ expect(getEditorText(ctx.editor)).toContain("Forked edit");
+ });
+
+ it("fork({ initialUpdate }) uses the provided update instead of the live doc", () => {
+ ctx = createCollabEditor();
+ setEditorText(ctx.editor, "Current content");
+
+ // Create a snapshot of the current state
+ const snapshotDoc = new Y.Doc();
+ Y.applyUpdateV2(snapshotDoc, Y.encodeStateAsUpdateV2(ctx.doc));
+
+ // Modify the live editor
+ setEditorText(ctx.editor, "Modified after snapshot");
+
+ // Fork with the snapshot (which has "Current content")
+ const snapshotUpdate = Y.encodeStateAsUpdateV2(snapshotDoc);
+ const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
+ forkYDoc.fork({ initialUpdate: snapshotUpdate });
+
+ // The editor should show the snapshot content
+ expect(getEditorText(ctx.editor)).toBe("Current content");
+
+ // Merge without keeping changes to verify the live doc is still "Modified after snapshot"
+ forkYDoc.merge({ keepChanges: false });
+ expect(getEditorText(ctx.editor)).toBe("Modified after snapshot");
+ });
+
+ it("fork({ initialUpdate }) + merge({ keepChanges: false }) restores live doc", () => {
+ ctx = createCollabEditor();
+ setEditorText(ctx.editor, "Live content");
+
+ const snapshotDoc = new Y.Doc();
+ Y.applyUpdateV2(snapshotDoc, Y.encodeStateAsUpdateV2(ctx.doc));
+
+ setEditorText(ctx.editor, "Updated live content");
+
+ const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
+ forkYDoc.fork({
+ initialUpdate: Y.encodeStateAsUpdateV2(snapshotDoc),
+ });
+
+ expect(getEditorText(ctx.editor)).toBe("Live content");
+
+ forkYDoc.merge({ keepChanges: false });
+
+ expect(getEditorText(ctx.editor)).toBe("Updated live content");
+ });
+
+ it("calling fork() while already forked is a no-op", () => {
+ ctx = createCollabEditor();
+ setEditorText(ctx.editor, "Original");
+
+ const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
+ forkYDoc.fork();
+ setEditorText(ctx.editor, "Forked edit");
+
+ // Second fork should be a no-op
+ forkYDoc.fork();
+ expect(getEditorText(ctx.editor)).toBe("Forked edit");
+ });
+
+ it("isForked store state reflects fork/merge lifecycle", () => {
+ ctx = createCollabEditor();
+ const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
+
+ expect(forkYDoc.store.state.isForked).toBe(false);
+
+ forkYDoc.fork();
+ expect(forkYDoc.store.state.isForked).toBe(true);
+
+ forkYDoc.merge({ keepChanges: false });
+ expect(forkYDoc.store.state.isForked).toBe(false);
+ });
+
+ it("merge() is a no-op when not forked", () => {
+ ctx = createCollabEditor();
+ setEditorText(ctx.editor, "Untouched");
+
+ const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
+
+ // Should not throw or change anything.
+ forkYDoc.merge({ keepChanges: false });
+ forkYDoc.merge({ keepChanges: true });
+
+ expect(getEditorText(ctx.editor)).toBe("Untouched");
+ expect(forkYDoc.store.state.isForked).toBe(false);
+ });
+
+ it("forked doc is a separate Y.Doc from the original", () => {
+ ctx = createCollabEditor();
+ setEditorText(ctx.editor, "Before fork");
+
+ const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
+ forkYDoc.fork();
+
+ // Edit while forked
+ setEditorText(ctx.editor, "Forked edit");
+
+ // The original Y.Doc should not see the forked edit.
+ // Verify by creating a second editor pointing at the same original doc.
+ const secondDoc = new Y.Doc();
+ Y.applyUpdateV2(secondDoc, Y.encodeStateAsUpdateV2(ctx.doc));
+ const secondEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: secondDoc.get("doc"),
+ user: { name: "Peer", color: "#00FF00" },
+ provider: undefined,
+ },
+ }),
+ );
+ const div2 = document.createElement("div");
+ secondEditor.mount(div2);
+
+ // The second editor (synced from original doc) should still show "Before fork"
+ expect(getEditorText(secondEditor)).toBe("Before fork");
+
+ secondEditor.unmount();
+ secondDoc.destroy();
+ });
+
+ it("fork({ initialUpdate }) + merge({ keepChanges: true }) applies forked edits to original", () => {
+ ctx = createCollabEditor();
+ setEditorText(ctx.editor, "Current content");
+
+ // Take a snapshot
+ const snapshotDoc = new Y.Doc();
+ Y.applyUpdateV2(snapshotDoc, Y.encodeStateAsUpdateV2(ctx.doc));
+
+ // Move the live doc forward
+ setEditorText(ctx.editor, "Live content");
+
+ // Fork from the snapshot
+ const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
+ forkYDoc.fork({ initialUpdate: Y.encodeStateAsUpdateV2(snapshotDoc) });
+ expect(getEditorText(ctx.editor)).toBe("Current content");
+
+ // Edit while forked
+ setEditorText(ctx.editor, "Forked modification");
+
+ // Merge and keep changes — the forked edits are applied to the original
+ // doc. Because both fork and original have concurrent edits, the CRDT
+ // merge produces interleaved content rather than a clean replacement.
+ forkYDoc.merge({ keepChanges: true });
+ const text = getEditorText(ctx.editor);
+ // The result should contain text from the forked edit (CRDT merges both).
+ expect(text).toContain("Fork");
+ expect(text).toContain("modification");
+ });
+});
diff --git a/packages/core/src/y/extensions/ForkYDoc.ts b/packages/core/src/y/extensions/ForkYDoc.ts
new file mode 100644
index 0000000000..6d9fcdd8a1
--- /dev/null
+++ b/packages/core/src/y/extensions/ForkYDoc.ts
@@ -0,0 +1,108 @@
+import * as Y from "@y/y";
+import {
+ createExtension,
+ createStore,
+ ExtensionOptions,
+} from "../../editor/BlockNoteExtension.js";
+import { CollaborationOptions } from "./index.js";
+import { YCursorExtension } from "./YCursorPlugin.js";
+import { findTypeInOtherYdoc } from "../utils.js";
+import { configureYProsemirror } from "@y/prosemirror";
+
+export const ForkYDocExtension = createExtension(
+ ({ editor, options }: ExtensionOptions) => {
+ let forkedState:
+ | {
+ originalFragment: Y.Type;
+ forkedFragment: Y.Type;
+ }
+ | undefined = undefined;
+
+ const store = createStore({ isForked: false });
+
+ return {
+ key: "yForkDoc",
+ store,
+ /**
+ * Fork the Y.js document from syncing to the remote,
+ * allowing modifications to the document without affecting the remote.
+ * These changes can later be rolled back or applied to the remote.
+ */
+ fork({
+ /**
+ * The initial update to apply to the forked document.
+ */
+ initialUpdate,
+ }: {
+ initialUpdate?: Uint8Array;
+ } = {}) {
+ if (forkedState) {
+ return;
+ }
+
+ const originalFragment = options.fragment;
+
+ if (!originalFragment) {
+ throw new Error("No fragment to fork from");
+ }
+
+ const doc = new Y.Doc();
+ // Copy the original document to a new Yjs document
+ Y.applyUpdateV2(
+ doc,
+ initialUpdate ?? Y.encodeStateAsUpdateV2(originalFragment.doc!),
+ );
+
+ // Find the forked fragment in the new Yjs document
+ const forkedFragment = findTypeInOtherYdoc(originalFragment, doc);
+
+ forkedState = {
+ originalFragment,
+ forkedFragment,
+ };
+
+ // Need to reset all the yjs plugins
+ editor.unregisterExtension([YCursorExtension]);
+ editor.exec(configureYProsemirror({ ytype: forkedFragment }));
+
+ // Tell the store that the editor is now forked
+ store.setState({ isForked: true });
+ },
+
+ /**
+ * Resume syncing the Y.js document to the remote
+ * If `keepChanges` is true, any changes that have been made to the forked document will be applied to the original document.
+ * Otherwise, the original document will be restored and the changes will be discarded.
+ */
+ merge({ keepChanges }: { keepChanges: boolean }) {
+ if (!forkedState) {
+ return;
+ }
+
+ const { originalFragment, forkedFragment } = forkedState;
+ // Register the plugins again, based on the original fragment (which is still in the original options)
+ editor.registerExtension([YCursorExtension(options)]);
+ editor.exec(
+ configureYProsemirror({
+ ytype: originalFragment,
+ attributionManager: options.attributionManager,
+ }),
+ );
+
+ if (keepChanges) {
+ // Apply any changes that have been made to the fork, onto the original doc
+ const update = Y.encodeStateAsUpdate(
+ forkedFragment.doc!,
+ Y.encodeStateVector(originalFragment.doc!),
+ );
+ // Applying this change will add to the undo stack, allowing it to be undone normally
+ Y.applyUpdate(originalFragment.doc!, update, editor);
+ }
+ // Reset the forked state
+ forkedState = undefined;
+ // Tell the store that the editor is no longer forked
+ store.setState({ isForked: false });
+ },
+ } as const;
+ },
+);
diff --git a/packages/core/src/y/extensions/RelativePositionMapping.test.ts b/packages/core/src/y/extensions/RelativePositionMapping.test.ts
new file mode 100644
index 0000000000..cd89448b76
--- /dev/null
+++ b/packages/core/src/y/extensions/RelativePositionMapping.test.ts
@@ -0,0 +1,418 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, expect, it } from "vite-plus/test";
+import * as Y from "@y/y";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { trackPosition } from "../../api/positionMapping.js";
+import { withCollaboration } from "./index.js";
+
+// Function to sync two documents
+function syncDocs(sourceDoc: Y.Doc, targetDoc: Y.Doc) {
+ const update = Y.encodeStateAsUpdate(sourceDoc);
+ Y.applyUpdate(targetDoc, update);
+}
+
+// Set up two-way sync
+function setupTwoWaySync(doc1: Y.Doc, doc2: Y.Doc) {
+ syncDocs(doc1, doc2);
+ syncDocs(doc2, doc1);
+
+ doc1.on("update", (update: Uint8Array) => {
+ Y.applyUpdate(doc2, update);
+ });
+
+ doc2.on("update", (update: Uint8Array) => {
+ Y.applyUpdate(doc1, update);
+ });
+}
+
+describe.skip("RelativePositionMapping (@y/y)", () => {
+ it("should return the same position when no changes are made", () => {
+ const ydoc = new Y.Doc();
+ const remoteYdoc = new Y.Doc();
+
+ const localEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: ydoc.get("doc"),
+ user: { color: "#ff0000", name: "Local User" },
+ provider: undefined,
+ },
+ }),
+ );
+ const div = document.createElement("div");
+ localEditor.mount(div);
+
+ const remoteEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: remoteYdoc.get("doc"),
+ user: { color: "#ff0000", name: "Remote User" },
+ provider: undefined,
+ },
+ }),
+ );
+
+ const remoteDiv = document.createElement("div");
+ remoteEditor.mount(remoteDiv);
+ setupTwoWaySync(ydoc, remoteYdoc);
+
+ const nodeSize = localEditor.prosemirrorState.doc.nodeSize;
+ const positions: number[] = [];
+ for (let i = 0; i < nodeSize; i++) {
+ positions.push(trackPosition(localEditor, i)());
+ }
+
+ expect(positions).toMatchInlineSnapshot(`
+ [
+ 0,
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ ]
+ `);
+
+ ydoc.destroy();
+ remoteYdoc.destroy();
+ localEditor.unmount();
+ remoteEditor.unmount();
+ });
+ it("should update the local position when collaborating", () => {
+ const ydoc = new Y.Doc();
+ const remoteYdoc = new Y.Doc();
+
+ const localEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: ydoc.get("doc"),
+ user: { color: "#ff0000", name: "Local User" },
+ provider: undefined,
+ },
+ }),
+ );
+ const div = document.createElement("div");
+ localEditor.mount(div);
+
+ const remoteEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: remoteYdoc.get("doc"),
+ user: { color: "#ff0000", name: "Remote User" },
+ provider: undefined,
+ },
+ }),
+ );
+
+ const remoteDiv = document.createElement("div");
+ remoteEditor.mount(remoteDiv);
+ setupTwoWaySync(ydoc, remoteYdoc);
+
+ localEditor.replaceBlocks(localEditor.document, [
+ {
+ type: "paragraph",
+ content: "Hello World",
+ },
+ ]);
+
+ // Store position at "Hello| World"
+ const getCursorPos = trackPosition(localEditor, 6);
+ // Store position at "|Hello World"
+ const getStartPos = trackPosition(localEditor, 3);
+ // Store position at "|Hello World" (but on the right side)
+ const getStartRightPos = trackPosition(localEditor, 3, "right");
+ // Store position at "H|ello World"
+ const getPosAfterPos = trackPosition(localEditor, 4);
+ // Store position at "H|ello World" (but on the right side)
+ const getPosAfterRightPos = trackPosition(localEditor, 4, "right");
+
+ // Insert text at the beginning
+ localEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
+
+ // Position should be updated
+ expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)
+ expect(getStartPos()).toBe(3); // 3
+ expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
+ expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
+ expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
+
+ ydoc.destroy();
+ remoteYdoc.destroy();
+ localEditor.unmount();
+ remoteEditor.unmount();
+ });
+
+ it("should match the same positions", () => {
+ const ydoc = new Y.Doc();
+ const remoteYdoc = new Y.Doc();
+
+ const localEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: ydoc.get("doc"),
+ user: { color: "#ff0000", name: "Local User" },
+ provider: undefined,
+ },
+ }),
+ );
+ const div = document.createElement("div");
+ localEditor.mount(div);
+
+ const remoteEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: remoteYdoc.get("doc"),
+ user: { color: "#ff0000", name: "Remote User" },
+ provider: undefined,
+ },
+ }),
+ );
+
+ const remoteDiv = document.createElement("div");
+ remoteEditor.mount(remoteDiv);
+ setupTwoWaySync(ydoc, remoteYdoc);
+
+ localEditor.replaceBlocks(localEditor.document, [
+ {
+ type: "paragraph",
+ content: "Hello World",
+ },
+ ]);
+
+ const nodeSize = localEditor.prosemirrorState.doc.nodeSize;
+ const positions: (() => number)[] = [];
+ for (let i = 0; i < nodeSize; i++) {
+ positions.push(trackPosition(localEditor, i));
+ }
+
+ localEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
+
+ expect(positions.map((getPos) => getPos())).toMatchInlineSnapshot(`
+ [
+ 0,
+ 1,
+ 2,
+ 3,
+ 9,
+ 10,
+ 11,
+ 12,
+ 13,
+ 14,
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 21,
+ 22,
+ 23,
+ ]
+ `);
+ ydoc.destroy();
+ remoteYdoc.destroy();
+ localEditor.unmount();
+ remoteEditor.unmount();
+ });
+
+ it("should handle multiple transactions when collaborating", () => {
+ const ydoc = new Y.Doc();
+ const remoteYdoc = new Y.Doc();
+
+ const localEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: ydoc.get("doc"),
+ user: { color: "#ff0000", name: "Local User" },
+ provider: undefined,
+ },
+ }),
+ );
+ const div = document.createElement("div");
+ localEditor.mount(div);
+
+ const remoteEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: remoteYdoc.get("doc"),
+ user: { color: "#ff0000", name: "Remote User" },
+ provider: undefined,
+ },
+ }),
+ );
+
+ const remoteDiv = document.createElement("div");
+ remoteEditor.mount(remoteDiv);
+ setupTwoWaySync(ydoc, remoteYdoc);
+
+ localEditor.replaceBlocks(localEditor.document, [
+ {
+ type: "paragraph",
+ content: "Hello World",
+ },
+ ]);
+
+ // Store position at "Hello| World"
+ const getCursorPos = trackPosition(localEditor, 6);
+ // Store position at "|Hello World"
+ const getStartPos = trackPosition(localEditor, 3);
+ // Store position at "|Hello World" (but on the right side)
+ const getStartRightPos = trackPosition(localEditor, 3, "right");
+ // Store position at "H|ello World"
+ const getPosAfterPos = trackPosition(localEditor, 4);
+ // Store position at "H|ello World" (but on the right side)
+ const getPosAfterRightPos = trackPosition(localEditor, 4, "right");
+
+ // Insert text at the beginning
+ localEditor._tiptapEditor.commands.insertContentAt(3, "T");
+ localEditor._tiptapEditor.commands.insertContentAt(4, "e");
+ localEditor._tiptapEditor.commands.insertContentAt(5, "s");
+ localEditor._tiptapEditor.commands.insertContentAt(6, "t");
+ localEditor._tiptapEditor.commands.insertContentAt(7, " ");
+
+ // Position should be updated
+ expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)
+ expect(getStartPos()).toBe(3); // 3
+ expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
+ expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
+ expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
+
+ ydoc.destroy();
+ remoteYdoc.destroy();
+ localEditor.unmount();
+ remoteEditor.unmount();
+ });
+
+ it("should update the local position from a remote transaction", () => {
+ const ydoc = new Y.Doc();
+ const remoteYdoc = new Y.Doc();
+
+ const localEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: ydoc.get("doc"),
+ user: { color: "#ff0000", name: "Local User" },
+ provider: undefined,
+ },
+ }),
+ );
+ const div = document.createElement("div");
+ localEditor.mount(div);
+
+ const remoteEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: remoteYdoc.get("doc"),
+ user: { color: "#ff0000", name: "Remote User" },
+ provider: undefined,
+ },
+ }),
+ );
+
+ const remoteDiv = document.createElement("div");
+ remoteEditor.mount(remoteDiv);
+ setupTwoWaySync(ydoc, remoteYdoc);
+
+ remoteEditor.replaceBlocks(remoteEditor.document, [
+ {
+ type: "paragraph",
+ content: "Hello World",
+ },
+ ]);
+
+ // Store position at "Hello| World"
+ const getCursorPos = trackPosition(localEditor, 6);
+ // Store position at "|Hello World"
+ const getStartPos = trackPosition(localEditor, 3);
+ // Store position at "|Hello World" (but on the right side)
+ const getStartRightPos = trackPosition(localEditor, 3, "right");
+ // Store position at "H|ello World"
+ const getPosAfterPos = trackPosition(localEditor, 4);
+ // Store position at "H|ello World" (but on the right side)
+ const getPosAfterRightPos = trackPosition(localEditor, 4, "right");
+
+ // Insert text at the beginning
+ localEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
+
+ // Position should be updated
+ expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)
+ expect(getStartPos()).toBe(3); // 3
+ expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
+ expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
+ expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
+
+ ydoc.destroy();
+ remoteYdoc.destroy();
+ localEditor.unmount();
+ remoteEditor.unmount();
+ });
+
+ it("should update the remote position from a remote transaction", () => {
+ const ydoc = new Y.Doc();
+ const remoteYdoc = new Y.Doc();
+
+ const localEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: ydoc.get("doc"),
+ user: { color: "#ff0000", name: "Local User" },
+ provider: undefined,
+ },
+ }),
+ );
+ const div = document.createElement("div");
+ localEditor.mount(div);
+
+ const remoteEditor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: remoteYdoc.get("doc"),
+ user: { color: "#ff0000", name: "Remote User" },
+ provider: undefined,
+ },
+ }),
+ );
+
+ const remoteDiv = document.createElement("div");
+ remoteEditor.mount(remoteDiv);
+ setupTwoWaySync(ydoc, remoteYdoc);
+
+ remoteEditor.replaceBlocks(remoteEditor.document, [
+ {
+ type: "paragraph",
+ content: "Hello World",
+ },
+ ]);
+
+ // Store position at "Hello| World"
+ const getCursorPos = trackPosition(remoteEditor, 6);
+ // Store position at "|Hello World"
+ const getStartPos = trackPosition(remoteEditor, 3);
+ // Store position at "|Hello World" (but on the right side)
+ const getStartRightPos = trackPosition(remoteEditor, 3, "right");
+ // Store position at "H|ello World"
+ const getPosAfterPos = trackPosition(remoteEditor, 4);
+ // Store position at "H|ello World" (but on the right side)
+ const getPosAfterRightPos = trackPosition(remoteEditor, 4, "right");
+
+ // Insert text at the beginning
+ localEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
+
+ // Position should be updated
+ expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)
+ expect(getStartPos()).toBe(3); // 3
+ expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
+ expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
+ expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
+
+ ydoc.destroy();
+ remoteYdoc.destroy();
+ localEditor.unmount();
+ remoteEditor.unmount();
+ });
+});
diff --git a/packages/core/src/y/extensions/RelativePositionMapping.ts b/packages/core/src/y/extensions/RelativePositionMapping.ts
new file mode 100644
index 0000000000..95b36ba63d
--- /dev/null
+++ b/packages/core/src/y/extensions/RelativePositionMapping.ts
@@ -0,0 +1,49 @@
+import { relativePositionStore, ySyncPluginKey } from "@y/prosemirror";
+import { createExtension } from "../../editor/BlockNoteExtension.js";
+
+export const RelativePositionMappingExtension = createExtension(
+ ({ editor }) => {
+ return {
+ key: "yPositionMapping",
+ mapPosition: (position: number, side: "left" | "right" = "left") => {
+ const ySyncPluginState = ySyncPluginKey.getState(
+ editor.prosemirrorState,
+ );
+ if (!ySyncPluginState?.ytype) {
+ throw new Error("YSync plugin state not found");
+ }
+
+ // 0 is a special case & always should map to itself
+ if (position === 0) {
+ return () => 0;
+ }
+
+ const posStore = relativePositionStore(
+ editor.prosemirrorState.doc.resolve(
+ position + (side === "right" ? 1 : -1),
+ ),
+ ySyncPluginState.ytype,
+ ySyncPluginState.attributionManager,
+ );
+
+ return () => {
+ const curYSyncPluginState = ySyncPluginKey.getState(
+ editor.prosemirrorState,
+ ) as typeof ySyncPluginState;
+ const pos = posStore(
+ editor.prosemirrorState.doc,
+ curYSyncPluginState.ytype,
+ curYSyncPluginState.attributionManager,
+ );
+
+ // This can happen if the element is garbage collected
+ if (pos === null) {
+ throw new Error("Position not found, cannot track positions");
+ }
+
+ return pos + (side === "right" ? -1 : 1);
+ };
+ },
+ } as const;
+ },
+);
diff --git a/packages/core/src/y/extensions/Suggestions.ts b/packages/core/src/y/extensions/Suggestions.ts
new file mode 100644
index 0000000000..c04d142619
--- /dev/null
+++ b/packages/core/src/y/extensions/Suggestions.ts
@@ -0,0 +1,170 @@
+import { getMarkRange, posToDOMRect } from "@tiptap/core";
+import * as Y from "@y/y";
+
+import {
+ createExtension,
+ ExtensionOptions,
+} from "../../editor/BlockNoteExtension.js";
+import {
+ acceptChanges,
+ rejectAllChanges,
+ rejectChanges,
+ configureYProsemirror,
+ acceptAllChanges,
+} from "@y/prosemirror";
+import { CollaborationOptions } from "./index.js";
+import { findTypeInOtherYdoc } from "../utils.js";
+
+export const SuggestionsExtension = createExtension(
+ ({ editor, options }: ExtensionOptions) => {
+ const suggestionDoc = options.suggestionDoc;
+ if (!suggestionDoc) {
+ throw new Error("Suggestion doc not found");
+ }
+
+ function getSuggestionElementAtPos(pos: number) {
+ let currentNode = editor.prosemirrorView.nodeDOM(pos);
+ while (currentNode && currentNode.parentElement) {
+ if (currentNode.nodeName === "INS" || currentNode.nodeName === "DEL") {
+ return currentNode as HTMLElement;
+ }
+ currentNode = currentNode.parentElement;
+ }
+ return null;
+ }
+
+ function getMarkAtPos(pos: number, markType: string) {
+ return editor.transact((tr) => {
+ const resolvedPos = tr.doc.resolve(pos);
+ const mark = resolvedPos
+ .marks()
+ .find((mark) => mark.type.name === markType);
+
+ if (!mark) {
+ return;
+ }
+
+ const markRange = getMarkRange(resolvedPos, mark.type);
+ if (!markRange) {
+ return;
+ }
+
+ return {
+ range: markRange,
+ mark,
+ get text() {
+ return tr.doc.textBetween(markRange.from, markRange.to);
+ },
+ get position() {
+ // to minimize re-renders, we convert to JSON, which is the same shape anyway
+ return posToDOMRect(
+ editor.prosemirrorView,
+ markRange.from,
+ markRange.to,
+ ).toJSON() as DOMRect;
+ },
+ };
+ });
+ }
+
+ function getSuggestionAtSelection() {
+ return editor.transact((tr) => {
+ const selection = tr.selection;
+ if (!selection.empty) {
+ return undefined;
+ }
+ return (
+ getMarkAtPos(selection.anchor, "insertion") ||
+ getMarkAtPos(selection.anchor, "deletion") ||
+ getMarkAtPos(selection.anchor, "modification")
+ );
+ });
+ }
+
+ return {
+ key: "suggestions",
+ runsBefore: ["ySync"],
+ viewSuggestions: () => {
+ if (options.attributionManager) {
+ options.attributionManager.suggestionMode = false;
+ }
+ editor.exec(
+ configureYProsemirror({
+ ytype: findTypeInOtherYdoc(options.fragment, suggestionDoc),
+ attributionManager: options.attributionManager,
+ }),
+ );
+ },
+ enableSuggestions: () => {
+ if (options.attributionManager) {
+ options.attributionManager.suggestionMode = true;
+ }
+ editor.exec(
+ configureYProsemirror({
+ ytype: findTypeInOtherYdoc(options.fragment, suggestionDoc),
+ attributionManager: options.attributionManager,
+ }),
+ );
+ },
+ disableSuggestions: () => {
+ editor.exec(
+ configureYProsemirror({
+ ytype: options.fragment,
+ attributionManager: Y.noAttributionsManager,
+ }),
+ );
+ },
+ applyAllSuggestions: () => {
+ return editor.exec(acceptAllChanges());
+ },
+ applySuggestion: (start: number, end?: number) => {
+ return editor.exec(acceptChanges(start, end));
+ },
+ revertSuggestion: (start: number, end?: number) => {
+ return editor.exec(rejectChanges(start, end));
+ },
+ revertAllSuggestions: () => {
+ return editor.exec(rejectAllChanges());
+ },
+
+ getSuggestionElementAtPos,
+ getMarkAtPos,
+ getSuggestionAtSelection,
+ getSuggestionAtCoords: (coords: { left: number; top: number }) => {
+ return editor.transact(() => {
+ const posAtCoords = editor.prosemirrorView.posAtCoords(coords);
+ if (posAtCoords === null || posAtCoords?.inside === -1) {
+ return undefined;
+ }
+
+ return (
+ getMarkAtPos(posAtCoords.pos, "y-attributed-insert") ||
+ getMarkAtPos(posAtCoords.pos, "y-attributed-delete") ||
+ getMarkAtPos(posAtCoords.pos, "y-attributed-format")
+ );
+ });
+ },
+ checkUnresolvedSuggestions: () => {
+ let hasUnresolvedSuggestions = false;
+
+ editor.prosemirrorState.doc.descendants((node) => {
+ if (hasUnresolvedSuggestions) {
+ return false;
+ }
+
+ hasUnresolvedSuggestions =
+ node.marks.findIndex(
+ (mark) =>
+ mark.type.name === "y-attributed-insert" ||
+ mark.type.name === "y-attributed-delete" ||
+ mark.type.name === "y-attributed-format",
+ ) !== -1;
+
+ return true;
+ });
+
+ return hasUnresolvedSuggestions;
+ },
+ } as const;
+ },
+);
diff --git a/packages/core/src/y/extensions/Versioning.test.ts b/packages/core/src/y/extensions/Versioning.test.ts
new file mode 100644
index 0000000000..421f84e584
--- /dev/null
+++ b/packages/core/src/y/extensions/Versioning.test.ts
@@ -0,0 +1,393 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { afterEach, describe, expect, it } from "vite-plus/test";
+import * as Y from "@y/y";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { VersioningExtension } from "../../extensions/Versioning/index.js";
+import type { VersioningEndpoints } from "../../extensions/Versioning/index.js";
+import { withCollaboration } from "./index.js";
+import { createYjsVersioningAdapter } from "./Versioning.js";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Simple in-memory Yjs versioning endpoints for tests.
+ * Stores snapshots and their binary content in plain Maps.
+ */
+function createInMemoryYjsEndpoints(): VersioningEndpoints {
+ const snapshots = new Map<
+ string,
+ {
+ id: string;
+ name?: string;
+ createdAt: number;
+ updatedAt: number;
+ restoredFromSnapshotId?: string;
+ }
+ >();
+ const contents = new Map();
+
+ return {
+ list: async () =>
+ [...snapshots.values()].sort((a, b) => b.createdAt - a.createdAt),
+ create: async (fragment, options) => {
+ const snapshot = {
+ id: crypto.randomUUID(),
+ name: options?.name,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ restoredFromSnapshotId: options?.restoredFromSnapshot?.id,
+ };
+ contents.set(snapshot.id, Y.encodeStateAsUpdateV2(fragment.doc!));
+ snapshots.set(snapshot.id, snapshot);
+ return snapshot;
+ },
+ getContent: async (snapshot) => {
+ const data = contents.get(snapshot.id);
+ if (!data) {
+ throw new Error(`Snapshot ${snapshot.id} not found`);
+ }
+ return data;
+ },
+ restore: async (fragment, snapshot) => {
+ // Create backup
+ const backup = {
+ id: crypto.randomUUID(),
+ name: "Backup",
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+ contents.set(backup.id, Y.encodeStateAsUpdateV2(fragment.doc!));
+ snapshots.set(backup.id, backup);
+
+ const snapshotContent = contents.get(snapshot.id)!;
+ const tempDoc = new Y.Doc();
+ Y.applyUpdateV2(tempDoc, snapshotContent);
+
+ const restored = {
+ id: crypto.randomUUID(),
+ name: "Restored Snapshot",
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ restoredFromSnapshotId: snapshot.id,
+ };
+ contents.set(restored.id, Y.encodeStateAsUpdateV2(tempDoc));
+ snapshots.set(restored.id, restored);
+ tempDoc.destroy();
+
+ return snapshotContent;
+ },
+ updateSnapshotName: async (snapshot, name) => {
+ const s = snapshots.get(snapshot.id);
+ if (!s) {
+ throw new Error(`Snapshot ${snapshot.id} not found`);
+ }
+ s.name = name;
+ s.updatedAt = Date.now();
+ },
+ };
+}
+
+/** Create a collaborative editor with versioning, mounted to a jsdom div. */
+function createCollabEditor(opts?: { withVersioning?: boolean }) {
+ const doc = new Y.Doc();
+ const fragment = doc.get("doc");
+ const endpoints = createInMemoryYjsEndpoints();
+
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment,
+ user: { name: "Test User", color: "#ff0000" },
+ provider: undefined,
+ versioningEndpoints:
+ opts?.withVersioning !== false ? endpoints : undefined,
+ },
+ }),
+ );
+
+ const div = document.createElement("div");
+ editor.mount(div);
+
+ return { editor, doc, fragment, endpoints };
+}
+
+/** Clean up an editor and its Y.Doc. */
+function cleanup(ctx: { editor: BlockNoteEditor; doc: Y.Doc }) {
+ ctx.editor.unmount();
+ ctx.doc.destroy();
+}
+
+/** Get the editor's current ProseMirror doc text content. */
+function getEditorText(editor: BlockNoteEditor): string {
+ return editor.prosemirrorState.doc.textContent;
+}
+
+// ---------------------------------------------------------------------------
+// Tests: createYjsVersioningAdapter (unit-level)
+// ---------------------------------------------------------------------------
+
+describe("createYjsVersioningAdapter", () => {
+ let ctx: ReturnType;
+
+ afterEach(() => {
+ if (ctx) {
+ cleanup(ctx);
+ }
+ });
+
+ it("getCurrentState returns the fragment passed to the adapter", () => {
+ ctx = createCollabEditor();
+ const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
+ const state = adapter.getCurrentState();
+
+ expect(state).toBe(ctx.fragment);
+ expect(state.doc).toBe(ctx.doc);
+ });
+
+ it("enterPreview reconfigures the editor to show snapshot content", () => {
+ ctx = createCollabEditor();
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Original content" },
+ ]);
+ const snapshotData = Y.encodeStateAsUpdateV2(ctx.doc);
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Modified content" },
+ ]);
+
+ const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
+ adapter.preview.enterPreview(snapshotData);
+
+ expect(getEditorText(ctx.editor)).toContain("Original content");
+ expect(getEditorText(ctx.editor)).not.toContain("Modified");
+ });
+
+ it("exitPreview resumes sync, showing the live document", () => {
+ ctx = createCollabEditor();
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Snapshot state" },
+ ]);
+ const snapshotData = Y.encodeStateAsUpdateV2(ctx.doc);
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Current state" },
+ ]);
+
+ const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
+ adapter.preview.enterPreview(snapshotData);
+ expect(getEditorText(ctx.editor)).toContain("Snapshot state");
+
+ adapter.preview.exitPreview();
+ expect(getEditorText(ctx.editor)).toContain("Current state");
+ });
+
+ it("successive enterPreview calls switch between snapshots", () => {
+ ctx = createCollabEditor();
+
+ // Create snapshot A
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Snapshot A" },
+ ]);
+ const snapshotA = Y.encodeStateAsUpdateV2(ctx.doc);
+
+ // Create snapshot B
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Snapshot B" },
+ ]);
+ const snapshotB = Y.encodeStateAsUpdateV2(ctx.doc);
+
+ // Move to current content
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Current" },
+ ]);
+
+ const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
+
+ // Preview A
+ adapter.preview.enterPreview(snapshotA);
+ expect(getEditorText(ctx.editor)).toContain("Snapshot A");
+
+ // Switch to B without exiting first
+ adapter.preview.enterPreview(snapshotB);
+ expect(getEditorText(ctx.editor)).toContain("Snapshot B");
+
+ // Exit should restore the live doc
+ adapter.preview.exitPreview();
+ expect(getEditorText(ctx.editor)).toContain("Current");
+ });
+
+ it("exitPreview is a no-op when not previewing", () => {
+ ctx = createCollabEditor();
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Content" },
+ ]);
+
+ const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
+
+ // Should not throw or change anything
+ adapter.preview.exitPreview();
+ expect(getEditorText(ctx.editor)).toContain("Content");
+ });
+
+ it("applyRestore is a no-op (server-side restore propagates via live sync)", () => {
+ ctx = createCollabEditor();
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Content" },
+ ]);
+
+ const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
+
+ // Should not throw and should leave the live document untouched.
+ expect(() => adapter.preview.applyRestore(new Uint8Array())).not.toThrow();
+ expect(getEditorText(ctx.editor)).toContain("Content");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests: Full integration with VersioningExtension + localStorageEndpoints
+// ---------------------------------------------------------------------------
+
+describe("Yjs versioning integration (VersioningExtension + in-memory endpoints)", () => {
+ let ctx: ReturnType;
+
+ afterEach(() => {
+ if (ctx) {
+ cleanup(ctx);
+ }
+ });
+
+ it("previews a snapshot, showing the old content in the editor", async () => {
+ ctx = createCollabEditor();
+ const versioning = ctx.editor.getExtension(VersioningExtension)!;
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Snapshot content" },
+ ]);
+ const snapshot = await versioning.createSnapshot!({ name: "v1" });
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Current content" },
+ ]);
+
+ await versioning.previewSnapshot(snapshot.id);
+
+ expect(versioning.store.state.previewedSnapshotId).toBe(snapshot.id);
+ expect(getEditorText(ctx.editor)).toContain("Snapshot content");
+ expect(getEditorText(ctx.editor)).not.toContain("Current");
+ });
+
+ it("exits preview and returns to live document", async () => {
+ ctx = createCollabEditor();
+ const versioning = ctx.editor.getExtension(VersioningExtension)!;
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Saved state" },
+ ]);
+ const snapshot = await versioning.createSnapshot!({ name: "v1" });
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Live state" },
+ ]);
+
+ await versioning.previewSnapshot(snapshot.id);
+ versioning.exitPreview();
+
+ expect(getEditorText(ctx.editor)).toContain("Live state");
+ });
+
+ it("full workflow: create, browse, preview, exit", async () => {
+ ctx = createCollabEditor();
+ const versioning = ctx.editor.getExtension(VersioningExtension)!;
+
+ // Create two versions
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Version 1" },
+ ]);
+ const v1 = await versioning.createSnapshot!({ name: "v1" });
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Version 2" },
+ ]);
+ const v2 = await versioning.createSnapshot!({ name: "v2" });
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Current state" },
+ ]);
+
+ // List and verify ordering
+ const list = await versioning.listSnapshots();
+ expect(list).toHaveLength(2);
+ expect(list[0]!.id).toBe(v2.id);
+
+ // Browse previews
+ await versioning.previewSnapshot(v1.id);
+ expect(getEditorText(ctx.editor)).toContain("Version 1");
+
+ await versioning.previewSnapshot(v2.id, { compareTo: v1.id });
+ expect(getEditorText(ctx.editor).length).toBeGreaterThan(0);
+
+ // Exit back to live
+ versioning.exitPreview();
+ expect(getEditorText(ctx.editor)).toContain("Current state");
+ });
+
+ it("restoreSnapshot resolves with the restored snapshot content", async () => {
+ ctx = createCollabEditor();
+ const versioning = ctx.editor.getExtension(VersioningExtension)!;
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Content" },
+ ]);
+ const snap = await versioning.createSnapshot!({ name: "v1" });
+
+ // applyRestore is a no-op for the Yjs adapter (the backend applies the
+ // restore and the change propagates over live sync), so restoreSnapshot
+ // resolves with the snapshot content returned by the endpoint.
+ const content = await versioning.restoreSnapshot!(snap.id);
+ expect(content).toBeInstanceOf(Uint8Array);
+ });
+
+ it("previewing multiple snapshots and switching between them", async () => {
+ ctx = createCollabEditor();
+ const versioning = ctx.editor.getExtension(VersioningExtension)!;
+
+ // Create three versions at different points
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Version 1" },
+ ]);
+ const v1 = await versioning.createSnapshot!({ name: "v1" });
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Version 2" },
+ ]);
+ const v2 = await versioning.createSnapshot!({ name: "v2" });
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Version 3" },
+ ]);
+ await versioning.createSnapshot!({ name: "v3" });
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Current live" },
+ ]);
+
+ // Preview older, then newer
+ await versioning.previewSnapshot(v1.id);
+ expect(getEditorText(ctx.editor)).toContain("Version 1");
+
+ await versioning.previewSnapshot(v2.id);
+ expect(getEditorText(ctx.editor)).toContain("Version 2");
+ expect(versioning.store.state.previewedSnapshotId).toBe(v2.id);
+
+ // Exit back to live
+ versioning.exitPreview();
+ expect(getEditorText(ctx.editor)).toContain("Current live");
+ });
+});
diff --git a/packages/core/src/y/extensions/Versioning.ts b/packages/core/src/y/extensions/Versioning.ts
new file mode 100644
index 0000000000..b4c44485b0
--- /dev/null
+++ b/packages/core/src/y/extensions/Versioning.ts
@@ -0,0 +1,84 @@
+import { configureYProsemirror } from "@y/prosemirror";
+import * as Y from "@y/y";
+
+import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import type { PreviewController } from "../../extensions/Versioning/index.js";
+import { findTypeInOtherYdoc } from "../utils.js";
+
+/**
+ * Creates a Yjs-specific adapter that provides the {@link PreviewController}
+ * and `getCurrentState` callback required by the base
+ * {@link VersioningExtension}.
+ *
+ * This is wired automatically by the {@link CollaborationExtension} when
+ * `versioningEndpoints` is provided. You only need to call this directly if
+ * you're using the `VersioningExtension` outside of the collaboration wrapper.
+ */
+export function createYjsVersioningAdapter(
+ editor: BlockNoteEditor,
+ fragment: Y.Type,
+): {
+ preview: PreviewController;
+ getCurrentState: () => Y.Type;
+ getCurrentContent: () => Uint8Array;
+} {
+ return {
+ getCurrentState: () => fragment,
+ // Serialise the live document as a V2 update — the same format that
+ // `getContent` returns (via `convertUpdateFormatV1ToV2`) and that
+ // `enterPreview` consumes (`applyUpdateV2`). Used to render a read-only
+ // diff of the live document against a snapshot.
+ getCurrentContent: () => Y.encodeStateAsUpdateV2(fragment.doc!),
+ preview: {
+ enterPreview: (
+ snapshotContent: Uint8Array,
+ compareToContent?: Uint8Array,
+ attributions?: Y.ContentMap,
+ ) => {
+ let prevSnapshot: { fragment: Y.Type } | undefined;
+ if (compareToContent) {
+ const compareToDoc = new Y.Doc({ isSuggestionDoc: true });
+ Y.applyUpdateV2(compareToDoc, compareToContent);
+ prevSnapshot = {
+ fragment: findTypeInOtherYdoc(fragment, compareToDoc),
+ };
+ }
+
+ const doc = new Y.Doc();
+ Y.applyUpdateV2(doc, snapshotContent);
+ editor.exec(
+ configureYProsemirror({
+ ytype: findTypeInOtherYdoc(fragment, doc),
+ // Pass the optional content map as `attrs` so the diff attribution
+ // manager knows who/when authored each change. Without it, the AM
+ // only produces "what changed" (empty userIds, null timestamps) and
+ // downstream mark tooltips show "unknown / unknown time".
+ attributionManager: prevSnapshot
+ ? Y.createAttributionManagerFromDiff(
+ prevSnapshot.fragment.doc!,
+ doc,
+ attributions ? { attrs: attributions } : undefined,
+ )
+ : undefined,
+ }),
+ );
+ },
+ exitPreview: () => {
+ editor.exec(configureYProsemirror({ ytype: fragment }));
+ },
+ applyRestore: (_snapshotContent: Uint8Array) => {
+ // For Yjs-backed versioning, restoration happens on the server (e.g.
+ // YHub's `/rollback` endpoint) which publishes a reverting update to
+ // the document's room. That update propagates back to this client over
+ // the live sync connection and updates `fragment` automatically, so
+ // there is nothing to apply locally — we only need to leave preview
+ // mode. `exitPreview` is already called by the base extension before
+ // this runs, so this is a no-op.
+ //
+ // Note: this assumes `endpoints.restore` performs the server-side
+ // restore. The default in-memory adapter has no server, which is why
+ // this is specific to the Yjs collaboration setup.
+ },
+ },
+ };
+}
diff --git a/packages/core/src/y/extensions/YCursorPlugin.ts b/packages/core/src/y/extensions/YCursorPlugin.ts
new file mode 100644
index 0000000000..c847df083f
--- /dev/null
+++ b/packages/core/src/y/extensions/YCursorPlugin.ts
@@ -0,0 +1,189 @@
+import { defaultSelectionBuilder, yCursorPlugin } from "@y/prosemirror";
+import {
+ createExtension,
+ ExtensionOptions,
+} from "../../editor/BlockNoteExtension.js";
+import { CollaborationOptions } from "./index.js";
+
+export type CollaborationUser = {
+ id?: string;
+ name: string;
+ color: string;
+ [key: string]: unknown;
+};
+
+/**
+ * Determine whether the foreground color should be white or black based on a provided background color
+ * Inspired by: https://stackoverflow.com/a/3943023
+ */
+function isDarkColor(bgColor: string): boolean {
+ const color = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor;
+ const r = parseInt(color.substring(0, 2), 16); // hexToR
+ const g = parseInt(color.substring(2, 4), 16); // hexToG
+ const b = parseInt(color.substring(4, 6), 16); // hexToB
+ const uicolors = [r / 255, g / 255, b / 255];
+ const c = uicolors.map((col) => {
+ if (col <= 0.03928) {
+ return col / 12.92;
+ }
+ return Math.pow((col + 0.055) / 1.055, 2.4);
+ });
+ const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
+ return L <= 0.179;
+}
+
+function defaultCursorRender(user: CollaborationUser) {
+ const cursorElement = document.createElement("span");
+
+ cursorElement.classList.add("bn-collaboration-cursor__base");
+
+ const caretElement = document.createElement("span");
+ caretElement.setAttribute("contentedEditable", "false");
+ caretElement.classList.add("bn-collaboration-cursor__caret");
+ caretElement.setAttribute(
+ "style",
+ `background-color: ${user.color}; color: ${
+ isDarkColor(user.color) ? "white" : "black"
+ }`,
+ );
+
+ const labelElement = document.createElement("span");
+
+ labelElement.classList.add("bn-collaboration-cursor__label");
+ labelElement.setAttribute(
+ "style",
+ `background-color: ${user.color}; color: ${
+ isDarkColor(user.color) ? "white" : "black"
+ }`,
+ );
+ labelElement.insertBefore(document.createTextNode(user.name), null);
+
+ caretElement.insertBefore(labelElement, null);
+
+ cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space
+ cursorElement.insertBefore(caretElement, null);
+ cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space
+
+ return cursorElement;
+}
+
+export const YCursorExtension = createExtension(
+ ({ options }: ExtensionOptions) => {
+ const recentlyUpdatedCursors = new Map();
+ const awareness =
+ options.provider &&
+ "awareness" in options.provider &&
+ typeof options.provider.awareness === "object"
+ ? options.provider.awareness
+ : undefined;
+ if (awareness) {
+ if (
+ "setLocalStateField" in awareness &&
+ typeof awareness.setLocalStateField === "function"
+ ) {
+ awareness.setLocalStateField("user", options.user);
+ }
+ if ("on" in awareness && typeof awareness.on === "function") {
+ if (options.showCursorLabels !== "always") {
+ awareness.on(
+ "change",
+ ({
+ updated,
+ }: {
+ added: Array;
+ updated: Array;
+ removed: Array;
+ }) => {
+ for (const clientID of updated) {
+ const cursor = recentlyUpdatedCursors.get(clientID);
+
+ if (cursor) {
+ setTimeout(() => {
+ cursor.element.setAttribute("data-active", "");
+ }, 10);
+
+ if (cursor.hideTimeout) {
+ clearTimeout(cursor.hideTimeout);
+ }
+
+ recentlyUpdatedCursors.set(clientID, {
+ element: cursor.element,
+ hideTimeout: setTimeout(() => {
+ cursor.element.removeAttribute("data-active");
+ }, 2000),
+ });
+ }
+ }
+ },
+ );
+ }
+ }
+ }
+
+ return {
+ key: "yCursor",
+ prosemirrorPlugins: [
+ awareness
+ ? yCursorPlugin(awareness, {
+ selectionBuilder: defaultSelectionBuilder,
+ cursorBuilder(user, clientID) {
+ let cursorData = recentlyUpdatedCursors.get(clientID);
+
+ if (!cursorData) {
+ const cursorElement = (
+ options.renderCursor ?? defaultCursorRender
+ )(user as CollaborationUser);
+
+ if (options.showCursorLabels !== "always") {
+ cursorElement.addEventListener("mouseenter", () => {
+ const cursor = recentlyUpdatedCursors.get(clientID)!;
+ cursor.element.setAttribute("data-active", "");
+
+ if (cursor.hideTimeout) {
+ clearTimeout(cursor.hideTimeout);
+ recentlyUpdatedCursors.set(clientID, {
+ element: cursor.element,
+ hideTimeout: undefined,
+ });
+ }
+ });
+
+ cursorElement.addEventListener("mouseleave", () => {
+ const cursor = recentlyUpdatedCursors.get(clientID)!;
+
+ recentlyUpdatedCursors.set(clientID, {
+ element: cursor.element,
+ hideTimeout: setTimeout(() => {
+ cursor.element.removeAttribute("data-active");
+ }, 2000),
+ });
+ });
+ }
+
+ cursorData = {
+ element: cursorElement,
+ hideTimeout: undefined,
+ };
+
+ recentlyUpdatedCursors.set(clientID, cursorData);
+ }
+
+ return cursorData.element;
+ },
+ })
+ : undefined,
+ ].filter((a) => a !== undefined),
+ dependsOn: ["ySync"],
+ updateUser(user: CollaborationUser) {
+ awareness?.setLocalStateField("user", user);
+ },
+ getUser(): CollaborationUser | undefined {
+ const state = awareness?.getLocalState();
+ if (!state) {
+ return undefined;
+ }
+ return state["user"];
+ },
+ } as const;
+ },
+);
diff --git a/packages/core/src/y/extensions/YSync.ts b/packages/core/src/y/extensions/YSync.ts
new file mode 100644
index 0000000000..b43b000ac6
--- /dev/null
+++ b/packages/core/src/y/extensions/YSync.ts
@@ -0,0 +1,163 @@
+import { configureYProsemirror, syncPlugin } from "@y/prosemirror";
+import {
+ type ExtensionOptions,
+ createExtension,
+} from "../../editor/BlockNoteExtension.js";
+import { blockMatchNodes } from "./blockMatchNodes.js";
+import { CollaborationOptions } from "./index.js";
+
+/**
+ * Deterministic hash of a string to an unsigned 32-bit integer.
+ */
+const hashStr = (s: string): number => {
+ let hash = 0;
+ for (let i = 0; i < s.length; i++) {
+ hash = Math.imul(31, hash) + s.charCodeAt(i);
+ }
+ return Math.abs(hash);
+};
+
+/**
+ * Pick a deterministic user-color from a palette based on user ids.
+ * Must be deterministic so the sync plugin's readback matches the mapper output.
+ */
+const userColorPalette: Array<{ light: string; dark: string }> = [
+ { light: "#fff0c2", dark: "#8a6d1a" },
+ { light: "#fcc9c3", dark: "#8a2e24" },
+ { light: "#d4e8eb", dark: "#4a7178" },
+ { light: "#c2eeff", dark: "#1a6e8a" },
+ { light: "#bef3ff", dark: "#0a7a8a" },
+];
+
+const colorsForUserIds = (
+ userIds: readonly string[] | undefined | null,
+): { light: string; dark: string } => {
+ if (!userIds || userIds.length === 0) {
+ return userColorPalette[0];
+ }
+ return userColorPalette[hashStr(userIds[0]) % userColorPalette.length];
+};
+
+/**
+ * Map a Y attribution to BlockNote's `y-attributed-*` mark attrs.
+ *
+ * The mapper must be deterministic in `(format, attribution)` and emit
+ * attrs that exactly match the declared mark schema in SuggestionMarks.ts.
+ * Any mismatch causes the sync plugin to fire phantom reconcile dispatches
+ * in a loop. See ATTRIBUTION.md in @y/prosemirror.
+ *
+ * Declared attrs per mark (all three are the same shape):
+ * - y-attributed-insert: { id, "user-color-light", "user-color-dark" }
+ * - y-attributed-delete: { id, "user-color-light", "user-color-dark" }
+ * - y-attributed-format: { id, "user-color-light", "user-color-dark" }
+ */
+const mapAttributionToMark = (
+ format: Record | null,
+ attribution: {
+ insert?: readonly string[];
+ delete?: readonly string[];
+ format?: Record;
+ insertAt?: number;
+ deleteAt?: number;
+ formatAt?: number;
+ },
+): Record => {
+ const out: Record = { ...format };
+
+ if (attribution.insert) {
+ const colors = colorsForUserIds(attribution.insert);
+ out["y-attributed-insert"] = {
+ userIds: attribution.insert,
+ "user-color-light": colors.light,
+ "user-color-dark": colors.dark,
+ };
+ }
+
+ if (attribution.delete) {
+ const colors = colorsForUserIds(attribution.delete);
+ out["y-attributed-delete"] = {
+ userIds: attribution.delete,
+ "user-color-light": colors.light,
+ "user-color-dark": colors.dark,
+ };
+ }
+
+ if (attribution.format) {
+ const userIds = [...new Set(Object.values(attribution.format).flat())];
+ const colors = colorsForUserIds(userIds);
+ out["y-attributed-format"] = {
+ userIds,
+ format: attribution.format,
+ "user-color-light": colors.light,
+ "user-color-dark": colors.dark,
+ };
+ }
+
+ return out;
+};
+
+export const YSyncExtension = createExtension(
+ ({
+ options,
+ editor,
+ }: ExtensionOptions<
+ Pick<
+ CollaborationOptions,
+ "fragment" | "attributionManager" | "suggestionDoc" | "provider"
+ >
+ >) => {
+ return {
+ key: "ySync",
+ mount: () => {
+ const configure = () => {
+ editor.exec(
+ configureYProsemirror({
+ ytype: options.fragment,
+ attributionManager: options.attributionManager,
+ }),
+ );
+ };
+
+ if (
+ options.provider &&
+ "synced" in options.provider &&
+ typeof options.provider.synced === "boolean"
+ ) {
+ if (options.provider["synced"]) {
+ configure();
+ } else if (
+ "on" in options.provider &&
+ typeof options.provider.on === "function"
+ ) {
+ options.provider.on("synced", (synced: boolean) => {
+ if (synced) {
+ configure();
+ }
+ });
+ } else {
+ throw new Error(
+ "YSyncExtension: provider must have a 'synced' boolean or an 'on' method to listen for 'sync'",
+ );
+ }
+ } else {
+ configure();
+ }
+ },
+ prosemirrorPlugins: [
+ syncPlugin({
+ suggestionDoc: options.suggestionDoc,
+ mapAttributionToMark,
+ // Node-pairing policy for the PM->Y diff: a `blockContainer` whose
+ // block-content type changes is treated as a *different* node, so the
+ // diff replaces the whole container (deleted + inserted siblings in
+ // the blockGroup) instead of producing two block-contents in one
+ // container => schema-invalid. No schema change / storage transform
+ // needed; `blockContainer` already whitelists the `y-attributed-*`
+ // marks. See blockMatchNodes.ts.
+ customCompare: blockMatchNodes,
+ }),
+ ],
+ runsBefore: ["default"],
+ } as const;
+ },
+);
diff --git a/packages/core/src/y/extensions/blockMatchNodes.ts b/packages/core/src/y/extensions/blockMatchNodes.ts
new file mode 100644
index 0000000000..79c0a230f5
--- /dev/null
+++ b/packages/core/src/y/extensions/blockMatchNodes.ts
@@ -0,0 +1,154 @@
+import * as delta from "lib0/delta";
+import * as schema from "lib0/schema";
+import { $prosemirrorDelta } from "@y/prosemirror";
+
+/**
+ * Canonical name of a content delta's first block child (the child carried by an
+ * insert op), or `null`. For a BlockNote `blockContainer` (content
+ * `blockContent blockGroup?`) this is its block-content type (paragraph,
+ * heading, image, ...).
+ */
+const firstChild = (
+ d: schema.Unwrap,
+): schema.Unwrap | null => {
+ for (const op of (d as any).children) {
+ if (delta.$insertOp.check(op)) {
+ for (const it of op.insert) {
+ if (delta.$deltaAny.check(it)) {
+ return it;
+ }
+ }
+ }
+ }
+ return null;
+};
+
+function getTableDimensions(
+ d: schema.Unwrap,
+): { rows: number; cols: number } | null {
+ if (d.name !== "table") {
+ return null;
+ }
+
+ // Collect all rows with their cells' colspan/rowspan values.
+ const rows: Array> = [];
+ for (const op of (d as any).children) {
+ if (delta.$insertOp.check(op)) {
+ for (const tr of op.insert as Array<
+ schema.Unwrap
+ >) {
+ if (tr.name !== "tableRow") {
+ return null;
+ }
+ const cells: Array<{ colspan: number; rowspan: number }> = [];
+ for (const trOp of (tr as any).children) {
+ if (delta.$insertOp.check(trOp)) {
+ for (const td of trOp.insert as Array<
+ schema.Unwrap
+ >) {
+ if (td.name !== "tableCell" && td.name !== "tableHeader") {
+ return null;
+ }
+ cells.push({
+ colspan: Number(td.attrs.colspan) || 1,
+ rowspan: Number(td.attrs.rowspan) || 1,
+ });
+ }
+ }
+ }
+ rows.push(cells);
+ }
+ }
+ }
+
+ if (rows.length === 0) {
+ return null;
+ }
+
+ // Build an occupancy grid to determine the true column count.
+ // Each entry in `grid[r]` tracks which columns are already occupied
+ // (by a cell from a previous row with rowspan > 1).
+ const grid: boolean[][] = [];
+ for (let r = 0; r < rows.length; r++) {
+ if (!grid[r]) {
+ grid[r] = [];
+ }
+ let col = 0;
+ for (const cell of rows[r]) {
+ // Skip columns already occupied by a rowspan from above.
+ while (grid[r][col]) {
+ col++;
+ }
+ // Mark all slots this cell occupies.
+ for (let dr = 0; dr < cell.rowspan; dr++) {
+ if (!grid[r + dr]) {
+ grid[r + dr] = [];
+ }
+ for (let dc = 0; dc < cell.colspan; dc++) {
+ grid[r + dr][col + dc] = true;
+ }
+ }
+ col += cell.colspan;
+ }
+ }
+
+ const numCols = Math.max(...grid.map((row) => row.length));
+ return { rows: rows.length, cols: numCols };
+}
+
+/**
+ * BlockNote's node-pairing policy for y-prosemirror's `matchNodes` option
+ * (forwarded to `lib0/delta.diff`). This is the schema-specific bit that lives
+ * in userland - the binding itself stays schema-agnostic.
+ *
+ * A `blockContainer` holds exactly one block content (`blockContent
+ * blockGroup?`). Diffing a *type change* of that content as an in-place child
+ * delete+insert would, under a suggestion, tombstone the old content next to the
+ * new one => two block-contents in one container => schema-invalid. So we
+ * declare a container's identity to be its first block-content child's type:
+ * when that changes, the two containers are reported as *different*, the PM->Y
+ * diff replaces the whole container, and the deleted + inserted containers sit
+ * as siblings in the blockGroup (`blockGroupChild+` allows that). Each carries
+ * the `y-attributed-*` node mark - which `blockContainer` already whitelists -
+ * so no schema change and no storage transform are needed. A plain text edit
+ * keeps the same first-child type => same identity => the diff descends and
+ * merges as usual.
+ *
+ * @param a removed (old) node
+ * @param b inserted (new) node
+ * @returns whether `a` and `b` are the same node (diff in place) vs different (replace)
+ */
+export const blockMatchNodes = (
+ a: schema.Unwrap,
+ b: schema.Unwrap,
+): boolean => {
+ if (a.name !== b.name) {
+ return false;
+ }
+
+ if (a.name !== "blockContainer") {
+ return true;
+ }
+
+ const childA = firstChild(a);
+ const childB = firstChild(b);
+
+ if (childA?.name !== childB?.name) {
+ return false;
+ }
+
+ if (childA?.name === "table" && childB?.name === "table") {
+ const dimA = getTableDimensions(childA);
+ const dimB = getTableDimensions(childB);
+ if (
+ dimA !== null &&
+ dimB !== null &&
+ dimA.rows !== dimB.rows &&
+ dimA.cols !== dimB.cols
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+};
diff --git a/packages/core/src/y/extensions/index.ts b/packages/core/src/y/extensions/index.ts
new file mode 100644
index 0000000000..974a4abc38
--- /dev/null
+++ b/packages/core/src/y/extensions/index.ts
@@ -0,0 +1,109 @@
+import type * as Y from "@y/y";
+import type { Awareness } from "@y/protocols/awareness";
+import {
+ createExtension,
+ ExtensionOptions,
+} from "../../editor/BlockNoteExtension.js";
+import { RelativePositionMappingExtension } from "./RelativePositionMapping.js";
+import { CollaborationUser, YCursorExtension } from "./YCursorPlugin.js";
+import { YSyncExtension } from "./YSync.js";
+import { BlockNoteEditorOptions } from "../../editor/BlockNoteEditor.js";
+import { SuggestionsExtension } from "./Suggestions.js";
+import { createYjsVersioningAdapter } from "./Versioning.js";
+import {
+ VersioningExtension,
+ VersioningEndpoints,
+ VersioningEndpointsFactory,
+} from "../../extensions/Versioning/index.js";
+
+export type CollaborationOptions = {
+ /**
+ * The Yjs Type that's used for collaboration.
+ */
+ fragment: Y.Type;
+ /**
+ * The user info for the current user that's shown to other collaborators.
+ */
+ user: CollaborationUser;
+ /**
+ * A Yjs provider (used for awareness / cursor information)
+ */
+ provider?: { awareness?: Awareness };
+ /**
+ * Optional function to customize how cursors of users are rendered
+ */
+ renderCursor?: (user: CollaborationUser) => HTMLElement;
+ /**
+ * Optional flag to set when the user label should be shown with the default
+ * collaboration cursor. Setting to "always" will always show the label,
+ * while "activity" will only show the label when the user moves the cursor
+ * or types. Defaults to "activity".
+ */
+ showCursorLabels?: "always" | "activity";
+ /**
+ * The attribution manager for the collaboration.
+ */
+ attributionManager?: Y.DiffAttributionManager;
+ /**
+ * The suggestion doc for the collaboration. If using suggestion mode
+ */
+ suggestionDoc?: Y.Doc;
+
+ /**
+ * The endpoints for the versioning functionality.
+ */
+ versioningEndpoints?:
+ | VersioningEndpoints
+ | VersioningEndpointsFactory;
+};
+
+export const CollaborationExtension = createExtension(
+ ({ editor, options }: ExtensionOptions) => {
+ return {
+ key: "collaboration",
+ blockNoteExtensions: [
+ options.suggestionDoc ? SuggestionsExtension(options) : null,
+ RelativePositionMappingExtension(),
+ YSyncExtension(options),
+ YCursorExtension(options),
+ options.versioningEndpoints
+ ? VersioningExtension({
+ ...createYjsVersioningAdapter(editor, options.fragment),
+ endpoints: options.versioningEndpoints,
+ })
+ : null,
+ ].filter((a) => a !== null),
+ } as const;
+ },
+);
+
+export function withCollaboration<
+ Options extends Partial>,
+>(
+ options: Options & {
+ /**
+ * Options for configuring the collaboration functionality.
+ */
+ collaboration: CollaborationOptions;
+ },
+): Options {
+ return {
+ ...options,
+ extensions: [
+ ...(options.extensions ?? []),
+ CollaborationExtension(options.collaboration),
+ ],
+ // We disable the default prosemirror history plugin, since it's not compatible with yjs
+ disableExtensions: ["history", ...(options.disableExtensions ?? [])],
+ // We don't want the default initial content, since it will generate a random id for the initial block on each client,
+ // leading to conflicts when syncing happens afterwards.
+ initialContent: [{ type: "paragraph", id: "initialBlockId" }],
+ };
+}
+
+export * from "./RelativePositionMapping.js";
+export * from "./YCursorPlugin.js";
+export * from "./YSync.js";
+export * from "./Versioning.js";
+export * from "./Suggestions.js";
+export * from "./snapshotBuilder.js";
diff --git a/packages/core/src/y/extensions/snapshotBuilder.test.ts b/packages/core/src/y/extensions/snapshotBuilder.test.ts
new file mode 100644
index 0000000000..6affac8e8f
--- /dev/null
+++ b/packages/core/src/y/extensions/snapshotBuilder.test.ts
@@ -0,0 +1,372 @@
+import { describe, expect, it } from "vite-plus/test";
+import { deltaToPNode } from "@y/prosemirror";
+import * as Y from "@y/y";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { docToBlocks } from "../../index.js";
+import {
+ type SnapshotStep,
+ buildSnapshots,
+ diffSnapshots,
+ snapshotToBlocks,
+} from "./snapshotBuilder.js";
+
+// Block ids are deterministic per-test: vitestSetup resets the UniqueID
+// counter (`window.__TEST_OPTIONS`) in `beforeEach`, so every test that
+// generates ids starts again from "0".
+
+describe("snapshotBuilder: yjs snapshots at points in time", () => {
+ const steps: SnapshotStep[] = [
+ {
+ name: "snapshot 1",
+ contributions: [
+ {
+ attribution: { by: "alice" },
+ changes: (editor) => {
+ editor.insertBlocks(
+ [{ type: "paragraph", content: "hello world" }],
+ editor.document[0],
+ "before",
+ );
+ },
+ },
+ ],
+ },
+ {
+ name: "snapshot 2",
+ contributions: [
+ {
+ attribution: { by: "bob" },
+ changes: (editor) => {
+ editor.insertBlocks(
+ [{ type: "paragraph", content: "second block" }],
+ editor.document[0],
+ "after",
+ );
+ },
+ },
+ ],
+ },
+ {
+ name: "snapshot 3",
+ contributions: [
+ {
+ attribution: { by: "alice" },
+ changes: (editor) => {
+ // edit the first paragraph's text
+ editor.updateBlock(editor.document[0], {
+ content: "hello there",
+ });
+ },
+ },
+ ],
+ },
+ ];
+
+ it("reconstructs the full BlockNote JSON at each snapshot", async () => {
+ const editor = BlockNoteEditor.create();
+ const result = await buildSnapshots(editor, steps);
+
+ // snapshot 1: a single "hello world" paragraph (+ trailing empty paragraph)
+ expect(snapshotToBlocks(result, result.snapshots[0].snapshot))
+ .toMatchInlineSnapshot(`
+ [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "hello world",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ]
+ `);
+
+ // snapshot 2: "second block" inserted after the first
+ expect(snapshotToBlocks(result, result.snapshots[1].snapshot))
+ .toMatchInlineSnapshot(`
+ [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "hello world",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "second block",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ]
+ `);
+
+ // snapshot 3: first paragraph edited to "hello there"
+ expect(snapshotToBlocks(result, result.snapshots[2].snapshot))
+ .toMatchInlineSnapshot(`
+ [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "hello there",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "second block",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ]
+ `);
+ });
+
+ it("diffs two snapshots", async () => {
+ const editor = BlockNoteEditor.create();
+ const result = await buildSnapshots(editor, steps);
+
+ const diff = diffSnapshots(
+ result,
+ result.snapshots[0].snapshot,
+ result.snapshots[2].snapshot,
+ );
+
+ expect(diff.before.length).toBe(2); // hello world + trailing empty
+ expect(diff.after.length).toBe(3); // hello there + second block + trailing empty
+ // there is a real delta between the two points in time
+ expect(diff.delta).toBeTruthy();
+ });
+
+ it("emits onSnapshot per step with attributed contributions and storable updates", async () => {
+ const editor = BlockNoteEditor.create();
+
+ const events: Array<{
+ name: string;
+ bys: unknown[];
+ updates: Uint8Array[];
+ afterTexts: string[];
+ }> = [];
+
+ const result = await buildSnapshots(editor, steps, {
+ onSnapshot: ({ name, before, after, contributions }) => {
+ events.push({
+ name,
+ bys: contributions.map((c) => c.attribution?.by),
+ updates: contributions.map((c) => c.update),
+ afterTexts: after.map((b) =>
+ Array.isArray(b.content)
+ ? b.content.map((c: any) => c.text ?? "").join("")
+ : "",
+ ),
+ });
+ // before/after are the block JSON either side of this step
+ expect(Array.isArray(before)).toBe(true);
+ // each contribution carries a ProseMirror delta for diff UIs
+ expect(contributions.every((c) => c.delta)).toBeTruthy();
+ },
+ });
+
+ // Callback ran once per step, in order, carrying each step's per-author
+ // contributions (one author each here).
+ expect(events.map((e) => [e.name, e.bys])).toEqual([
+ ["snapshot 1", ["alice"]],
+ ["snapshot 2", ["bob"]],
+ ["snapshot 3", ["alice"]],
+ ]);
+ expect(events.every((e) => e.updates.every((u) => u.byteLength > 0))).toBe(
+ true,
+ );
+
+ // A throwaway "server": apply the base update, then replay every
+ // contribution's update in order. This reproduces the exact final document.
+ const server = new Y.Doc({ gc: false });
+ Y.applyUpdateV2(server, result.baseUpdate);
+ for (const e of events) {
+ for (const u of e.updates) {
+ Y.applyUpdateV2(server, u);
+ }
+ }
+
+ const serverType = server.get(result.fragment);
+ const serverBlocks = docToBlocks(
+ deltaToPNode(serverType.toDeltaDeep(), editor.pmSchema, null),
+ );
+ expect(serverBlocks).toEqual(
+ snapshotToBlocks(result, result.snapshots[2].snapshot),
+ );
+ // last step's after-state text matches too
+ expect(events[2].afterTexts).toEqual(["hello there", "second block", ""]);
+ });
+
+ it("attributes multiple users within a single version", async () => {
+ const editor = BlockNoteEditor.create();
+
+ // One version, two authors: alice adds a block, bob adds another.
+ const multiAuthor: SnapshotStep[] = [
+ {
+ name: "shared version",
+ contributions: [
+ {
+ attribution: { by: "alice" },
+ changes: (e) =>
+ e.insertBlocks(
+ [{ type: "paragraph", content: "by alice" }],
+ e.document[0],
+ "before",
+ ),
+ },
+ {
+ attribution: { by: "bob" },
+ changes: (e) =>
+ e.insertBlocks(
+ [{ type: "paragraph", content: "by bob" }],
+ e.document[0],
+ "after",
+ ),
+ },
+ ],
+ },
+ ];
+
+ const result = await buildSnapshots(editor, multiAuthor);
+
+ // One version, but two attributed contributions inside it.
+ expect(result.snapshots).toHaveLength(1);
+ const contributions = result.snapshots[0].contributions;
+ expect(contributions.map((c) => c.attribution?.by)).toEqual([
+ "alice",
+ "bob",
+ ]);
+ expect(contributions.every((c) => c.update.byteLength > 0)).toBe(true);
+
+ // Replaying both contributions' updates reproduces the version's content.
+ const server = new Y.Doc({ gc: false });
+ Y.applyUpdateV2(server, result.baseUpdate);
+ for (const c of contributions) {
+ Y.applyUpdateV2(server, c.update);
+ }
+ const serverBlocks = docToBlocks(
+ deltaToPNode(
+ server.get(result.fragment).toDeltaDeep(),
+ editor.pmSchema,
+ null,
+ ),
+ );
+ expect(serverBlocks).toEqual(
+ snapshotToBlocks(result, result.snapshots[0].snapshot),
+ );
+ });
+
+ it("drops no-op contributions (an author who changed nothing)", async () => {
+ const editor = BlockNoteEditor.create();
+
+ const result = await buildSnapshots(editor, [
+ {
+ name: "version",
+ contributions: [
+ {
+ attribution: { by: "alice" },
+ changes: (e) =>
+ e.insertBlocks(
+ [{ type: "paragraph", content: "real change" }],
+ e.document[0],
+ "before",
+ ),
+ },
+ // carol does nothing — should not produce a contribution.
+ { attribution: { by: "carol" }, changes: () => {} },
+ ],
+ },
+ ]);
+
+ expect(
+ result.snapshots[0].contributions.map((c) => c.attribution?.by),
+ ).toEqual(["alice"]);
+ });
+});
diff --git a/packages/core/src/y/extensions/snapshotBuilder.ts b/packages/core/src/y/extensions/snapshotBuilder.ts
new file mode 100644
index 0000000000..df7a357c74
--- /dev/null
+++ b/packages/core/src/y/extensions/snapshotBuilder.ts
@@ -0,0 +1,273 @@
+import { deltaToPNode, docToDelta, nodeToDelta } from "@y/prosemirror";
+import * as Y from "@y/y";
+
+import { type Block, BlockNoteEditor, docToBlocks } from "../../index.js";
+import { diff } from "lib0/delta";
+import { blockMatchNodes } from "./blockMatchNodes.js";
+import { Node } from "prosemirror-model";
+
+function docDiffToDelta(previousDoc: Node, newDoc: Node) {
+ const initialDelta = nodeToDelta(previousDoc);
+ const finalDelta = nodeToDelta(newDoc);
+ return diff(initialDelta.done(), finalDelta.done(), {
+ compare: blockMatchNodes,
+ });
+}
+/**
+ * Build up Yjs snapshots of a document at named points in time.
+ *
+ * The idea: describe a document's history as a list of named steps. Each step
+ * receives the *same* editor instance that the previous step mutated, makes
+ * some changes, and we record a Yjs snapshot of the document at that point. The
+ * snapshots can later be reconstructed and diffed against each other.
+ *
+ * This deliberately does NOT use the y-prosemirror sync plugin. Instead, for
+ * each step we:
+ * 1. run the step's `changes` against the editor,
+ * 2. diff the editor's new ProseMirror doc against the previous one
+ * (`docDiffToDelta`),
+ * 3. apply that delta to a plain Y.Type inside its own transaction (tagged
+ * with the step's `attribution` as the transaction origin), building up
+ * real Yjs history,
+ * 4. emit a {@link SnapshotEvent} (before / after blocks + the diff as both a
+ * Yjs update and a ProseMirror delta) via `onSnapshot`.
+ *
+ * Because we want snapshots to stay valid, the backing Y.Doc has gc disabled.
+ *
+ * @example Pre-populate a doc with multi-author history, then ship it
+ * ```ts
+ * const editor = createSnapshotEditor();
+ * const result = await buildSnapshots(editor, [
+ * {
+ * name: "Intro",
+ * contributions: [
+ * { attribution: { by: "alice" }, changes: (e) => { } },
+ * { attribution: { by: "bob" }, changes: (e) => { } },
+ * ],
+ * },
+ * ], {
+ * onSnapshot: async ({ name, contributions }) => {
+ * // e.g. PATCH each contribution to YHub attributed to its author (see
+ * // yhub.ts), then commit a single version marker named `name`.
+ * for (const { attribution, update } of contributions) {
+ * await storeToServer(update, { ...attribution });
+ * }
+ * },
+ * });
+ *
+ * // Or seed the whole document at once (matches the example's localStorage
+ * // `bn-doc-state-` key, which is a base64 V2 update):
+ * const fullUpdate = Y.encodeStateAsUpdateV2(result.ydoc);
+ * ```
+ */
+
+/** Arbitrary attribution attached to a contribution (e.g. `{ by: "alice" }`). */
+export type SnapshotAttribution = Record;
+
+/**
+ * A single attributed contribution to a version. Each contribution is applied
+ * in its own Yjs transaction (origin = its `attribution`), so a single version
+ * can carry content authored by several different users.
+ */
+export type SnapshotContribution = {
+ /**
+ * Attribution for this contribution's changes (e.g. `{ by: "alice" }`). Set
+ * as the Yjs transaction origin, so callers can map it to e.g. YHub
+ * `customAttributions` / `?userid=` to differentiate who changed what.
+ */
+ attribution?: SnapshotAttribution;
+ /**
+ * Mutate the editor. Receives the same editor instance the previous
+ * contribution (or step) left off with, so changes accumulate.
+ */
+ changes: (editor: BlockNoteEditor) => void;
+};
+
+/** A single named version in the document's history, built from one or more attributed contributions. */
+export type SnapshotStep = {
+ name: string;
+ /** The attributed contributions that together produce this version. */
+ contributions: SnapshotContribution[];
+};
+
+/** One attributed contribution's change, as both a Yjs update and a ProseMirror delta. */
+export type SnapshotContributionDiff = {
+ /** Attribution for this contribution, if any. */
+ attribution?: SnapshotAttribution;
+ /**
+ * V2 Yjs update containing only this contribution's transaction. Apply
+ * sequentially (`Y.applyUpdateV2`) / PATCH to a server to rebuild the history.
+ */
+ update: Uint8Array;
+ /** The ProseMirror delta transforming the previous doc into the new one. */
+ delta: ReturnType;
+};
+
+/** Emitted once per step, after all of its contributions have been applied to the Y.Doc. */
+export type SnapshotEvent = {
+ /** Zero-based index of the step. */
+ index: number;
+ /** The step's name. */
+ name: string;
+ /**
+ * The attributed contributions that produced this version, in order. Always
+ * at least one; no-op contributions (that changed nothing) are omitted.
+ */
+ contributions: SnapshotContributionDiff[];
+ /** The document (block JSON) before this step's changes. */
+ before: Block[];
+ /** The document (block JSON) after this step's changes. */
+ after: Block[];
+ /** A Yjs snapshot of the doc at this point in time. */
+ snapshot: Y.Snapshot;
+};
+
+export type BuildSnapshotsOptions = {
+ /** Root key / fragment name on the Y.Doc. @default "prosemirror" */
+ fragment?: string;
+ /**
+ * Called once per step, after its changes have been applied to the Y.Doc.
+ * May be async — steps are processed sequentially and each callback is
+ * awaited before the next step runs, so server writes stay ordered.
+ */
+ onSnapshot?: (event: SnapshotEvent) => void | Promise;
+};
+
+export type BuildSnapshotsResult = {
+ /** The editor instance threaded through every step. */
+ editor: BlockNoteEditor;
+ /** The backing (gc-disabled) Y.Doc holding the full history. */
+ ydoc: Y.Doc;
+ /** The Y.Type the ProseMirror content was synced into. */
+ yType: Y.Type;
+ /** The fragment / root key used on the Y.Doc. */
+ fragment: string;
+ /**
+ * V2 update for the starting document state (the editor's initial doc),
+ * before any step ran. When replaying the per-step `diff.update`s onto a
+ * blank doc, apply this FIRST — the step updates are relative to it.
+ */
+ baseUpdate: Uint8Array;
+ /** One event per step, in order. */
+ snapshots: SnapshotEvent[];
+};
+
+/**
+ * Run a list of named steps against a single editor, recording a Yjs snapshot
+ * of the document after each step and emitting a {@link SnapshotEvent}.
+ */
+export async function buildSnapshots(
+ editor: BlockNoteEditor,
+ steps: SnapshotStep[],
+ options: BuildSnapshotsOptions = {},
+): Promise {
+ const fragment = options.fragment ?? "prosemirror";
+
+ // gc must be off so snapshots remain reconstructable later.
+ const ydoc = new Y.Doc({ gc: false });
+ const yType = ydoc.get(fragment);
+
+ // Seed the Y.Type with the editor's starting doc so that every subsequent
+ // diff is relative to a Y.Type that actually mirrors `previousDoc`. Capture
+ // the empty state vector first so we can expose the seed as `baseUpdate`.
+ const emptyStateVector = Y.encodeStateVector(ydoc);
+ let previousDoc = editor.prosemirrorState.doc;
+ ydoc.transact(() => {
+ yType.applyDelta(docToDelta(previousDoc) as any);
+ });
+ const baseUpdate = Y.encodeStateAsUpdateV2(ydoc, emptyStateVector);
+
+ const snapshots: SnapshotEvent[] = [];
+
+ for (let index = 0; index < steps.length; index++) {
+ const step = steps[index];
+ const beforeDoc = previousDoc;
+
+ // Each contribution mutates the editor in its own transaction (origin = its
+ // attribution), so we capture one update per author. Together they produce
+ // this version — but the version marker is committed separately by the
+ // consumer (see seed.ts), letting multiple users be attributed within it.
+ const contributions: SnapshotContributionDiff[] = [];
+ for (const contribution of step.contributions) {
+ const docBefore = previousDoc;
+ editor.transact(() => {
+ contribution.changes(editor);
+ });
+ const newDoc = editor.prosemirrorState.doc;
+ previousDoc = newDoc;
+
+ // Skip no-op contributions: the author changed nothing, so there is no
+ // update to attribute or PATCH.
+ if (newDoc.eq(docBefore)) {
+ continue;
+ }
+
+ const delta = docDiffToDelta(docBefore, newDoc);
+ const beforeStateVector = Y.encodeStateVector(ydoc);
+ ydoc.transact(() => {
+ yType.applyDelta(delta as any);
+ }, contribution.attribution);
+ const update = Y.encodeStateAsUpdateV2(ydoc, beforeStateVector);
+
+ contributions.push({
+ attribution: contribution.attribution,
+ update,
+ delta,
+ });
+ }
+
+ const event: SnapshotEvent = {
+ index,
+ name: step.name,
+ contributions,
+ before: docToBlocks(beforeDoc),
+ after: docToBlocks(previousDoc),
+ snapshot: Y.snapshot(ydoc),
+ };
+ snapshots.push(event);
+ await options.onSnapshot?.(event);
+ }
+
+ return { editor, ydoc, yType, fragment, baseUpdate, snapshots };
+}
+
+/**
+ * Reconstruct the ProseMirror root node a snapshot represented.
+ */
+export function snapshotToProsemirrorNode(
+ result: Pick,
+ snapshot: Y.Snapshot,
+) {
+ const restored = Y.createDocFromSnapshot(result.ydoc, snapshot);
+ const restoredType = restored.get(result.fragment);
+ return deltaToPNode(restoredType.toDeltaDeep(), result.editor.pmSchema, null);
+}
+
+/**
+ * Reconstruct the BlockNote document (block JSON) a snapshot represented.
+ */
+export function snapshotToBlocks(
+ result: Pick,
+ snapshot: Y.Snapshot,
+): Block[] {
+ return docToBlocks(snapshotToProsemirrorNode(result, snapshot));
+}
+
+/**
+ * Diff two snapshots, returning the before/after blocks and the ProseMirror
+ * delta that transforms one into the other.
+ */
+export function diffSnapshots(
+ result: Pick,
+ before: Y.Snapshot,
+ after: Y.Snapshot,
+) {
+ const beforeNode = snapshotToProsemirrorNode(result, before);
+ const afterNode = snapshotToProsemirrorNode(result, after);
+
+ return {
+ before: docToBlocks(beforeNode),
+ after: docToBlocks(afterNode),
+ delta: docDiffToDelta(beforeNode, afterNode),
+ };
+}
diff --git a/packages/core/src/y/index.ts b/packages/core/src/y/index.ts
new file mode 100644
index 0000000000..4a0e02964e
--- /dev/null
+++ b/packages/core/src/y/index.ts
@@ -0,0 +1,4 @@
+export * from "./extensions/index.js";
+export * from "./utils.js";
+export * from "./comments/index.js";
+export * from "./versioning/index.js";
diff --git a/packages/core/src/y/utils.test.ts b/packages/core/src/y/utils.test.ts
new file mode 100644
index 0000000000..edf242308e
--- /dev/null
+++ b/packages/core/src/y/utils.test.ts
@@ -0,0 +1,1042 @@
+import { Block, docToBlocks } from "../index.js";
+import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
+import { describe, expect, it } from "vite-plus/test";
+import * as Y from "@y/y";
+import {
+ _blocksToProsemirrorNode,
+ blocksToYDoc,
+ blocksToYType,
+ yDocToBlocks,
+ yfragmentToBlocks,
+} from "./utils.js";
+
+describe("Test y (v14) 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.get("test");
+ blocksToYType(editor, blocks, fragment);
+
+ const blockOutput = yfragmentToBlocks(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([]);
+ });
+
+ // An empty block array round-trips through yjs to the canonical empty
+ // BlockNote document: a single empty paragraph. (The id is generated, so we
+ // normalize it before comparing.)
+ const emptyDocument: Block[] = [
+ {
+ id: "0",
+ type: "paragraph",
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [],
+ children: [],
+ },
+ ];
+ const normalizeIds = (blocks: Block[]) =>
+ blocks.map((block) => ({ ...block, id: "0" }));
+
+ it("empty document - converts to and from yjs (doc)", () => {
+ const blocks: Block[] = [];
+ const ydoc = blocksToYDoc(editor, blocks);
+ const blockOutput = yDocToBlocks(editor, ydoc);
+ expect(normalizeIds(blockOutput)).toEqual(emptyDocument);
+ });
+
+ it("empty document - converts to and from yjs (fragment)", () => {
+ const blocks: Block[] = [];
+ const doc = new Y.Doc();
+ const fragment = doc.get("test");
+ blocksToYType(editor, blocks, fragment);
+
+ const blockOutput = yfragmentToBlocks(editor, fragment);
+ expect(normalizeIds(blockOutput)).toEqual(emptyDocument);
+ });
+ });
+
+ 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/y/utils.ts b/packages/core/src/y/utils.ts
new file mode 100644
index 0000000000..6ac764de22
--- /dev/null
+++ b/packages/core/src/y/utils.ts
@@ -0,0 +1,185 @@
+import { docToDelta, pmToFragment, deltaToPNode } from "@y/prosemirror";
+import {
+ type Block,
+ type BlockNoteEditor,
+ type BlockSchema,
+ type InlineContentSchema,
+ type PartialBlock,
+ type StyleSchema,
+ blockToNode,
+ docToBlocks,
+} from "../index.js";
+
+import * as Y from "@y/y";
+
+/**
+ * Find the equivalent of a Y.Type in another Y.Doc.
+ *
+ * For root types this looks up the matching shared key; for sub-types it
+ * locates the item by its client/clock ID in the target doc's store.
+ */
+export function findTypeInOtherYdoc>(
+ ytype: T,
+ otherYdoc: Y.Doc,
+): T {
+ const ydoc = ytype.doc;
+ if (!ydoc) {
+ throw new Error("type does not have a ydoc");
+ }
+ if (ytype._item === null) {
+ /**
+ * If is a root type, we need to find the root key in the original ydoc
+ * and use it to get the type in the other ydoc.
+ */
+ const rootKey = Array.from(ydoc.share.keys()).find(
+ (key) => ydoc.share.get(key) === ytype,
+ );
+ if (rootKey == null) {
+ throw new Error("type does not exist in other ydoc");
+ }
+ return otherYdoc.get(rootKey as string, ytype.constructor as any) as T;
+ } else {
+ /**
+ * If it is a sub type, we use the item id to find the history type.
+ */
+ const ytypeItem = ytype._item;
+ const otherStructs = otherYdoc.store.clients.get(ytypeItem.id.client) ?? [];
+ const itemIndex = Y.findIndexSS(otherStructs, ytypeItem.id.clock);
+ const otherItem = otherStructs[itemIndex] as Y.Item | undefined;
+ if (!otherItem) {
+ throw new Error("type does not exist in other ydoc");
+ }
+ const otherContent = otherItem.content as Y.ContentType | undefined;
+ if (!otherContent) {
+ throw new Error("type does not exist in other ydoc");
+ }
+ return otherContent.type as T;
+ }
+}
+
+/**
+ * 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.Type collaborative doc into a BlockNote document (BlockNote style JSON of all blocks)
+ * @param editor BlockNote editor
+ * @param fragment Y.Type
+ * @returns BlockNote document (BlockNote style JSON of all blocks)
+ */
+export function yfragmentToBlocks<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(editor: BlockNoteEditor, fragment: Y.Type) {
+ const pmNode = deltaToPNode(fragment.toDeltaDeep(), editor.pmSchema, null);
+ return docToBlocks(pmNode);
+}
+
+/**
+ * Convert blocks to a Y.Type
+ *
+ * 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 fragment XML fragment name
+ * @returns Y.Type
+ */
+export function blocksToYType<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ blocks: Block[],
+ fragment?: Y.Type,
+) {
+ if (!fragment) {
+ fragment = new Y.Doc().get("prosemirror");
+ }
+ return pmToFragment(_blocksToProsemirrorNode(editor, blocks), fragment);
+}
+
+/**
+ * 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 fragment 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,
+ fragment = "prosemirror",
+) {
+ return yfragmentToBlocks(editor, ydoc.get(fragment));
+}
+
+/**
+ * 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 fragment XML fragment name
+ */
+export function blocksToYDoc<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+ blocks: PartialBlock[],
+ fragment = "prosemirror",
+) {
+ const d = docToDelta(_blocksToProsemirrorNode(editor, blocks));
+ const doc = new Y.Doc();
+ doc.get(fragment).applyDelta(d);
+ return doc;
+}
diff --git a/packages/core/src/y/versioning/__test__/fixtures/activity-all-after.bin b/packages/core/src/y/versioning/__test__/fixtures/activity-all-after.bin
new file mode 100644
index 0000000000..d965d95ccb
Binary files /dev/null and b/packages/core/src/y/versioning/__test__/fixtures/activity-all-after.bin differ
diff --git a/packages/core/src/y/versioning/__test__/fixtures/activity-all-after.json b/packages/core/src/y/versioning/__test__/fixtures/activity-all-after.json
new file mode 100644
index 0000000000..5e4ef30b6f
--- /dev/null
+++ b/packages/core/src/y/versioning/__test__/fixtures/activity-all-after.json
@@ -0,0 +1,50 @@
+[
+ {
+ "from": 1782218211312,
+ "to": 1782218211312,
+ "by": "Dilbert Adams",
+ "customAttributions": [
+ {
+ "k": "type",
+ "v": "version"
+ },
+ {
+ "k": "name",
+ "v": "Test Version 1782218211197"
+ }
+ ]
+ },
+ {
+ "from": 1782218082853,
+ "to": 1782218082853,
+ "by": "Charlie Brown",
+ "customAttributions": [
+ {
+ "k": "type",
+ "v": "version"
+ },
+ {
+ "k": "name",
+ "v": "Test Version 1"
+ }
+ ]
+ },
+ {
+ "from": 1782217704391,
+ "to": 1782217705077,
+ "by": "Charlie Brown",
+ "customAttributions": []
+ },
+ {
+ "from": 1782217702869,
+ "to": 1782217703318,
+ "by": "Charlie Brown",
+ "customAttributions": []
+ },
+ {
+ "from": 1782217700507,
+ "to": 1782217700507,
+ "by": "Charlie Brown",
+ "customAttributions": []
+ }
+]
diff --git a/packages/core/src/y/versioning/__test__/fixtures/activity-all.bin b/packages/core/src/y/versioning/__test__/fixtures/activity-all.bin
new file mode 100644
index 0000000000..f8c47360eb
Binary files /dev/null and b/packages/core/src/y/versioning/__test__/fixtures/activity-all.bin differ
diff --git a/packages/core/src/y/versioning/__test__/fixtures/activity-all.json b/packages/core/src/y/versioning/__test__/fixtures/activity-all.json
new file mode 100644
index 0000000000..8d1623156e
--- /dev/null
+++ b/packages/core/src/y/versioning/__test__/fixtures/activity-all.json
@@ -0,0 +1,20 @@
+[
+ {
+ "from": 1782217704391,
+ "to": 1782217705077,
+ "by": "Charlie Brown",
+ "customAttributions": []
+ },
+ {
+ "from": 1782217702869,
+ "to": 1782217703318,
+ "by": "Charlie Brown",
+ "customAttributions": []
+ },
+ {
+ "from": 1782217700507,
+ "to": 1782217700507,
+ "by": "Charlie Brown",
+ "customAttributions": []
+ }
+]
diff --git a/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-after.bin b/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-after.bin
new file mode 100644
index 0000000000..77751af3b1
Binary files /dev/null and b/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-after.bin differ
diff --git a/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-after.json b/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-after.json
new file mode 100644
index 0000000000..c08d5f49a0
--- /dev/null
+++ b/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-after.json
@@ -0,0 +1,32 @@
+[
+ {
+ "from": 1782218211312,
+ "to": 1782218211312,
+ "by": "Dilbert Adams",
+ "customAttributions": [
+ {
+ "k": "type",
+ "v": "version"
+ },
+ {
+ "k": "name",
+ "v": "Test Version 1782218211197"
+ }
+ ]
+ },
+ {
+ "from": 1782218082853,
+ "to": 1782218082853,
+ "by": "Charlie Brown",
+ "customAttributions": [
+ {
+ "k": "type",
+ "v": "version"
+ },
+ {
+ "k": "name",
+ "v": "Test Version 1"
+ }
+ ]
+ }
+]
diff --git a/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-before.json b/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-before.json
new file mode 100644
index 0000000000..fe51488c70
--- /dev/null
+++ b/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-before.json
@@ -0,0 +1 @@
+[]
diff --git a/packages/core/src/y/versioning/__test__/fixtures/changeset.bin b/packages/core/src/y/versioning/__test__/fixtures/changeset.bin
new file mode 100644
index 0000000000..b454a364dc
Binary files /dev/null and b/packages/core/src/y/versioning/__test__/fixtures/changeset.bin differ
diff --git a/packages/core/src/y/versioning/__test__/fixtures/patch-response.json b/packages/core/src/y/versioning/__test__/fixtures/patch-response.json
new file mode 100644
index 0000000000..8df9fb9a52
--- /dev/null
+++ b/packages/core/src/y/versioning/__test__/fixtures/patch-response.json
@@ -0,0 +1,4 @@
+{
+ "success": true,
+ "message": "Document updated"
+}
diff --git a/packages/core/src/y/versioning/__test__/seed.test.ts b/packages/core/src/y/versioning/__test__/seed.test.ts
new file mode 100644
index 0000000000..07fe42bd17
--- /dev/null
+++ b/packages/core/src/y/versioning/__test__/seed.test.ts
@@ -0,0 +1,406 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vite-plus/test";
+import { decodeAny } from "lib0/buffer";
+import { deltaToPNode, pmToFragment } from "@y/prosemirror";
+import * as Y from "@y/y";
+
+import { docToBlocks } from "../../../index.js";
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import {
+ type SnapshotStep,
+ buildSnapshots,
+} from "../../extensions/snapshotBuilder.js";
+import { seedYHubDocument } from "../seed.js";
+
+const BASE_URL = "https://yhub.test";
+const ORG = "test-org";
+const DOC_ID = "test-doc";
+
+const steps: SnapshotStep[] = [
+ {
+ name: "Intro",
+ contributions: [
+ {
+ attribution: { by: "alice" },
+ changes: (editor) => {
+ editor.insertBlocks(
+ [{ type: "paragraph", content: "hello world" }],
+ editor.document[0],
+ "before",
+ );
+ },
+ },
+ ],
+ },
+ {
+ name: "More",
+ contributions: [
+ {
+ attribution: { by: "bob" },
+ changes: (editor) => {
+ editor.insertBlocks(
+ [{ type: "paragraph", content: "second block" }],
+ editor.document[0],
+ "after",
+ );
+ },
+ },
+ ],
+ },
+];
+
+type DecodedSubPatch = {
+ update: Uint8Array;
+ by?: string;
+ at?: number;
+ customAttributions?: Array<{ k: string; v: string }>;
+};
+
+type DecodedPatch = {
+ // Present on the base PATCH (single top-level update).
+ update?: Uint8Array;
+ by?: string;
+ at?: number;
+ customAttributions?: Array<{ k: string; v: string }>;
+ // Present on per-version PATCHes (bulk authored content + marker).
+ patches?: DecodedSubPatch[];
+};
+
+async function decodePatchBody(call: any): Promise {
+ const init = call[1] as RequestInit;
+ const body = init.body as Uint8Array;
+ return decodeAny(new Uint8Array(body)) as DecodedPatch;
+}
+
+/** Apply every update a PATCH body carries (base update and/or each sub-patch). */
+function applyDecodedPatch(server: Y.Doc, decoded: DecodedPatch) {
+ if (decoded.update) {
+ Y.applyUpdate(server, decoded.update);
+ }
+ for (const p of decoded.patches ?? []) {
+ Y.applyUpdate(server, p.update);
+ }
+}
+
+describe("seedYHubDocument", () => {
+ let fetchSpy: ReturnType;
+
+ beforeEach(() => {
+ fetchSpy = vi.spyOn(globalThis, "fetch");
+ fetchSpy.mockResolvedValue(new Response(null, { status: 200 }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("PATCHes a base update then a per-version content+marker bundle", async () => {
+ const editor = BlockNoteEditor.create();
+ const build = await buildSnapshots(editor, steps, { fragment: "" });
+
+ const versions = await seedYHubDocument(
+ { baseUrl: BASE_URL, org: ORG, docId: DOC_ID },
+ build,
+ );
+
+ // 1 base PATCH + 1 per version
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
+
+ // All PATCHes hit the /ydoc/{org}/{docId} endpoint
+ for (const call of fetchSpy.mock.calls) {
+ expect(call[0]).toBe(`${BASE_URL}/ydoc/${ORG}/${DOC_ID}`);
+ expect((call[1] as RequestInit).method).toBe("PATCH");
+ }
+
+ // First PATCH is the base content, with no version marker and no bundle.
+ const base = await decodePatchBody(fetchSpy.mock.calls[0]);
+ expect(base.customAttributions).toEqual([]);
+ expect(base.patches).toBeUndefined();
+ expect(base.update?.byteLength).toBeGreaterThan(0);
+
+ // Each version PATCH is a `patches` bundle: one authored content patch (no
+ // marker) followed by a final type:version marker patch (no author).
+ const v1 = await decodePatchBody(fetchSpy.mock.calls[1]);
+ expect(v1.patches).toHaveLength(2);
+ const [v1content, v1marker] = v1.patches!;
+ expect(v1content.by).toBe("alice");
+ expect(v1content.customAttributions).toEqual([]);
+ expect(v1content.update.byteLength).toBeGreaterThan(0);
+ // The marker is attributed to every author of the version (here just one).
+ expect(v1marker.by).toBe("alice");
+ expect(v1marker.customAttributions).toEqual([
+ { k: "type", v: "version" },
+ { k: "id", v: versions[0].id },
+ { k: "name", v: "Intro" },
+ ]);
+
+ const v2 = await decodePatchBody(fetchSpy.mock.calls[2]);
+ const [v2content, v2marker] = v2.patches!;
+ expect(v2content.by).toBe("bob");
+ expect(v2marker.customAttributions).toEqual([
+ { k: "type", v: "version" },
+ { k: "id", v: versions[1].id },
+ { k: "name", v: "More" },
+ ]);
+
+ // Returned markers line up with the steps.
+ expect(versions.map((v) => v.name)).toEqual(["Intro", "More"]);
+
+ // Every patch is stamped with a strictly increasing `at`, so the backfilled
+ // history is deterministically ordered (content before its marker, versions
+ // in order).
+ const ats = [
+ base.at!,
+ ...v1.patches!.map((p) => p.at!),
+ ...v2.patches!.map((p) => p.at!),
+ ];
+ expect(ats.every((a) => typeof a === "number")).toBe(true);
+ expect(ats).toEqual([...ats].sort((a, b) => a - b));
+ expect(new Set(ats).size).toBe(ats.length);
+ });
+
+ it("attributes multiple users within a single version", async () => {
+ const multiAuthor: SnapshotStep[] = [
+ {
+ name: "Shared version",
+ contributions: [
+ {
+ attribution: { by: "alice" },
+ changes: (e) =>
+ e.insertBlocks(
+ [{ type: "paragraph", content: "by alice" }],
+ e.document[0],
+ "before",
+ ),
+ },
+ {
+ attribution: { by: "bob" },
+ changes: (e) =>
+ e.insertBlocks(
+ [{ type: "paragraph", content: "by bob" }],
+ e.document[0],
+ "after",
+ ),
+ },
+ ],
+ },
+ ];
+
+ const editor = BlockNoteEditor.create();
+ const build = await buildSnapshots(editor, multiAuthor, { fragment: "" });
+ await seedYHubDocument(
+ { baseUrl: BASE_URL, org: ORG, docId: DOC_ID },
+ build,
+ );
+
+ // base PATCH + one version bundle.
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ const version = await decodePatchBody(fetchSpy.mock.calls[1]);
+
+ // Two authored content patches (distinct users) + one marker patch.
+ expect(version.patches).toHaveLength(3);
+ const contentPatches = version.patches!.slice(0, -1);
+ const marker = version.patches![version.patches!.length - 1];
+
+ expect(contentPatches.map((p) => p.by)).toEqual(["alice", "bob"]);
+
+ // The version marker is credited to the most recent contributor.
+ expect(marker.by).toBe("bob");
+ expect(marker.customAttributions).toContainEqual({
+ k: "type",
+ v: "version",
+ });
+ });
+
+ it("throws if the server rejects a PATCH", async () => {
+ fetchSpy.mockResolvedValue(
+ new Response(null, { status: 500, statusText: "Server Error" }),
+ );
+
+ const editor = BlockNoteEditor.create();
+ const build = await buildSnapshots(editor, steps, { fragment: "" });
+
+ await expect(
+ seedYHubDocument({ baseUrl: BASE_URL, org: ORG, docId: DOC_ID }, build),
+ ).rejects.toThrow(/YHub seed request failed: 500/);
+ });
+
+ // The richer content (heading + bulletList + replaceBlocks) the example seeds.
+ const richSteps: SnapshotStep[] = [
+ {
+ name: "Initial draft",
+ contributions: [
+ {
+ attribution: { by: "Alice" },
+ changes: (editor) => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "heading",
+ props: { level: 1 },
+ content: "Team Sync Notes",
+ },
+ { type: "paragraph", content: "Quick notes from today's sync." },
+ ]);
+ },
+ },
+ ],
+ },
+ {
+ name: "Add agenda",
+ contributions: [
+ {
+ attribution: { by: "Bob" },
+ changes: (editor) => {
+ editor.insertBlocks(
+ [
+ { type: "heading", props: { level: 2 }, content: "Agenda" },
+ { type: "bulletListItem", content: "Roadmap review" },
+ { type: "bulletListItem", content: "Open questions" },
+ ],
+ editor.document[editor.document.length - 1],
+ "after",
+ );
+ },
+ },
+ ],
+ },
+ {
+ name: "Revise intro",
+ contributions: [
+ {
+ attribution: { by: "Alice" },
+ changes: (editor) => {
+ editor.updateBlock(editor.document[1], {
+ content: "Notes and action items from today's team sync.",
+ });
+ },
+ },
+ ],
+ },
+ ];
+
+ // Mirrors the live path: a real YHub server doc that the PATCHed (V1) updates
+ // are applied to, in order, exactly as `seedYHubDocument` sends them. This is
+ // what the editor would sync down, so reconstructing it via `deltaToPNode`
+ // proves the seeded content is valid (the bug that surfaced live was an
+ // *empty* fragment reconstructing to a null node).
+ it("seeds content a live editor can reconstruct", async () => {
+ const editor = BlockNoteEditor.create();
+ const build = await buildSnapshots(editor, richSteps, { fragment: "" });
+
+ // Stand-in YHub server: apply each PATCH's update to a real Y.Doc.
+ const server = new Y.Doc();
+ fetchSpy.mockImplementation(async (...args: any[]) => {
+ const decoded = await decodePatchBody(args);
+ // seedYHubDocument sends V1 updates, either as a base `update` or as a
+ // bundle of authored content + marker `patches`.
+ applyDecodedPatch(server, decoded);
+ return new Response(null, { status: 200 });
+ });
+
+ await seedYHubDocument(
+ { baseUrl: BASE_URL, org: ORG, docId: DOC_ID },
+ build,
+ );
+
+ // Reconstruct the document from the server's accumulated state, the same way
+ // the live editor does on sync — must not throw "failed to create node".
+ const serverType = server.get("");
+ const node = deltaToPNode(serverType.toDeltaDeep(), editor.pmSchema, null);
+ const blocks = docToBlocks(node);
+
+ const texts = blocks.map((b: any) =>
+ Array.isArray(b.content)
+ ? b.content.map((c: any) => c.text ?? "").join("")
+ : "",
+ );
+ expect(texts).toEqual([
+ "Team Sync Notes",
+ "Notes and action items from today's team sync.",
+ "Agenda",
+ "Roadmap review",
+ "Open questions",
+ ]);
+
+ // The reconstructed server document matches the final build snapshot.
+ const lastSnapshot = build.snapshots[build.snapshots.length - 1].snapshot;
+ expect(blocks).toEqual(
+ docToBlocks(
+ deltaToPNode(
+ Y.createDocFromSnapshot(build.ydoc, lastSnapshot)
+ .get("")
+ .toDeltaDeep(),
+ editor.pmSchema,
+ null,
+ ),
+ ),
+ );
+ });
+
+ // Reproduces the live failure: the editor writes its initial content (one
+ // blockGroup) into the fresh local fragment, THEN the seeded content (another
+ // blockGroup) syncs in from the server. Merging gives the fragment root two
+ // blockGroups, which can't fill a `doc` (exactly one blockGroup) →
+ // `deltaToPNode` throws "failed to create node: null". The fix (see the
+ // versioning example) is to let the server's content sync into an empty doc
+ // FIRST and create the editor afterwards, so it adopts that single blockGroup
+ // instead of writing a competing one.
+ it("merging seeded content into an editor-initialised doc throws (the live error)", async () => {
+ // Server: seeded content in fragment "".
+ const serverEditor = BlockNoteEditor.create();
+ const build = await buildSnapshots(serverEditor, richSteps, {
+ fragment: "",
+ });
+ const server = new Y.Doc();
+ Y.applyUpdateV2(server, build.baseUpdate);
+ for (const e of build.snapshots) {
+ for (const c of e.contributions) {
+ Y.applyUpdateV2(server, c.update);
+ }
+ }
+
+ // Client: a fresh doc that the editor populated with its own initial
+ // content before sync (simulated with pmToFragment of the empty doc).
+ const clientEditor = BlockNoteEditor.create();
+ const client = new Y.Doc();
+ pmToFragment(clientEditor.prosemirrorState.doc, client.get(""));
+
+ // Sync the server's seeded content into the client.
+ Y.applyUpdate(client, Y.encodeStateAsUpdate(server));
+
+ expect(() =>
+ deltaToPNode(client.get("").toDeltaDeep(), clientEditor.pmSchema, null),
+ ).toThrow(/failed to create node/);
+ });
+
+ // The principle behind the fix: a doc that holds ONLY the seeded content (no
+ // competing editor-initial blockGroup) reconstructs cleanly into one doc.
+ it("seeded content with no competing initial blockGroup reconstructs cleanly", async () => {
+ const serverEditor = BlockNoteEditor.create();
+ const build = await buildSnapshots(serverEditor, richSteps, {
+ fragment: "",
+ });
+
+ // Local doc populated from the seed BEFORE any editor initial content.
+ const local = new Y.Doc();
+ Y.applyUpdateV2(local, build.baseUpdate);
+ for (const e of build.snapshots) {
+ for (const c of e.contributions) {
+ Y.applyUpdateV2(local, c.update);
+ }
+ }
+
+ const node = deltaToPNode(
+ local.get("").toDeltaDeep(),
+ serverEditor.pmSchema,
+ null,
+ );
+ expect(docToBlocks(node).length).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/core/src/y/versioning/__test__/yhub.test.ts b/packages/core/src/y/versioning/__test__/yhub.test.ts
new file mode 100644
index 0000000000..8ae0772dbb
--- /dev/null
+++ b/packages/core/src/y/versioning/__test__/yhub.test.ts
@@ -0,0 +1,457 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vite-plus/test";
+import { encodeAny } from "lib0/buffer";
+import * as Y from "@y/y";
+
+import {
+ CURRENT_VERSION_ID,
+ type VersionSnapshot,
+} from "../../../extensions/Versioning/index.js";
+import { createYHubVersioningEndpoints } from "../yhub.js";
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+
+// ---------------------------------------------------------------------------
+// Fixture data — version entries now carry an `id` custom attribution (UUID).
+// ---------------------------------------------------------------------------
+
+const VERSION_ENTRY_1 = {
+ from: 1782218082853,
+ to: 1782218082853,
+ by: "Charlie Brown",
+ customAttributions: [
+ { k: "type", v: "version" },
+ { k: "id", v: "uuid-version-1" },
+ { k: "name", v: "Test Version 1" },
+ ],
+};
+
+const VERSION_ENTRY_2 = {
+ from: 1782218211312,
+ to: 1782218211312,
+ by: "Dilbert Adams",
+ customAttributions: [
+ { k: "type", v: "version" },
+ { k: "id", v: "uuid-version-2" },
+ { k: "name", v: "Test Version 2" },
+ ],
+};
+
+// Snapshots as produced by `list()` (see `activityToSnapshot`): the activity
+// entry's `to` timestamp becomes both `createdAt` and `updatedAt`. The
+// changeset/rollback APIs are now driven by these timestamps directly, so the
+// endpoints no longer make an activity lookup to resolve them.
+const SNAPSHOT_1: VersionSnapshot = {
+ id: "uuid-version-1",
+ name: "Test Version 1",
+ createdAt: VERSION_ENTRY_1.to,
+ updatedAt: VERSION_ENTRY_1.to,
+ secondaryLabel: VERSION_ENTRY_1.by,
+};
+
+const SNAPSHOT_2: VersionSnapshot = {
+ id: "uuid-version-2",
+ name: "Test Version 2",
+ createdAt: VERSION_ENTRY_2.to,
+ updatedAt: VERSION_ENTRY_2.to,
+ secondaryLabel: VERSION_ENTRY_2.by,
+};
+
+const PATCH_RESPONSE = { success: true, message: "Document updated" };
+
+function makeChangeset(
+ opts: { nextDoc?: boolean; attributions?: boolean } = {},
+) {
+ const doc = new Y.Doc();
+ const frag = doc.get("default", "XmlFragment");
+ frag.insert(0, ["hello"]);
+ return {
+ prevDoc: Y.encodeStateAsUpdate(new Y.Doc()),
+ ...(opts.nextDoc !== false ? { nextDoc: Y.encodeStateAsUpdate(doc) } : {}),
+ ...(opts.attributions ? { attributions: new Uint8Array([0]) } : {}),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const BASE_URL = "https://yhub.test";
+const ORG = "test-org";
+const DOC_ID = "test-doc";
+
+// The factory now returns a callback that receives the editor instance (used to
+// resolve author ids to usernames via the UserExtension). These tests create a
+// bare editor with no UserExtension, so author labels stay as the raw `by` ids.
+function makeEndpoints() {
+ const editor = BlockNoteEditor.create();
+ return createYHubVersioningEndpoints({
+ baseUrl: BASE_URL,
+ org: ORG,
+ docId: DOC_ID,
+ activityLimit: 50,
+ })(editor);
+}
+
+function mockFetchResponse(body: unknown, status = 200) {
+ const encoded = encodeAny(body);
+ return new Response(encoded as Blob | BufferSource, {
+ status,
+ statusText: status === 200 ? "OK" : "Error",
+ });
+}
+
+function makeFragment(): Y.Type {
+ const doc = new Y.Doc();
+ const frag = doc.get("default", "XmlFragment");
+ frag.insert(0, ["test content"]);
+ return frag;
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("createYHubVersioningEndpoints", () => {
+ let fetchSpy: ReturnType;
+
+ beforeEach(() => {
+ fetchSpy = vi.spyOn(globalThis, "fetch");
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // -------------------------------------------------------------------------
+ // list
+ // -------------------------------------------------------------------------
+ describe("list", () => {
+ it("returns version-tagged entries using the id attribution as snapshot id", async () => {
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse([VERSION_ENTRY_2, VERSION_ENTRY_1]),
+ );
+ // Latest edit == latest version → no "current version" entry.
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2]));
+
+ const endpoints = makeEndpoints();
+ const snapshots = await endpoints.list();
+
+ expect(snapshots).toHaveLength(2);
+ expect(snapshots[0].id).toBe("uuid-version-2");
+ expect(snapshots[0].name).toBe("Test Version 2");
+ expect(snapshots[0].secondaryLabel).toBe("Dilbert Adams");
+ expect(snapshots[1].id).toBe("uuid-version-1");
+ expect(snapshots[1].name).toBe("Test Version 1");
+ });
+
+ it("passes withCustomAttributions=type:version to the API", async () => {
+ // First call: version markers. Second call: latest edit of any kind.
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
+
+ const endpoints = makeEndpoints();
+ await endpoints.list();
+
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ const versionUrl = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(versionUrl.pathname).toBe(`/activity/${ORG}/${DOC_ID}`);
+ expect(versionUrl.searchParams.get("withCustomAttributions")).toBe(
+ "type:version",
+ );
+ expect(versionUrl.searchParams.get("customAttributions")).toBe("true");
+
+ // The current-version probe fetches the latest entry of *any* type.
+ const currentUrl = new URL(fetchSpy.mock.calls[1][0] as string);
+ expect(currentUrl.pathname).toBe(`/activity/${ORG}/${DOC_ID}`);
+ expect(currentUrl.searchParams.get("limit")).toBe("1");
+ expect(currentUrl.searchParams.has("withCustomAttributions")).toBe(false);
+ });
+
+ it("returns empty array when no versions exist", async () => {
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
+
+ const endpoints = makeEndpoints();
+ const snapshots = await endpoints.list();
+
+ expect(snapshots).toEqual([]);
+ });
+
+ it("sorts snapshots newest-first", async () => {
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse([VERSION_ENTRY_1, VERSION_ENTRY_2]),
+ );
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2]));
+
+ const endpoints = makeEndpoints();
+ const snapshots = await endpoints.list();
+
+ expect(snapshots[0].createdAt).toBeGreaterThan(snapshots[1].createdAt);
+ });
+
+ it("silently skips entries without an id attribution", async () => {
+ const noIdEntry = {
+ from: 1782218082853,
+ to: 1782218082853,
+ by: "Bad Entry",
+ customAttributions: [{ k: "type", v: "version" }],
+ };
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse([VERSION_ENTRY_1, noIdEntry]),
+ );
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1]));
+
+ const endpoints = makeEndpoints();
+ const snapshots = await endpoints.list();
+
+ expect(snapshots).toHaveLength(1);
+ expect(snapshots[0].id).toBe("uuid-version-1");
+ });
+
+ it("prepends a 'current version' entry when there are edits beyond the latest version", async () => {
+ // A more recent edit than VERSION_ENTRY_2, by a different author.
+ const latestEdit = {
+ from: 1782218300000,
+ to: 1782218300000,
+ by: "Eve Online",
+ };
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse([VERSION_ENTRY_2, VERSION_ENTRY_1]),
+ );
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse([latestEdit]));
+
+ const endpoints = makeEndpoints();
+ const snapshots = await endpoints.list();
+
+ expect(snapshots).toHaveLength(3);
+ expect(snapshots[0].id).toBe(CURRENT_VERSION_ID);
+ expect(snapshots[0].createdAt).toBe(latestEdit.to);
+ expect(snapshots[0].secondaryLabel).toBe("Eve Online");
+ // The real version markers follow, newest-first.
+ expect(snapshots[1].id).toBe("uuid-version-2");
+ expect(snapshots[2].id).toBe("uuid-version-1");
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // create
+ // -------------------------------------------------------------------------
+ describe("create", () => {
+ it("PATCHes with type:version, id, and name attributions and returns optimistic snapshot", async () => {
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
+
+ const endpoints = makeEndpoints();
+ const snapshot = await endpoints.create!(makeFragment(), {
+ name: "My Version",
+ });
+
+ // Only one fetch call (PATCH) — no activity fetch
+ expect(fetchSpy).toHaveBeenCalledOnce();
+ const [patchUrl, patchInit] = fetchSpy.mock.calls[0];
+ expect(patchUrl).toBe(`${BASE_URL}/ydoc/${ORG}/${DOC_ID}`);
+ expect(patchInit.method).toBe("PATCH");
+ expect(patchInit.body).toBeInstanceOf(Uint8Array);
+
+ // Optimistic snapshot has a UUID id and the provided name
+ expect(snapshot.id).toMatch(/^[0-9a-f-]+$/);
+ expect(snapshot.name).toBe("My Version");
+ expect(snapshot.createdAt).toBeGreaterThan(0);
+ });
+
+ it("creates a version without a name", async () => {
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
+
+ const endpoints = makeEndpoints();
+ const snapshot = await endpoints.create!(makeFragment());
+
+ expect(snapshot.name).toBeUndefined();
+ expect(snapshot.id).toMatch(/^[0-9a-f-]+$/);
+ });
+
+ it("throws when the fragment is not attached to a doc", async () => {
+ const endpoints = makeEndpoints();
+ const detached = { doc: null } as unknown as Y.Type;
+ await expect(
+ endpoints.create!(detached, { name: "fail" }),
+ ).rejects.toThrow("not attached to a Y.Doc");
+ });
+
+ it("getContent works on the returned snapshot without an extra lookup", async () => {
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
+ const cs = makeChangeset();
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
+
+ const endpoints = makeEndpoints();
+ const snapshot = await endpoints.create!(makeFragment(), {
+ name: "new",
+ });
+
+ const content = await endpoints.getContent(snapshot);
+ expect(content).toBeInstanceOf(Uint8Array);
+ // PATCH + changeset — the timestamp comes from the snapshot itself, so
+ // there's no activity lookup.
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ const url = new URL(fetchSpy.mock.calls[1][0] as string);
+ expect(url.searchParams.get("to")).toBe(String(snapshot.createdAt));
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // getContent
+ // -------------------------------------------------------------------------
+ describe("getContent", () => {
+ it("fetches the changeset by to= with no activity lookup", async () => {
+ // changeset fetch
+ const cs = makeChangeset();
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
+
+ const endpoints = makeEndpoints();
+ const content = await endpoints.getContent(SNAPSHOT_1);
+
+ expect(content).toBeInstanceOf(Uint8Array);
+ expect(content.byteLength).toBeGreaterThan(0);
+
+ // The snapshot carries its own timestamp, so only the changeset is fetched.
+ expect(fetchSpy).toHaveBeenCalledOnce();
+
+ // changeset reconstructed by timestamp, NOT by custom attribution
+ const url = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(url.pathname).toBe(`/changeset/${ORG}/${DOC_ID}`);
+ expect(url.searchParams.get("ydoc")).toBe("true");
+ expect(url.searchParams.get("to")).toBe(String(SNAPSHOT_1.createdAt));
+ expect(url.searchParams.has("from")).toBe(false);
+ expect(url.searchParams.has("withCustomAttributions")).toBe(false);
+ });
+
+ it("throws when changeset has no nextDoc", async () => {
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ prevDoc: new Uint8Array() }),
+ );
+
+ const endpoints = makeEndpoints();
+ await expect(endpoints.getContent(SNAPSHOT_1)).rejects.toThrow(
+ "no document state",
+ );
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // getAttributions
+ // -------------------------------------------------------------------------
+ describe("getAttributions", () => {
+ it("fetches attributions between two versions", async () => {
+ const endpoints = makeEndpoints();
+
+ // changeset fetch (timestamps come straight from the snapshots)
+ const cs = makeChangeset({ attributions: true });
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
+
+ try {
+ await endpoints.getAttributions!(SNAPSHOT_2, SNAPSHOT_1);
+ } catch {
+ // Expected — mock attributions aren't valid Y.ContentMap
+ }
+
+ // Only the changeset is fetched — no activity lookups.
+ expect(fetchSpy).toHaveBeenCalledOnce();
+ const url = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(url.searchParams.get("from")).toBe(String(SNAPSHOT_1.createdAt));
+ expect(url.searchParams.get("to")).toBe(String(SNAPSHOT_2.createdAt));
+ expect(url.searchParams.get("attributions")).toBe("true");
+ });
+
+ it("uses from=0 when compareTo is omitted", async () => {
+ const endpoints = makeEndpoints();
+
+ // changeset fetch
+ const cs = makeChangeset({ attributions: true });
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
+
+ try {
+ await endpoints.getAttributions!(SNAPSHOT_1);
+ } catch {
+ // Expected
+ }
+
+ const url = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(url.searchParams.get("from")).toBe("0");
+ });
+
+ it("throws when changeset has no attributions", async () => {
+ const endpoints = makeEndpoints();
+
+ // changeset without attributions
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ nextDoc: new Uint8Array() }),
+ );
+
+ await expect(endpoints.getAttributions!(SNAPSHOT_1)).rejects.toThrow(
+ "no attributions",
+ );
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // restore
+ // -------------------------------------------------------------------------
+ describe("restore", () => {
+ it("fetches content and issues rollback (no backup)", async () => {
+ const endpoints = makeEndpoints();
+ const cs = makeChangeset();
+
+ // 1: GET /changeset (getContentAt via to=)
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
+ // 2: POST /rollback
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
+
+ const content = await endpoints.restore!(makeFragment(), SNAPSHOT_1);
+
+ // No backup PATCH and no activity lookup — just changeset + rollback.
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+
+ // 1st call: GET changeset by timestamp
+ const csUrl = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(csUrl.pathname).toBe(`/changeset/${ORG}/${DOC_ID}`);
+ expect(csUrl.searchParams.get("to")).toBe(String(SNAPSHOT_1.createdAt));
+
+ // 2nd call: POST rollback
+ const [rollbackUrl, rollbackInit] = fetchSpy.mock.calls[1];
+ expect(rollbackUrl).toContain(`/rollback/${ORG}/${DOC_ID}`);
+ expect(rollbackInit.method).toBe("POST");
+
+ expect(content).toBeInstanceOf(Uint8Array);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // updateSnapshotName is NOT provided
+ // -------------------------------------------------------------------------
+ describe("updateSnapshotName", () => {
+ it("is not provided (attributions are immutable)", () => {
+ const endpoints = makeEndpoints();
+ expect(endpoints.updateSnapshotName).toBeUndefined();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // error handling
+ // -------------------------------------------------------------------------
+ describe("error handling", () => {
+ it("throws on non-OK HTTP responses", async () => {
+ fetchSpy.mockResolvedValueOnce(
+ new Response("Not Found", { status: 404, statusText: "Not Found" }),
+ );
+
+ const endpoints = makeEndpoints();
+ await expect(endpoints.list()).rejects.toThrow(
+ "YHub request failed: 404",
+ );
+ });
+ });
+});
diff --git a/packages/core/src/y/versioning/index.ts b/packages/core/src/y/versioning/index.ts
new file mode 100644
index 0000000000..5a8ecdd18e
--- /dev/null
+++ b/packages/core/src/y/versioning/index.ts
@@ -0,0 +1,2 @@
+export * from "./yhub.js";
+export * from "./seed.js";
diff --git a/packages/core/src/y/versioning/seed.ts b/packages/core/src/y/versioning/seed.ts
new file mode 100644
index 0000000000..d3bad110b0
--- /dev/null
+++ b/packages/core/src/y/versioning/seed.ts
@@ -0,0 +1,168 @@
+import * as Y from "@y/y";
+import { encodeAny } from "lib0/buffer";
+import { uint32 } from "lib0/random";
+
+import type { BuildSnapshotsResult } from "../extensions/snapshotBuilder.js";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface SeedYHubDocumentOptions {
+ /** Base URL of the YHub API (e.g. `"https://yhub.example.com"`), no trailing slash. */
+ baseUrl: string;
+ /** YHub organisation identifier. */
+ org: string;
+ /** Document identifier within the organisation. */
+ docId: string;
+ /** Optional headers to include in every request (e.g. auth tokens). */
+ headers?: Record |