Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/core/src/yjs/extensions/ForkYDoc.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it } from "vite-plus/test";
import { trackPosition } from "../../api/positionMapping.js";
import * as Y from "yjs";
import { Awareness } from "y-protocols/awareness";
import { BlockNoteEditor } from "../../index.js";
Expand Down Expand Up @@ -213,4 +214,40 @@ describe("ForkYDocExtension", () => {
forkYDoc.merge({ keepChanges: true });
expect(getEditorText(ctx.editor)).toContain("Forked modification");
});

// https://github.com/TypeCellOS/BlockNote/issues/2946
it("can track positions while forked", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Hello World");

const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
forkYDoc.fork();

// Store position at "Hello| World"
const getCursorPos = trackPosition(ctx.editor, 8);
expect(getCursorPos()).toBe(8);

// Insert text at the beginning of "|Hello World"
ctx.editor._tiptapEditor.commands.insertContentAt(3, "Test ");
expect(getCursorPos()).toBe(13);
});

// https://github.com/TypeCellOS/BlockNote/issues/2946
it("can track positions across fork and merge", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Hello World");

// Store position at "Hello| World"
const getCursorPos = trackPosition(ctx.editor, 8);

const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
forkYDoc.fork();
expect(getCursorPos()).toBe(8);

ctx.editor._tiptapEditor.commands.insertContentAt(3, "Test ");
expect(getCursorPos()).toBe(13);

forkYDoc.merge({ keepChanges: true });
expect(getCursorPos()).toBe(13);
});
});
27 changes: 26 additions & 1 deletion packages/core/src/yjs/extensions/ForkYDoc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { yUndoPluginKey } from "y-prosemirror";
import { ySyncPluginKey, yUndoPluginKey } from "y-prosemirror";
import * as Y from "yjs";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
createExtension,
createStore,
Expand All @@ -11,6 +12,26 @@ import { YSyncExtension } from "./YSync.js";
import { YUndoExtension } from "./YUndo.js";
import { findTypeInOtherYdoc } from "../utils.js";

/**
* Point the `ySync` plugin state at `fragment`.
*
* Swapping the `ySync` plugin reconfigures the ProseMirror state, and
* ProseMirror carries over the state of plugins that share a key instead of
* re-initializing them. So the new plugin's `binding` (which is set from its
* view, via a transaction) ends up on the new fragment, while `type` and `doc`
* still point at the fragment the editor was bound to before. Anything reading
* those (e.g. `RelativePositionMappingExtension`) would then mix up the two
* Y.Docs, so we set them explicitly here.
*/
function bindYSyncPluginStateTo(
editor: BlockNoteEditor<any, any, any>,
fragment: Y.XmlFragment,
) {
editor.transact((tr) =>
tr.setMeta(ySyncPluginKey, { type: fragment, doc: fragment.doc }),
);
}

export const ForkYDocExtension = createExtension(
({ editor, options }: ExtensionOptions<CollaborationOptions>) => {
let forkedState:
Expand Down Expand Up @@ -84,6 +105,8 @@ export const ForkYDocExtension = createExtension(
],
);

bindYSyncPluginStateTo(editor, forkedFragment);

// Tell the store that the editor is now forked
store.setState({ isForked: true });
},
Expand All @@ -110,6 +133,8 @@ export const ForkYDocExtension = createExtension(
],
);

bindYSyncPluginStateTo(editor, originalFragment);

// Reset the undo stack to the original undo stack
yUndoPluginKey.getState(
editor.prosemirrorState,
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/yjs/extensions/RelativePositionMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,15 @@ export const RelativePositionMappingExtension = createExtension(
const curYSyncPluginState = ySyncPluginKey.getState(
editor.prosemirrorState,
) as typeof ySyncPluginState;
// Resolve against the doc that owns the currently bound type, and not
// against `curYSyncPluginState.doc`. Those can point at different
// Y.Docs (e.g. right after forking the doc, see `ForkYDocExtension`),
// in which case the resolved type wouldn't be part of the bound
// fragment and the position would be reported as "not found".
const boundType = curYSyncPluginState.binding.type;
const pos = relativePositionToAbsolutePosition(
curYSyncPluginState.doc,
curYSyncPluginState.binding.type,
boundType.doc,
boundType,
relativePosition,
curYSyncPluginState.binding.mapping,
);
Expand Down
3 changes: 2 additions & 1 deletion packages/xl-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@
"typescript": "^5.9.3",
"undici": "^6.22.0",
"vite-plugin-externalize-deps": "^0.10.0",
"vite-plus": "catalog:"
"vite-plus": "catalog:",
"yjs": "^13.6.27"
},
"peerDependencies": {
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
Expand Down
137 changes: 137 additions & 0 deletions packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Regression test for https://github.com/TypeCellOS/BlockNote/issues/2946
*
* Runs the `update` stream tool against a Yjs-collaborative editor, fully
* offline (no LLM call): the tool call is fed straight into the executor.
*
* `AIExtension.invokeAI` forks the Y.Doc before the request starts, so the
* fork is part of the setup here.
*/
import {
BlockNoteEditor,
expandPMRangeToWords,
getBlockInfo,
getNodeById,
} from "@blocknote/core";
import type { ForkYDocExtension } from "@blocknote/core/yjs";
import { withCollaboration } from "@blocknote/core/yjs";
import { TextSelection } from "prosemirror-state";
import { describe, expect, it } from "vite-plus/test";
import * as Y from "yjs";

import { AIExtension } from "../../../AIExtension.js";
import { StreamToolExecutor } from "../../../streamTool/StreamToolExecutor.js";
import { StreamTool } from "../../../streamTool/streamTool.js";
import { tools } from "./tools/index.js";

function createLocalEditor(text: string) {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "paragraph", content: text }],
trailingBlock: false,
extensions: [AIExtension()],
});
editor.mount(document.createElement("div"));

