Hello world
', + user: { name: 'Track Tester', email: 'track@example.com' }, + trackedChanges: { pairReplacements: false }, + }); + + try { + const worldRange = getSubstringRange(interactionEditor.state.doc, 'world'); + interactionEditor.commands.insertTrackedChange({ + from: worldRange.from, + to: worldRange.to, + text: 'universe', + }); + + // Gather both independent ids for the insertion and deletion halves. + const changes = []; + interactionEditor.state.doc.descendants((node) => { + node.marks.forEach((mark) => { + if (mark.type.name === TrackInsertMarkName || mark.type.name === TrackDeleteMarkName) { + changes.push({ type: mark.type.name, id: mark.attrs.id }); + } + }); + }); + const insertion = changes.find((c) => c.type === TrackInsertMarkName); + const deletion = changes.find((c) => c.type === TrackDeleteMarkName); + expect(insertion).toBeDefined(); + expect(deletion).toBeDefined(); + expect(insertion.id).not.toBe(deletion.id); + + // Accepting the insertion must not touch the deletion side. + interactionEditor.commands.acceptTrackedChangeById(insertion.id); + expect(getMarkedText(interactionEditor.state.doc, TrackInsertMarkName)).toBe(''); + expect(getMarkedText(interactionEditor.state.doc, TrackDeleteMarkName)).toBe('world'); + + // The deletion is still independently resolvable by its own id. + // Rejecting the deletion keeps the original text (unmarking it); + // the previously accepted insertion stays. Both words coexist in + // the final doc, which is the point of treating them as + // independent revisions. + interactionEditor.commands.rejectTrackedChangeById(deletion.id); + expect(getMarkedText(interactionEditor.state.doc, TrackDeleteMarkName)).toBe(''); + expect(interactionEditor.state.doc.textContent).toContain('universe'); + expect(interactionEditor.state.doc.textContent).toContain('world'); + } finally { + interactionEditor.destroy(); + } + }); + it('attaches comment to replacement using shared ID', () => { const doc = createDoc('Hello world'); const state = createState(doc); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js index 11d0b9636f..4d6c6b2603 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js @@ -12,6 +12,8 @@ import { CommentsPluginKey, createOrUpdateTrackedChangeComment } from '../commen import { findMarkInRangeBySnapshot } from './trackChangesHelpers/markSnapshotHelpers.js'; import { hasExpandedSelection } from '@utils/selectionUtils.js'; +const readPairReplacements = (editor) => editor?.options?.trackedChanges?.pairReplacements !== false; + export const TrackChanges = Extension.create({ name: 'trackChanges', @@ -313,12 +315,26 @@ export const TrackChanges = Extension.create({ // Get marks from original position BEFORE any changes for format preservation const marks = state.doc.resolve(from).marks(); - // For replacements (both deletion and insertion), generate a shared ID upfront - // so the deletion and insertion marks are linked together + // id-minting strategy for a tracked insert/delete/replace: + // - One `primaryId` anchors the operation. When the caller supplies + // `id` (e.g. the Document API write adapter), that becomes the + // primary; otherwise we mint a fresh UUID. + // - The primary id is used for the insertion (pure insert) or the + // lone deletion (pure delete), and always as the `changeId` we + // report back — comment threads key off this id too. + // - For a replacement: in paired mode both halves share the + // primary id (Google-Docs-like one-click resolve). In unpaired + // mode (modules.trackChanges.pairReplacements: false), the + // insertion keeps the primary id and the deletion mints its own + // fresh id via markDeletion, so each revision is independently + // addressable per ECMA-376 §17.13.5. + const pairReplacements = readPairReplacements(editor); const isReplacement = from !== to && text; - const sharedId = id ?? (isReplacement ? uuidv4() : null); + const primaryId = id ?? uuidv4(); + const insertionId = primaryId; + const deletionId = pairReplacements || !isReplacement ? primaryId : null; - let changeId = sharedId; + const changeId = primaryId; let insertPos = to; // Default insert position is after the selection let deletionMark = null; let deletionNodes = []; @@ -331,13 +347,10 @@ export const TrackChanges = Extension.create({ to, user: resolvedUser, date, - id: sharedId, + id: deletionId, }); deletionMark = result.deletionMark; deletionNodes = result.nodes || []; - if (!changeId) { - changeId = deletionMark.attrs.id; - } // Map the insert position through the deletion mapping insertPos = result.deletionMap.map(to); } @@ -358,12 +371,8 @@ export const TrackChanges = Extension.create({ to: insertedTo, user: resolvedUser, date, - id: sharedId, + id: insertionId, }); - - if (!changeId) { - changeId = insertedMark.attrs.id; - } } // Store metadata for external consumers (pass full mark objects for comments plugin) @@ -669,6 +678,14 @@ const getChangesByIdToResolve = (state, id) => { const matchingChange = trackedChanges[changeIndex]; const matchingId = matchingChange.mark.attrs.id; + // The neighbor walk collects every adjacent segment that shares the same id. + // This catches: + // - A single logical mark split across multiple segments (e.g. because + // surrounding text marks differ) — always correct to resolve together. + // - The paired opposite-type mark when pairReplacements is true (shared id). + // In unpaired mode, the ins/del halves have distinct ids so the walk stops + // at the revision boundary naturally — no special casing needed here. + const linkedBefore = []; const linkedAfter = []; diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index a8e7593f69..6e75e4aed0 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -689,6 +689,7 @@ const editorOptions = (doc) => { highlightColors: commentsModuleConfig.value?.highlightColors, highlightOpacity: commentsModuleConfig.value?.highlightOpacity, }, + trackedChanges: proxy.$superdoc.config.modules?.trackChanges, editorCtor: useLayoutEngine ? PresentationEditor : undefined, onBeforeCreate: onEditorBeforeCreate, onCreate: onEditorCreate, diff --git a/packages/superdoc/src/core/helpers/normalize-track-changes-config.js b/packages/superdoc/src/core/helpers/normalize-track-changes-config.js index 609ee8457b..1dc9bf60da 100644 --- a/packages/superdoc/src/core/helpers/normalize-track-changes-config.js +++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.js @@ -2,7 +2,7 @@ /** * @typedef {'review' | 'original' | 'final' | 'off'} TrackChangesMode - * @typedef {{ visible: boolean, mode: TrackChangesMode, enabled: boolean }} NormalizedTrackChangesConfig + * @typedef {{ visible: boolean, mode: TrackChangesMode, enabled: boolean, pairReplacements: boolean }} NormalizedTrackChangesConfig */ const ALLOWED_MODES = /** @type {const} */ (['review', 'original', 'final', 'off']); @@ -77,6 +77,10 @@ export function normalizeTrackChangesConfig(config) { const enabled = resolveBool(fromCanonical?.enabled, fromLegacyLayout?.enabled, true); + // Replacement pairing is only surfaced on the canonical path. The legacy + // buckets never exposed this knob, so there's no alias to resolve. + const pairReplacements = resolveBool(fromCanonical?.pairReplacements, undefined, true); + // Default mode derives from documentMode + visibility so a viewing-mode // document without an explicit mode falls back to 'original' unless the // consumer asked for tracked changes to be visible. @@ -85,7 +89,7 @@ export function normalizeTrackChangesConfig(config) { const mode = resolveMode(fromCanonical?.mode, fromLegacyLayout?.mode, defaultMode); /** @type {NormalizedTrackChangesConfig} */ - const normalized = { visible, mode, enabled }; + const normalized = { visible, mode, enabled, pairReplacements }; // Write-through to every path so all existing internal reads see the same // resolved values without needing to migrate each call site in this pass. diff --git a/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js b/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js index 9d3b543bfc..8e496b32b2 100644 --- a/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js +++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js @@ -18,7 +18,7 @@ describe('normalizeTrackChangesConfig', () => { const config = {}; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: false, mode: 'review', enabled: true }); + expect(result).toEqual({ visible: false, mode: 'review', enabled: true, pairReplacements: true }); expect(config.modules.trackChanges).toEqual(result); expect(config.trackChanges).toEqual({ visible: false }); expect(config.layoutEngineOptions.trackedChanges).toEqual({ mode: 'review', enabled: true }); @@ -54,7 +54,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: true, mode: 'original', enabled: false }); + expect(result).toEqual({ visible: true, mode: 'original', enabled: false, pairReplacements: true }); expect(warnSpy).not.toHaveBeenCalled(); }); @@ -160,7 +160,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: true, mode: 'original', enabled: false }); + expect(result).toEqual({ visible: true, mode: 'original', enabled: false, pairReplacements: true }); }); }); @@ -172,7 +172,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: false, mode: 'review', enabled: true }); + expect(result).toEqual({ visible: false, mode: 'review', enabled: true, pairReplacements: true }); expect(warnSpy).not.toHaveBeenCalled(); }); @@ -184,7 +184,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: false, mode: 'review', enabled: true }); + expect(result).toEqual({ visible: false, mode: 'review', enabled: true, pairReplacements: true }); expect(Array.isArray(config.modules)).toBe(false); }); @@ -218,6 +218,44 @@ describe('normalizeTrackChangesConfig', () => { }); }); + describe('pairReplacements flag', () => { + it('defaults to true when not supplied', () => { + const result = normalizeTrackChangesConfig({}); + expect(result.pairReplacements).toBe(true); + }); + + it('accepts pairReplacements: false on the canonical path', () => { + const result = normalizeTrackChangesConfig({ + modules: { trackChanges: { pairReplacements: false } }, + }); + expect(result.pairReplacements).toBe(false); + }); + + it('mirrors pairReplacements onto the canonical path write-through', () => { + const config = { + modules: { trackChanges: { pairReplacements: false } }, + }; + normalizeTrackChangesConfig(config); + expect(config.modules.trackChanges.pairReplacements).toBe(false); + }); + + it('coerces non-boolean pairReplacements to the default (true)', () => { + const result = normalizeTrackChangesConfig({ + modules: { trackChanges: { pairReplacements: 'no' } }, + }); + expect(result.pairReplacements).toBe(true); + }); + + it('is not derivable from any legacy key (no alias)', () => { + // Even if a legacy key is set, pairReplacements stays at its default. + const result = normalizeTrackChangesConfig({ + trackChanges: { visible: true }, + layoutEngineOptions: { trackedChanges: { mode: 'original' } }, + }); + expect(result.pairReplacements).toBe(true); + }); + }); + describe('extended mode values (final / off)', () => { it('preserves mode: "final" supplied via the legacy layout path', () => { const config = { @@ -256,7 +294,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: true, mode: 'original', enabled: false }); + expect(result).toEqual({ visible: true, mode: 'original', enabled: false, pairReplacements: true }); expect(warnSpy).toHaveBeenCalledTimes(2); const messages = warnSpy.mock.calls.map((call) => call[0]); expect(messages.some((m) => /config\.trackChanges\b/.test(m) && !/layoutEngineOptions/.test(m))).toBe(true); @@ -308,7 +346,7 @@ describe('normalizeTrackChangesConfig', () => { const first = normalizeTrackChangesConfig(config); const second = normalizeTrackChangesConfig(config); - expect(first).toEqual({ visible: true, mode: 'final', enabled: true }); + expect(first).toEqual({ visible: true, mode: 'final', enabled: true, pairReplacements: true }); expect(second).toEqual(first); }); }); diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index 6c31b4e0db..5e6a6ef810 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -544,6 +544,7 @@ * - 'final': show the document with changes applied * - 'off': disable tracked-change rendering * @property {boolean} [enabled=true] Whether the layout engine treats tracked changes as active + * @property {boolean} [pairReplacements=true] When `true` (default), a tracked replacement (insertion paired with deletion) is resolved as a single change with one accept/reject action — closer to the Google Docs model. When `false`, each insertion and each deletion is an independent change with its own id, matching Microsoft Word and ECMA-376 §17.13.5. */ /**