diff --git a/apps/server/src/collaboration/yjs.util.spec.ts b/apps/server/src/collaboration/yjs.util.spec.ts index 8f6fc2e4..93f4ea5a 100644 --- a/apps/server/src/collaboration/yjs.util.spec.ts +++ b/apps/server/src/collaboration/yjs.util.spec.ts @@ -529,4 +529,79 @@ describe('replaceYjsMarkedText', () => { expect(result).toEqual({ applied: false, currentText: 'abcdef' }); expect(text.toDelta()).toEqual(before); }); + + // #496: apply must NOT silently strip the replaced run's inline formatting. + // Build a paragraph and format the marked range with extra marks, then assert + // the replacement carries them. + function buildFormatted( + runs: Array<{ text: string; attrs?: Record }>, + ): { 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.attrs) text.format(offset, run.text.length, run.attrs); + offset += run.text.length; + } + return { fragment, text }; + } + + it('preserves the original run formatting (bold + link) on the replacement', () => { + const { fragment, text } = buildFormatted([ + { text: 'see ' }, + { + text: 'old', + attrs: { + comment: { commentId: 'c1', resolved: false }, + bold: true, + link: { href: 'https://x.test' }, + }, + }, + { text: ' end' }, + ]); + + const result = replaceYjsMarkedText(fragment, 'c1', 'old', 'new'); + + expect(result).toEqual({ applied: true, currentText: 'new' }); + // The comment anchor AND the bold/link marks survive the delete+insert. + expect(text.toDelta()).toEqual([ + { insert: 'see ' }, + { + insert: 'new', + attributes: { + comment: { commentId: 'c1', resolved: false }, + bold: true, + link: { href: 'https://x.test' }, + }, + }, + { insert: ' end' }, + ]); + }); + + it('mixed formatting under the mark: replacement takes the DOMINANT run marks', () => { + // Marked run = "bold" (bold, 4 chars) + "x" (plain, 1 char), same commentId. + // The dominant (longer) segment is bold, so the flat replacement is bold. + const { fragment, text } = buildFormatted([ + { + text: 'bold', + attrs: { comment: { commentId: 'c1', resolved: false }, bold: true }, + }, + { text: 'x', attrs: { comment: { commentId: 'c1', resolved: false } } }, + ]); + + const result = replaceYjsMarkedText(fragment, 'c1', 'boldx', 'Z'); + + expect(result).toEqual({ applied: true, currentText: 'Z' }); + expect(text.toDelta()).toEqual([ + { + insert: 'Z', + attributes: { comment: { commentId: 'c1', resolved: false }, bold: true }, + }, + ]); + }); }); diff --git a/apps/server/src/collaboration/yjs.util.ts b/apps/server/src/collaboration/yjs.util.ts index bea28dfc..25c6af27 100644 --- a/apps/server/src/collaboration/yjs.util.ts +++ b/apps/server/src/collaboration/yjs.util.ts @@ -145,6 +145,10 @@ type MarkedSegment = { length: number; text: string; markAttrs: Record; + // The FULL attribute set of this delta run — the `comment` mark plus any + // inline formatting (bold/italic/code/link/…). Captured so apply can carry the + // original run's formatting onto the replacement instead of dropping it. + attributes: Record; }; /** @@ -202,6 +206,7 @@ export function replaceYjsMarkedText( length, text: insert, markAttrs: markAttr, + attributes, }); } offset += length; @@ -251,15 +256,26 @@ export function replaceYjsMarkedText( 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. + // 3. All guards passed: delete the marked run and re-insert newText 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; + + // Carry the ORIGINAL run's formatting onto the replacement (#496): inserting + // with only the `comment` mark silently dropped bold/italic/code/link of the + // replaced text. Yjs applies one flat attribute set to the whole insert, so + // when the marked run mixes formatting we pick the DOMINANT segment (the one + // covering the most characters) and apply its attributes — a v1 that preserves + // the common single-format case exactly and, for a mixed run, keeps the + // prevailing style rather than losing all of it. The dominant segment already + // carries the `comment` mark (every collected segment does), so the anchor is + // preserved; we defensively re-assert it in case a future attribute shape + // omits it. + const dominant = segments.reduce((a, b) => (b.length > a.length ? b : a)); + const insertAttrs = { ...dominant.attributes, comment: dominant.markAttrs }; node.delete(start, len); - node.insert(start, newText, { comment: markAttrs }); + node.insert(start, newText, insertAttrs); return { applied: true, currentText: newText }; }