return { editor, fork: () => undefined };
}

function createCollabEditor(text: string) {
const ydoc = new Y.Doc();
const editor = BlockNoteEditor.create(
withCollaboration({
collaboration: {
fragment: ydoc.getXmlFragment("doc"),
user: { color: "#ff0000", name: "Local User" },
provider: undefined,
},
trailingBlock: false,
extensions: [AIExtension()],
}),
);
editor.mount(document.createElement("div"));

editor.replaceBlocks(editor.document, [{ type: "paragraph", content: text }]);

return {
editor,
fork: () =>
editor.getExtension<typeof ForkYDocExtension>("yForkDoc")?.fork(),
};
Comment on lines +55 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fail when the fork extension is unavailable.

Optional chaining makes fork() a no-op when yForkDoc is missing. The collaborative tests can then pass without testing the forked Y.Doc path. Throw if the extension is unavailable.

Proposed fix
   return {
     editor,
-    fork: () =>
-      editor.getExtension<typeof ForkYDocExtension>("yForkDoc")?.fork(),
+    fork: () => {
+      const forkYDoc = editor.getExtension<typeof ForkYDocExtension>("yForkDoc");
+      if (!forkYDoc) {
+        throw new Error("yForkDoc extension is not available");
+      }
+      forkYDoc.fork();
+    },
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
editor,
fork: () =>
editor.getExtension<typeof ForkYDocExtension>("yForkDoc")?.fork(),
};
return {
editor,
fork: () => {
const forkYDoc = editor.getExtension<typeof ForkYDocExtension>("yForkDoc");
if (!forkYDoc) {
throw new Error("yForkDoc extension is not available");
}
forkYDoc.fork();
},
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts` around lines
55 - 59, Update the returned fork function around the yForkDoc extension lookup
to fail explicitly when ForkYDocExtension is unavailable instead of returning
undefined through optional chaining. Preserve the existing extension.fork()
behavior when the extension is present, and ensure collaborative tests cannot
silently skip the forked Y.Doc path.

}

/**
* Selects the full content of the first block, mirroring what the AI menu does
* (`buildAIRequest` -> `expandPMRangeToWords`)
*/
function selectWholeFirstBlock(editor: BlockNoteEditor<any, any, any>) {
const id = editor.document[0].id;
const info = getBlockInfo(getNodeById(id, editor.prosemirrorState.doc)!);
if (!info.isBlockContainer) {
throw new Error("not a block container");
}
const from = info.blockContent.beforePos + 1;
const to = info.blockContent.afterPos - 1;

editor.transact((tr) => {
tr.setSelection(TextSelection.create(tr.doc, from, to));
});

return expandPMRangeToWords(editor.prosemirrorState.doc, {
$from: editor.prosemirrorState.doc.resolve(from),
$to: editor.prosemirrorState.doc.resolve(to),
});
}

async function runUpdate(
editor: BlockNoteEditor<any, any, any>,
id: string,
html: string,
selection?: { from: number; to: number },
) {
const streamTools = [
tools.update(editor, {
idsSuffixed: false,
withDelays: false,
updateSelection: selection,
}),
] as StreamTool<any>[];

await new StreamToolExecutor(streamTools).execute(
(async function* () {
yield {
operation: { type: "update" as const, id, block: html },
isUpdateToPreviousOperation: false,
isPossiblyPartial: false,
metadata: undefined,
};
})(),
);
}

describe.each([
["local", createLocalEditor],
["collaborative", createCollabEditor],
])("update tool (%s)", (_name, createEditor) => {
it("updates a selected paragraph", async () => {
const { editor, fork } = createEditor("Bonjour le monde");
fork();
const id = editor.document[0].id;
const selection = selectWholeFirstBlock(editor);

await runUpdate(editor, id, "<p>Bonjour à tous</p>", selection);

editor.getExtension(AIExtension)?.acceptChanges();
expect((editor.document[0] as any).content[0].text).toBe("Bonjour à tous");
});

it("updates a paragraph without a selection", async () => {
const { editor, fork } = createEditor("Bonjour le monde");
fork();
const id = editor.document[0].id;

await runUpdate(editor, id, "<p>Bonjour à tous</p>");

editor.getExtension(AIExtension)?.acceptChanges();
expect((editor.document[0] as any).content[0].text).toBe("Bonjour à tous");
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading