feat(collab): replaceYjsMarkedText — atomic check-and-replace of comment-marked text (#315 phase 2)
The primitive behind "Apply comment suggestion": walk the XmlFragment, collect
the delta segments carrying the `comment` mark for a commentId, and replace them
with new text ONLY if the run is intact (single Y.XmlText, contiguous, and the
joined text still equals the expected anchor). Otherwise return a verdict
{ applied:false, currentText } — null when the anchor is gone, else the current
text — so the caller can report "someone changed it". On apply it deletes the
run and re-inserts the new text re-attaching the same comment mark (thread stays
anchored). Mutates in place for the caller's connection.transact(); opens no
transaction of its own.
Non-string inserts (embeds) advance the offset by their 1-unit index length so a
marked segment after an embed gets the right position and an embed inside a run
is correctly rejected as a changed anchor.
Tests (yjs.util.spec.ts): happy path (mark preserved, surrounding text and no
mark-bleed), resolved-mark match, changed text, deleted anchor, paragraph split,
interleaved unmarked text, and embed before/inside the run. 17 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
setYjsMark,
|
||||
removeYjsMarkByAttribute,
|
||||
updateYjsMarkAttribute,
|
||||
replaceYjsMarkedText,
|
||||
type YjsSelection,
|
||||
} from './yjs.util';
|
||||
|
||||
@@ -276,3 +277,230 @@ describe('updateYjsMarkAttribute', () => {
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceYjsMarkedText', () => {
|
||||
// Build a single-paragraph XmlText from runs. Insert the whole string as
|
||||
// plain text FIRST, then format only the marked ranges — otherwise text
|
||||
// inserted right after a marked run inherits its comment mark (Yjs carries
|
||||
// formatting from the left insertion boundary).
|
||||
function buildRuns(
|
||||
runs: Array<{
|
||||
text: string;
|
||||
comment?: { commentId: string; resolved: boolean };
|
||||
}>,
|
||||
): { fragment: Y.XmlFragment; text: Y.XmlText } {
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const para = new Y.XmlElement('paragraph');
|
||||
fragment.insert(0, [para]);
|
||||
const text = new Y.XmlText();
|
||||
para.insert(0, [text]);
|
||||
text.insert(0, runs.map((r) => r.text).join(''));
|
||||
let offset = 0;
|
||||
for (const run of runs) {
|
||||
if (run.comment) {
|
||||
text.format(offset, run.text.length, { comment: run.comment });
|
||||
}
|
||||
offset += run.text.length;
|
||||
}
|
||||
return { fragment, text };
|
||||
}
|
||||
|
||||
// Two paragraphs, each with its own XmlText, both marked with the same
|
||||
// commentId — mirrors a suggestion anchor that got split across blocks.
|
||||
function buildTwoParagraphs(
|
||||
a: { text: string; comment?: { commentId: string; resolved: boolean } },
|
||||
b: { text: string; comment?: { commentId: string; resolved: boolean } },
|
||||
): { fragment: Y.XmlFragment; textA: Y.XmlText; textB: Y.XmlText } {
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const build = (seg: typeof a) => {
|
||||
const para = new Y.XmlElement('paragraph');
|
||||
const text = new Y.XmlText();
|
||||
para.insert(0, [text]);
|
||||
text.insert(0, seg.text);
|
||||
if (seg.comment) {
|
||||
text.format(0, seg.text.length, { comment: seg.comment });
|
||||
}
|
||||
return { para, text };
|
||||
};
|
||||
const pa = build(a);
|
||||
const pb = build(b);
|
||||
fragment.insert(0, [pa.para, pb.para]);
|
||||
return { fragment, textA: pa.text, textB: pb.text };
|
||||
}
|
||||
|
||||
it('happy path: replaces marked text with newText and keeps the comment mark', () => {
|
||||
const { fragment, text } = buildRuns([
|
||||
{ text: 'Hello ' },
|
||||
{ text: 'world', comment: { commentId: 'c1', resolved: false } },
|
||||
{ text: '!' },
|
||||
]);
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'world', 'planet');
|
||||
|
||||
expect(result).toEqual({ applied: true, currentText: 'planet' });
|
||||
// New text carries the SAME comment mark; surrounding text is untouched.
|
||||
expect(text.toDelta()).toEqual([
|
||||
{ insert: 'Hello ' },
|
||||
{
|
||||
insert: 'planet',
|
||||
attributes: { comment: { commentId: 'c1', resolved: false } },
|
||||
},
|
||||
{ insert: '!' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches by commentId even when the mark is resolved', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'foo', comment: { commentId: 'c9', resolved: true } },
|
||||
]);
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c9', 'foo', 'bar');
|
||||
|
||||
expect(result).toEqual({ applied: true, currentText: 'bar' });
|
||||
expect(text.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'bar',
|
||||
attributes: { comment: { commentId: 'c9', resolved: true } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('changed text: marked text differs from expected → no-op, doc unchanged', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
|
||||
]);
|
||||
const before = text.toDelta();
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'expected', 'new');
|
||||
|
||||
expect(result).toEqual({ applied: false, currentText: 'abc' });
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
|
||||
it('anchor deleted: no mark with that commentId → { applied: false, currentText: null }', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
|
||||
]);
|
||||
const before = text.toDelta();
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'missing', 'abc', 'new');
|
||||
|
||||
expect(result).toEqual({ applied: false, currentText: null });
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
|
||||
it('paragraph split: same commentId in two XmlText nodes → no-op, doc unchanged', () => {
|
||||
const { fragment, textA, textB } = buildTwoParagraphs(
|
||||
{ text: 'Hello ', comment: { commentId: 'c1', resolved: false } },
|
||||
{ text: 'world', comment: { commentId: 'c1', resolved: false } },
|
||||
);
|
||||
const beforeA = textA.toDelta();
|
||||
const beforeB = textB.toDelta();
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'Hello world', 'new');
|
||||
|
||||
expect(result).toEqual({ applied: false, currentText: 'Hello world' });
|
||||
expect(textA.toDelta()).toEqual(beforeA);
|
||||
expect(textB.toDelta()).toEqual(beforeB);
|
||||
});
|
||||
|
||||
it('interleaved unmarked text: marked run not contiguous → no-op, doc unchanged', () => {
|
||||
const { fragment, text } = buildRuns([
|
||||
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
|
||||
{ text: 'X' },
|
||||
{ text: 'def', comment: { commentId: 'c1', resolved: false } },
|
||||
]);
|
||||
const before = text.toDelta();
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'abcdef', 'new');
|
||||
|
||||
// Joined marked text ("abcdef") is returned, but the run is not contiguous.
|
||||
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
|
||||
it('preserves surrounding text and merges adjacent marked segments on apply', () => {
|
||||
// The marked run itself is split into two adjacent delta segments; they must
|
||||
// be treated as one contiguous run and replaced as a whole.
|
||||
const { fragment, text } = buildRuns([
|
||||
{ text: 'pre ' },
|
||||
{ text: 'ab', comment: { commentId: 'c1', resolved: false } },
|
||||
{ text: 'cd', comment: { commentId: 'c1', resolved: false } },
|
||||
{ text: ' post' },
|
||||
]);
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'abcd', 'Z');
|
||||
|
||||
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
||||
expect(text.toDelta()).toEqual([
|
||||
{ insert: 'pre ' },
|
||||
{
|
||||
insert: 'Z',
|
||||
attributes: { comment: { commentId: 'c1', resolved: false } },
|
||||
},
|
||||
{ insert: ' post' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('embed before the marked run: offset accounts for the embed unit → replaces the right text, embed intact', () => {
|
||||
// "AB", then a Yjs embed (1 index unit), then marked "world". Before the
|
||||
// fix the embed was skipped WITHOUT advancing offset, so the computed start
|
||||
// for "world" was too low by 1 → delete/insert would have hit the embed/text
|
||||
// instead of "world", mangling the embed. With the fix offset is correct.
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const para = new Y.XmlElement('paragraph');
|
||||
fragment.insert(0, [para]);
|
||||
const text = new Y.XmlText();
|
||||
para.insert(0, [text]);
|
||||
text.insert(0, 'AB');
|
||||
text.insertEmbed(2, { image: { src: 'x' } });
|
||||
text.insert(3, 'world');
|
||||
text.format(3, 'world'.length, {
|
||||
comment: { commentId: 'c1', resolved: false },
|
||||
});
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'world', 'planet');
|
||||
|
||||
expect(result).toEqual({ applied: true, currentText: 'planet' });
|
||||
// "AB" untouched, embed still present and intact, "world" → "planet"
|
||||
// carrying the SAME comment mark.
|
||||
expect(text.toDelta()).toEqual([
|
||||
{ insert: 'AB' },
|
||||
{ insert: { image: { src: 'x' } } },
|
||||
{
|
||||
insert: 'planet',
|
||||
attributes: { comment: { commentId: 'c1', resolved: false } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('embed inside the marked run: embed splits the run → non-contiguous → no-op, doc unchanged', () => {
|
||||
// marked "abc", an embed, marked "def" — same commentId. The embed occupies
|
||||
// one index unit between the two marked segments, so they are not contiguous
|
||||
// → the guard rejects it and nothing is mutated (embed intact).
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const para = new Y.XmlElement('paragraph');
|
||||
fragment.insert(0, [para]);
|
||||
const text = new Y.XmlText();
|
||||
para.insert(0, [text]);
|
||||
text.insert(0, 'abc');
|
||||
text.insertEmbed(3, { image: { src: 'y' } });
|
||||
text.insert(4, 'def');
|
||||
text.format(0, 'abc'.length, {
|
||||
comment: { commentId: 'c1', resolved: false },
|
||||
});
|
||||
text.format(4, 'def'.length, {
|
||||
comment: { commentId: 'c1', resolved: false },
|
||||
});
|
||||
const before = text.toDelta();
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'abcdef', 'new');
|
||||
|
||||
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,6 +133,137 @@ export function removeYjsMarkByAttribute(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single marked delta segment collected during the walk, together with the
|
||||
* Y.XmlText node that owns it, the segment's start offset within that node,
|
||||
* and the full `comment` mark attributes object (needed to re-attach the mark
|
||||
* to the replacement text).
|
||||
*/
|
||||
type MarkedSegment = {
|
||||
node: Y.XmlText;
|
||||
offset: number;
|
||||
length: number;
|
||||
text: string;
|
||||
markAttrs: Record<string, any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Atomically check-and-replace the text currently under a comment mark.
|
||||
*
|
||||
* Walks the fragment collecting every delta segment whose `comment` mark has the
|
||||
* given commentId. The replacement is applied ONLY if the marked run is intact:
|
||||
* it lives in a single Y.XmlText node, is contiguous (no unmarked text spliced
|
||||
* into the middle), and its joined text still equals `expectedText`. On success
|
||||
* the run is deleted and `newText` is inserted at the same offset carrying the
|
||||
* SAME comment attributes, so the comment thread stays anchored to the new text.
|
||||
*
|
||||
* This mutates the passed fragment/text directly and does NOT open its own Y
|
||||
* transaction — the caller is expected to wrap the call in connection.transact()
|
||||
* so the delete+insert are atomic (mirrors updateYjsMarkAttribute's direct
|
||||
* mutation style).
|
||||
*
|
||||
* @returns `{ applied: true, currentText: newText }` on replacement, otherwise
|
||||
* `{ applied: false, currentText }` where currentText is the text currently
|
||||
* under the mark (or null when the mark/anchor no longer exists).
|
||||
*/
|
||||
export function replaceYjsMarkedText(
|
||||
fragment: Y.XmlFragment,
|
||||
commentId: string,
|
||||
expectedText: string,
|
||||
newText: string,
|
||||
): { applied: boolean; currentText: string | null } {
|
||||
// 1. Collect every marked segment in document order.
|
||||
const segments: MarkedSegment[] = [];
|
||||
|
||||
const processItem = (item: any) => {
|
||||
if (item instanceof Y.XmlText) {
|
||||
const deltas = item.toDelta();
|
||||
let offset = 0;
|
||||
|
||||
for (const delta of deltas) {
|
||||
const insert = delta.insert;
|
||||
// Non-string inserts (embeds) carry no text length we can splice on.
|
||||
if (typeof insert !== 'string') {
|
||||
// A Yjs embed occupies one unit in the index space used by delete/
|
||||
// insert/format — advance offset so a marked segment after an embed
|
||||
// gets the right position (and an embed inside a marked run creates a
|
||||
// gap → the contiguity guard rejects it as a changed anchor).
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
const length = insert.length;
|
||||
const attributes = delta.attributes ?? {};
|
||||
const markAttr = attributes['comment'];
|
||||
|
||||
if (markAttr && markAttr.commentId === commentId) {
|
||||
segments.push({
|
||||
node: item,
|
||||
offset,
|
||||
length,
|
||||
text: insert,
|
||||
markAttrs: markAttr,
|
||||
});
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
} else if (item instanceof Y.XmlElement) {
|
||||
for (let i = 0; i < item.length; i++) {
|
||||
processItem(item.get(i));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < fragment.length; i++) {
|
||||
processItem(fragment.get(i));
|
||||
}
|
||||
|
||||
const joinedText = segments.map((s) => s.text).join('');
|
||||
|
||||
// 2a. No segments — the mark/anchor was deleted.
|
||||
if (segments.length === 0) {
|
||||
return { applied: false, currentText: null };
|
||||
}
|
||||
|
||||
// 2b. Segments span more than one Y.XmlText node (paragraph split by Enter,
|
||||
// or the mark bled across blocks) — treat as changed.
|
||||
const node = segments[0].node;
|
||||
const sameNode = segments.every((s) => s.node === node);
|
||||
if (!sameNode) {
|
||||
return { applied: false, currentText: joinedText };
|
||||
}
|
||||
|
||||
// 2c. Non-contiguous within the single node: unmarked text is spliced between
|
||||
// the first and last marked segment. Since collected segments are in document
|
||||
// order, contiguity holds iff each segment starts where the previous ended.
|
||||
let contiguous = true;
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
if (segments[i].offset !== segments[i - 1].offset + segments[i - 1].length) {
|
||||
contiguous = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!contiguous) {
|
||||
return { applied: false, currentText: joinedText };
|
||||
}
|
||||
|
||||
// 2d. The text under the mark changed.
|
||||
if (joinedText !== expectedText) {
|
||||
return { applied: false, currentText: joinedText };
|
||||
}
|
||||
|
||||
// 3. All guards passed: delete the marked run and re-insert newText with the
|
||||
// same comment attributes at the same offset. Atomic within the caller's
|
||||
// transaction.
|
||||
const start = segments[0].offset;
|
||||
const len = segments.reduce((sum, s) => sum + s.length, 0);
|
||||
const markAttrs = segments[0].markAttrs;
|
||||
|
||||
node.delete(start, len);
|
||||
node.insert(start, newText, { comment: markAttrs });
|
||||
|
||||
return { applied: true, currentText: newText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a mark's attributes for all text that has the specified attribute value.
|
||||
* Useful for resolving/unresolving comments by commentId.
|
||||
|
||||
Reference in New Issue
Block a user