diff --git a/apps/server/src/collaboration/yjs.util.spec.ts b/apps/server/src/collaboration/yjs.util.spec.ts index 29511d6d..eb0eb0f0 100644 --- a/apps/server/src/collaboration/yjs.util.spec.ts +++ b/apps/server/src/collaboration/yjs.util.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/collaboration/yjs.util.ts b/apps/server/src/collaboration/yjs.util.ts index c79e5331..bea28dfc 100644 --- a/apps/server/src/collaboration/yjs.util.ts +++ b/apps/server/src/collaboration/yjs.util.ts @@ -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; +}; + +/** + * 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.