From d2e8af3a32a0c69e9fd41d9f6c3d4c542a31f0c0 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 16 Apr 2026 09:44:11 -0700 Subject: [PATCH 1/2] feat(track-changes): add pairReplacements mode matching Word/ECMA-376 (SD-2607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit when track changes are on and a user replaces text, SuperDoc groups the insertion and deletion under one shared id so accepting or rejecting takes one click (Google-Docs-like). Microsoft Word and ECMA-376 §17.13.5 treat every and as an independent revision with its own required w:id. a consumer wants their UI to match Word. this adds modules.trackChanges.pairReplacements (default true preserves current behaviour). when false, each insertion and each deletion is an independent change, addressable and resolvable on its own. - importer: buildTrackedChangeIdMap accepts { pairReplacements }; when false, skips the adjacent w:del+w:ins pairing so each Word w:id maps to its own UUID - insertTrackedChange: no shared id on replacements in unpaired mode - getChangesByIdToResolve: returns just the single matching mark in unpaired mode (no neighbor walk) - wiring: SuperDoc.vue -> editor.options.trackedChanges -> Editor.ts -> SuperConverter.trackedChangesOptions -> docxImporter no exporter change needed — / are already written per-mark with their own w:id in both modes. no public API shape change. --- apps/docs/core/superdoc/configuration.mdx | 3 ++ .../src/editors/v1/core/Editor.ts | 1 + .../v1/core/super-converter/SuperConverter.js | 8 +++ .../v2/importer/docxImporter.js | 4 +- .../v2/importer/trackedChangeIdMapper.js | 20 ++++--- .../v2/importer/trackedChangeIdMapper.test.js | 35 +++++++++++++ .../track-changes-extension.test.js | 33 ++++++++++++ .../extensions/track-changes/track-changes.js | 31 +++++++---- packages/superdoc/src/SuperDoc.vue | 1 + .../helpers/normalize-track-changes-config.js | 8 ++- .../normalize-track-changes-config.test.js | 52 ++++++++++++++++--- packages/superdoc/src/core/types/index.js | 1 + 12 files changed, 170 insertions(+), 27 deletions(-) diff --git a/apps/docs/core/superdoc/configuration.mdx b/apps/docs/core/superdoc/configuration.mdx index 0150bd0b23..79429c3769 100644 --- a/apps/docs/core/superdoc/configuration.mdx +++ b/apps/docs/core/superdoc/configuration.mdx @@ -215,6 +215,9 @@ new SuperDoc({ Whether the layout engine treats tracked changes as active. + + 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. + diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index fb653c0be1..4a7543de19 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -2093,6 +2093,7 @@ export class Editor extends EventEmitter { mockWindow: this.options.mockWindow ?? null, mockDocument: this.options.mockDocument ?? null, isNewFile: this.options.isNewFile ?? false, + trackedChangesOptions: (this.options as { trackedChanges?: unknown }).trackedChanges ?? null, }); } } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js index 2170d65dd9..d537cf71ea 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js @@ -208,6 +208,14 @@ class SuperConverter { this.fonts = params?.fonts || {}; + /** + * Track-changes options forwarded from the editor. Consumed during + * import (e.g. by `buildTrackedChangeIdMap`) so behaviors like + * `pairReplacements` can be toggled per SuperDoc instance. + * @type {{ pairReplacements?: boolean } | null} + */ + this.trackedChangesOptions = params?.trackedChangesOptions || null; + this.addedMedia = {}; this.comments = []; this.footnotes = []; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index a4ed2677f8..c743bb8653 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -152,7 +152,9 @@ export const createDocumentJson = (docx, converter, editor) => { patchNumberingDefinitions(docx); const numbering = getNumberingDefinitions(docx); - converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx); + converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, { + pairReplacements: converter.trackedChangesOptions?.pairReplacements !== false, + }); const comments = importCommentData({ docx, nodeListHandler, converter, editor }); const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering }); const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js index ab7187b469..f190122f35 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; /** * @typedef {{ type: string, author: string, date: string, internalId: string }} TrackedChangeEntry - * @typedef {{ lastTrackedChange: TrackedChangeEntry | null }} WalkContext + * @typedef {{ lastTrackedChange: TrackedChangeEntry | null, pairReplacements: boolean }} WalkContext */ const TRACKED_CHANGE_NAMES = new Set(['w:ins', 'w:del']); @@ -70,7 +70,7 @@ function assignInternalId(element, idMap, context, insideTrackedChange) { date: element.attributes?.['w:date'] ?? '', }; - if (context.lastTrackedChange && isReplacementPair(context.lastTrackedChange, current)) { + if (context.pairReplacements && context.lastTrackedChange && isReplacementPair(context.lastTrackedChange, current)) { // Second half of a replacement — share the first half's UUID, but only // if this w:id hasn't already been mapped. A reused id that was already // part of an earlier pair must keep its original mapping. @@ -128,23 +128,27 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { * Builds a map from OOXML `w:id` values to stable internal UUIDs by scanning * `word/document.xml`. * - * Word tracked replacements use separate `w:id` values for the delete and - * insert halves. This function detects adjacent opposite-type changes with - * matching author and date and maps both halves to the same internal UUID so - * the editor can resolve them as a single logical change. + * When `pairReplacements` is `true` (the default), Word tracked replacements + * are detected as adjacent opposite-type changes with matching author and + * date, and both halves map to the same internal UUID so the editor can + * resolve them as one logical change. When `pairReplacements` is `false`, + * each `w:id` maps to its own UUID — matching the ECMA-376 §17.13.5 model + * where every `` and `` is an independent revision. * * Must run before comment import so all consumers — translators, comment * helpers, and the tracked-change resolver — see a fully populated map. * * @param {object} docx Parsed DOCX package + * @param {{ pairReplacements?: boolean }} [options] * @returns {Map} Word `w:id` → internal UUID */ -export function buildTrackedChangeIdMap(docx) { +export function buildTrackedChangeIdMap(docx, options = {}) { const body = docx?.['word/document.xml']?.elements?.[0]; if (!body?.elements) return new Map(); + const pairReplacements = options.pairReplacements !== false; const idMap = new Map(); - walkElements(body.elements, idMap, { lastTrackedChange: null }); + walkElements(body.elements, idMap, { lastTrackedChange: null, pairReplacements }); return idMap; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js index 8bc6ce61d9..060ccd4591 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js @@ -255,4 +255,39 @@ describe('buildTrackedChangeIdMap', () => { expect(idMap.get('0')).toBe(idMap.get('1')); }); + + describe('pairReplacements: false (unpaired mode)', () => { + it('keeps adjacent w:del + w:ins with matching author/date as independent ids', () => { + const docx = createDocx( + paragraph( + trackedChange('w:del', '10', 'Alice', '2024-01-01T00:00:00Z'), + trackedChange('w:ins', '11', 'Alice', '2024-01-01T00:00:00Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false }); + + expect(idMap.size).toBe(2); + expect(idMap.get('10')).toBeTruthy(); + expect(idMap.get('11')).toBeTruthy(); + expect(idMap.get('10')).not.toBe(idMap.get('11')); + }); + + it('still maps each standalone tracked change to its own UUID', () => { + const docx = createDocx(paragraph(trackedChange('w:del', '1')), paragraph(trackedChange('w:ins', '2'))); + + const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false }); + + expect(idMap.size).toBe(2); + expect(idMap.get('1')).not.toBe(idMap.get('2')); + }); + + it('treats real Word replacement siblings as independent', () => { + const docx = createDocx(paragraph(wordDelete('0', 'test '), wordInsert('1', 'abc '))); + + const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false }); + + expect(idMap.get('0')).not.toBe(idMap.get('1')); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js index 96498b49f9..3bb8b047ab 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js @@ -1896,6 +1896,39 @@ describe('TrackChanges extension commands', () => { expect(meta.insertedMark.attrs.id).toBe(meta.deletionMark.attrs.id); }); + it('gives each replacement mark its own ID when pairReplacements is false', () => { + const doc = createDoc('Hello world'); + const state = createState(doc); + + let dispatchedTr; + const dispatch = vi.fn((tr) => { + dispatchedTr = tr; + state.apply(tr); + }); + + commands.insertTrackedChange({ + from: 7, + to: 12, + text: 'universe', + user: { name: 'Test', email: 'test@example.com' }, + })({ + state, + dispatch, + editor: { + options: { + user: { name: 'Default', email: 'default@example.com' }, + trackedChanges: { pairReplacements: false }, + }, + commands: { addCommentReply: vi.fn() }, + }, + }); + + const meta = dispatchedTr.getMeta(TrackChangesBasePluginKey); + expect(meta.insertedMark).toBeDefined(); + expect(meta.deletionMark).toBeDefined(); + expect(meta.insertedMark.attrs.id).not.toBe(meta.deletionMark.attrs.id); + }); + 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..aa343fee36 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', @@ -180,8 +182,8 @@ export const TrackChanges = Extension.create({ acceptTrackedChangeById: (id) => - ({ state, tr, commands }) => { - const toResolve = getChangesByIdToResolve(state, id) || []; + ({ editor, state, tr, commands }) => { + const toResolve = getChangesByIdToResolve(state, id, readPairReplacements(editor)) || []; return toResolve .map(({ from, to }) => { @@ -202,8 +204,8 @@ export const TrackChanges = Extension.create({ rejectTrackedChangeById: (id) => - ({ state, tr, commands }) => { - const toReject = getChangesByIdToResolve(state, id) || []; + ({ editor, state, tr, commands }) => { + const toReject = getChangesByIdToResolve(state, id, readPairReplacements(editor)) || []; return toReject .map(({ from, to }) => { @@ -314,9 +316,14 @@ export const TrackChanges = Extension.create({ 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 + // so the deletion and insertion marks are linked together. When the + // consumer opts out via modules.trackChanges.pairReplacements: false, + // each mark gets its own UUID so they're independent revisions. + const pairReplacements = readPairReplacements(editor); const isReplacement = from !== to && text; - const sharedId = id ?? (isReplacement ? uuidv4() : null); + const sharedId = id ?? (isReplacement && pairReplacements ? uuidv4() : null); + const deletionId = id ?? sharedId; + const insertionId = id ?? sharedId; let changeId = sharedId; let insertPos = to; // Default insert position is after the selection @@ -331,7 +338,7 @@ export const TrackChanges = Extension.create({ to, user: resolvedUser, date, - id: sharedId, + id: deletionId, }); deletionMark = result.deletionMark; deletionNodes = result.nodes || []; @@ -358,7 +365,7 @@ export const TrackChanges = Extension.create({ to: insertedTo, user: resolvedUser, date, - id: sharedId, + id: insertionId, }); if (!changeId) { @@ -661,12 +668,18 @@ const dispatchTrackedChangeResolution = ({ state, tr, dispatch, editor, touchedC return true; }; -const getChangesByIdToResolve = (state, id) => { +const getChangesByIdToResolve = (state, id, pairReplacements = true) => { const trackedChanges = getTrackChanges(state); const changeIndex = trackedChanges.findIndex(({ mark }) => mark.attrs.id === id); if (changeIndex === -1) return; const matchingChange = trackedChanges[changeIndex]; + + // In unpaired mode, each mark is its own logical change — no neighbor walk. + if (!pairReplacements) { + return [matchingChange]; + } + const matchingId = matchingChange.mark.attrs.id; const linkedBefore = []; 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. */ /** From 8716dc967c2b27cea20e4e59a62bfe73d541a348 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 16 Apr 2026 11:47:11 -0700 Subject: [PATCH 2/2] fix(track-changes): address review findings on pairReplacements (SD-2607) - always walk adjacent same-id marks in getChangesByIdToResolve so a single logical revision split across multiple segments resolves as one unit; the unpaired case is handled implicitly because ins/del now have distinct ids - align changeId with the insertion mark's id so comment threads and the optional comment reply attach to the same thread in unpaired mode - simplify id-minting: one primary id anchors the operation; the deletion mints its own fresh id only when unpaired AND it's a replacement. the Document API write adapter now gets unpaired revisions when the flag is off without any adapter-level change - add trackedChanges?: {...} to EditorOptions so consumers don't need casts - add an unpaired-mode example snippet to the docs - extension test now covers the headline guarantee: in unpaired mode, acceptTrackedChangeById(insertionId) resolves only the insertion, and the deletion is still independently rejectable by its own id --- apps/docs/core/superdoc/configuration.mdx | 12 ++++ .../src/editors/v1/core/Editor.ts | 2 +- .../src/editors/v1/core/types/EditorConfig.ts | 13 +++++ .../track-changes-extension.test.js | 50 +++++++++++++++++ .../extensions/track-changes/track-changes.js | 56 ++++++++++--------- 5 files changed, 106 insertions(+), 27 deletions(-) diff --git a/apps/docs/core/superdoc/configuration.mdx b/apps/docs/core/superdoc/configuration.mdx index 79429c3769..63f5ba1c83 100644 --- a/apps/docs/core/superdoc/configuration.mdx +++ b/apps/docs/core/superdoc/configuration.mdx @@ -232,6 +232,18 @@ new SuperDoc({ }); ``` +Opt into Microsoft Word / ECMA-376-style independent revisions, where each insertion and each deletion has its own id and resolves on its own: + +```javascript +new SuperDoc({ + selector: '#editor', + document: 'contract.docx', + modules: { + trackChanges: { pairReplacements: false }, + }, +}); +``` + ### Toolbar module diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 4a7543de19..8d876602c0 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -2093,7 +2093,7 @@ export class Editor extends EventEmitter { mockWindow: this.options.mockWindow ?? null, mockDocument: this.options.mockDocument ?? null, isNewFile: this.options.isNewFile ?? false, - trackedChangesOptions: (this.options as { trackedChanges?: unknown }).trackedChanges ?? null, + trackedChangesOptions: this.options.trackedChanges ?? null, }); } } diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index 82c6be5ba1..b146287783 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -352,6 +352,19 @@ export interface EditorOptions { /** Comment highlight configuration */ comments?: CommentConfig; + /** + * Track-changes runtime configuration forwarded from the SuperDoc-level + * `modules.trackChanges` config. Read by the TrackChanges extension and + * by the SuperConverter during import. Fields are all optional; missing + * ones fall back to defaults resolved at SuperDoc construction time. + */ + trackedChanges?: { + visible?: boolean; + mode?: 'review' | 'original' | 'final' | 'off'; + enabled?: boolean; + pairReplacements?: boolean; + }; + /** Whether this is a new file */ isNewFile?: boolean; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js index 3bb8b047ab..a91788c952 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js @@ -1929,6 +1929,56 @@ describe('TrackChanges extension commands', () => { expect(meta.insertedMark.attrs.id).not.toBe(meta.deletionMark.attrs.id); }); + it('resolves only the targeted half of a replacement in unpaired mode', () => { + const { editor: interactionEditor } = initTestEditor({ + mode: 'text', + content: '

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 aa343fee36..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 @@ -182,8 +182,8 @@ export const TrackChanges = Extension.create({ acceptTrackedChangeById: (id) => - ({ editor, state, tr, commands }) => { - const toResolve = getChangesByIdToResolve(state, id, readPairReplacements(editor)) || []; + ({ state, tr, commands }) => { + const toResolve = getChangesByIdToResolve(state, id) || []; return toResolve .map(({ from, to }) => { @@ -204,8 +204,8 @@ export const TrackChanges = Extension.create({ rejectTrackedChangeById: (id) => - ({ editor, state, tr, commands }) => { - const toReject = getChangesByIdToResolve(state, id, readPairReplacements(editor)) || []; + ({ state, tr, commands }) => { + const toReject = getChangesByIdToResolve(state, id) || []; return toReject .map(({ from, to }) => { @@ -315,17 +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. When the - // consumer opts out via modules.trackChanges.pairReplacements: false, - // each mark gets its own UUID so they're independent revisions. + // 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 && pairReplacements ? uuidv4() : null); - const deletionId = id ?? sharedId; - const insertionId = id ?? sharedId; + 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 = []; @@ -342,9 +351,6 @@ export const TrackChanges = Extension.create({ }); 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); } @@ -367,10 +373,6 @@ export const TrackChanges = Extension.create({ date, id: insertionId, }); - - if (!changeId) { - changeId = insertedMark.attrs.id; - } } // Store metadata for external consumers (pass full mark objects for comments plugin) @@ -668,20 +670,22 @@ const dispatchTrackedChangeResolution = ({ state, tr, dispatch, editor, touchedC return true; }; -const getChangesByIdToResolve = (state, id, pairReplacements = true) => { +const getChangesByIdToResolve = (state, id) => { const trackedChanges = getTrackChanges(state); const changeIndex = trackedChanges.findIndex(({ mark }) => mark.attrs.id === id); if (changeIndex === -1) return; const matchingChange = trackedChanges[changeIndex]; - - // In unpaired mode, each mark is its own logical change — no neighbor walk. - if (!pairReplacements) { - return [matchingChange]; - } - 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 = [];