mutatePageContent wrote agent edits back by DELETING the whole Yjs fragment and
re-applying a fresh Y.Doc. Yjs is a CRDT — the editor anchors its selection to
node ids — so wiping every id made an open editor's cursor lose its anchor and
snap to the end of the document on every agent write. It was most visible on
comment anchoring (issue #152): a comment changes no text, yet the cursor jumped.
(Before commit 4201f0a3 the anchoring silently no-op'd, so the destructive write
never ran for comments — hence the regression.)
Fix: write via `updateYFragment` (y-prosemirror) — the same routine the editor
uses to sync its own edits into Yjs. It structurally diffs the new doc against
the live fragment and touches only changed nodes, preserving the ids of unchanged
ones, so the cursor stays put. This improves ALL agent write tools (text edits,
node ops, comments, replace) — minimal diff instead of full replace: less collab
noise, stable block-ids, other users' cursors no longer disrupted.
- collaboration.ts: new `applyDocToFragment` (sanitize -> PMNode.fromJSON against
a memoized docmost schema -> updateYFragment in one transact), keeping the
`findUnstorableAttr` encode diagnostic; swap the destructive write-back for it.
- package.json: `y-prosemirror` promoted to a direct dependency (was transitive).
- test: comment-cursor-stability.test.mjs — a Yjs RelativePosition (the cursor
anchor) survives both a sibling edit and a comment-mark anchoring (the old
full-replace tombstoned it -> null). 292 package tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
76 lines
3.1 KiB
JavaScript
76 lines
3.1 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import * as Y from "yjs";
|
|
import { applyDocToFragment } from "../../build/lib/collaboration.js";
|
|
|
|
// Regression for issue #152: agent writes (comment anchoring especially) must
|
|
// NOT yank the open editor's cursor to the end of the document. The cursor is a
|
|
// Yjs RelativePosition anchored to node ids; the old write-back deleted the whole
|
|
// fragment and rebuilt it, destroying every id, so the position no longer
|
|
// resolved. `applyDocToFragment` uses `updateYFragment` (the editor's own diff),
|
|
// which keeps unchanged nodes' ids — so a RelativePosition still resolves.
|
|
|
|
const para = (text, marks) => ({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text, ...(marks ? { marks } : {}) }],
|
|
});
|
|
const doc = (...paras) => ({ type: "doc", content: paras });
|
|
|
|
/** The XmlText of the Nth paragraph in the live fragment. */
|
|
function paragraphText(ydoc, n) {
|
|
const el = ydoc.getXmlFragment("default").get(n); // <paragraph> XmlElement
|
|
return el.get(0); // its XmlText child
|
|
}
|
|
|
|
test("an UNCHANGED node keeps its Yjs identity across an edit (cursor survives)", () => {
|
|
const ydoc = new Y.Doc();
|
|
applyDocToFragment(ydoc, doc(para("Hello world"), para("Second")));
|
|
|
|
// Anchor a cursor at offset 5 inside the FIRST (soon-to-be-unchanged) paragraph.
|
|
const relPos = Y.createRelativePositionFromTypeIndex(paragraphText(ydoc, 0), 5);
|
|
|
|
// Edit only the SECOND paragraph; the first is untouched.
|
|
applyDocToFragment(ydoc, doc(para("Hello world"), para("Second edited")));
|
|
|
|
const abs = Y.createAbsolutePositionFromRelativePosition(relPos, ydoc);
|
|
assert.notEqual(abs, null, "the cursor's relative position must still resolve");
|
|
assert.equal(abs.index, 5, "the cursor must stay at the same offset");
|
|
// And the edit actually landed.
|
|
assert.equal(paragraphText(ydoc, 1).toString(), "Second edited");
|
|
});
|
|
|
|
test("anchoring a comment mark keeps the cursor in the marked text (issue #152)", () => {
|
|
const ydoc = new Y.Doc();
|
|
applyDocToFragment(ydoc, doc(para("Hello world")));
|
|
|
|
// The user's cursor sits inside the text that is about to be commented.
|
|
const relPos = Y.createRelativePositionFromTypeIndex(paragraphText(ydoc, 0), 3);
|
|
|
|
// Agent anchors a comment over "Hello" — text is identical, only a mark added.
|
|
applyDocToFragment(
|
|
ydoc,
|
|
doc({
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Hello",
|
|
marks: [
|
|
{ type: "comment", attrs: { commentId: "c1", resolved: false } },
|
|
],
|
|
},
|
|
{ type: "text", text: " world" },
|
|
],
|
|
}),
|
|
);
|
|
|
|
// The text is intact (the mark splits "Hello" / " world" but reads the same).
|
|
const para0 = ydoc.getXmlFragment("default").get(0);
|
|
assert.equal(para0.toString().replace(/<[^>]*>/g, ""), "Hello world");
|
|
|
|
// ...and the cursor anchored before the write still resolves (did not jump to
|
|
// the document end as it did with the destructive full-replace).
|
|
const abs = Y.createAbsolutePositionFromRelativePosition(relPos, ydoc);
|
|
assert.notEqual(abs, null, "comment anchoring must not destroy the cursor anchor");
|
|
});
|