From b940deb38ce7125f3273cb9755ee66467bd6cbb6 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 16 Apr 2026 09:34:58 -0700 Subject: [PATCH 1/9] refactor(config): consolidate track-changes config under modules.trackChanges (SD-2608) track-changes config lived in two places (config.trackChanges for visibility, config.layoutEngineOptions.trackedChanges for mode/enabled), unlike every other module which sits under config.modules.*. consumers had to learn three keys for one module. this adds modules.trackChanges as the canonical path and keeps both legacy keys working as deprecated aliases with one-time warnings. - new normalizer in core/helpers resolves canonical + legacy with precedence new > legacy > derived default, and mirrors values back to legacy paths so the ~14 internal reads keep working untouched - accepts all four TrackedChangesMode values (review | original | final | off) to preserve existing pass-through of layoutEngineOptions.trackedChanges - suppresses re-warning on a second normalization of the same config object so write-through values don't look like new legacy usage - JSDoc, Mintlify docs, and the one SuperDoc.test.js legacy use-site updated --- apps/docs/core/superdoc/configuration.mdx | 39 ++- apps/docs/modules/comments.mdx | 17 +- packages/superdoc/src/core/SuperDoc.js | 23 +- packages/superdoc/src/core/SuperDoc.test.js | 3 +- .../helpers/normalize-track-changes-config.js | 115 +++++++ .../normalize-track-changes-config.test.js | 315 ++++++++++++++++++ packages/superdoc/src/core/types/index.js | 19 +- 7 files changed, 498 insertions(+), 33 deletions(-) create mode 100644 packages/superdoc/src/core/helpers/normalize-track-changes-config.js create mode 100644 packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js diff --git a/apps/docs/core/superdoc/configuration.mdx b/apps/docs/core/superdoc/configuration.mdx index 6433165c19..0150bd0b23 100644 --- a/apps/docs/core/superdoc/configuration.mdx +++ b/apps/docs/core/superdoc/configuration.mdx @@ -124,11 +124,11 @@ new SuperDoc({ - - Viewing-mode visibility controls for tracked changes + + **Deprecated** — Use [`modules.trackChanges`](#track-changes-module) instead. This top-level key remains supported as an alias and will emit a one-time console warning. - Show tracked-change markup and threads when `documentMode` is `viewing` + Show tracked-change markup and threads when `documentMode` is `viewing`. @@ -196,6 +196,39 @@ new SuperDoc({ +### Track changes module + + + Track changes configuration. Supersedes the top-level `trackChanges` and `layoutEngineOptions.trackedChanges` keys, which remain supported as deprecated aliases. + + + + Show tracked-change markup and threads when `documentMode` is `viewing`. + + + Rendering mode for tracked changes. + - `'review'`: show insertions and deletions inline (default for editing/suggesting). + - `'original'`: show the document as it existed before tracked changes (default for viewing when `visible` is `false`). + - `'final'`: show the document with changes applied. + - `'off'`: disable tracked-change rendering. + + + Whether the layout engine treats tracked changes as active. + + + + +```javascript +new SuperDoc({ + selector: '#editor', + document: 'contract.docx', + documentMode: 'viewing', + modules: { + trackChanges: { visible: true, mode: 'review' }, + }, +}); +``` + ### Toolbar module diff --git a/apps/docs/modules/comments.mdx b/apps/docs/modules/comments.mdx index cf9b1f456d..8c6e0783a6 100644 --- a/apps/docs/modules/comments.mdx +++ b/apps/docs/modules/comments.mdx @@ -140,20 +140,27 @@ modules: { ## Viewing mode visibility -Comments are hidden by default when `documentMode` is `viewing`. Use the -top-level `comments.visible` and `trackChanges.visible` flags to control what -renders in read-only mode. +Comments are hidden by default when `documentMode` is `viewing`. Use +`comments.visible` and `modules.trackChanges.visible` to control what renders +in read-only mode. ```javascript new SuperDoc({ selector: "#viewer", document: "contract.docx", documentMode: "viewing", - comments: { visible: true }, // Standard comment threads - trackChanges: { visible: false }, // Tracked-change markup + threads + comments: { visible: true }, // Standard comment threads + modules: { + trackChanges: { visible: false }, // Tracked-change markup + threads + }, }); ``` + + The top-level `trackChanges` key still works as a deprecated alias for + `modules.trackChanges` and will emit a one-time console warning. + + ## Setting up the comments UI During initialization: diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index b4021f0af5..e1c59146ba 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -20,6 +20,7 @@ import { Whiteboard } from './whiteboard/Whiteboard'; import { WhiteboardRenderer } from './whiteboard/WhiteboardRenderer'; import { SurfaceManager } from './surface-manager.js'; import { createDeprecatedEditorProxy } from '../helpers/deprecation.js'; +import { normalizeTrackChangesConfig } from './helpers/normalize-track-changes-config.js'; const DEFAULT_USER = Object.freeze({ name: 'Default SuperDoc user', @@ -136,7 +137,6 @@ export class SuperDoc extends EventEmitter { conversations: [], isInternal: false, comments: { visible: false }, - trackChanges: { visible: false }, // toolbar config toolbar: null, // Optional DOM element to render the toolbar in @@ -221,11 +221,7 @@ export class SuperDoc extends EventEmitter { } else if (typeof this.config.comments.visible !== 'boolean') { this.config.comments.visible = false; } - if (!this.config.trackChanges || typeof this.config.trackChanges !== 'object') { - this.config.trackChanges = { visible: false }; - } else if (typeof this.config.trackChanges.visible !== 'boolean') { - this.config.trackChanges.visible = false; - } + normalizeTrackChangesConfig(this.config); // Web layout behavior: // - Backward compatible default: web layout still uses PM rendering. @@ -257,21 +253,6 @@ export class SuperDoc extends EventEmitter { } } - // Initialize tracked changes defaults based on document mode - if (!this.config.layoutEngineOptions) { - this.config.layoutEngineOptions = {}; - } - // Only set defaults if user didn't explicitly configure tracked changes - if (!this.config.layoutEngineOptions.trackedChanges) { - // Default: ON for editing/suggesting modes, OFF for viewing mode - const isViewingMode = this.config.documentMode === 'viewing'; - const viewingTrackedChangesVisible = isViewingMode && this.config.trackChanges?.visible === true; - this.config.layoutEngineOptions.trackedChanges = { - mode: isViewingMode ? (viewingTrackedChangesVisible ? 'review' : 'original') : 'review', - enabled: true, - }; - } - // Enable virtualization by default for better performance on large documents. // Only renders visible pages (~5) instead of all pages. if (!this.config.layoutEngineOptions.virtualization) { diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 02a72005f8..14f1c2dc95 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -807,9 +807,8 @@ describe('SuperDoc core', () => { selector: '#host', document: 'https://example.com/doc.docx', documents: [], - modules: { comments: {}, toolbar: {} }, + modules: { comments: {}, toolbar: {}, trackChanges: { visible: true } }, comments: { visible: true }, - trackChanges: { visible: true }, colors: ['red'], role: 'editor', user: { name: 'Jane', email: 'jane@example.com' }, diff --git a/packages/superdoc/src/core/helpers/normalize-track-changes-config.js b/packages/superdoc/src/core/helpers/normalize-track-changes-config.js new file mode 100644 index 0000000000..609ee8457b --- /dev/null +++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.js @@ -0,0 +1,115 @@ +// @ts-check + +/** + * @typedef {'review' | 'original' | 'final' | 'off'} TrackChangesMode + * @typedef {{ visible: boolean, mode: TrackChangesMode, enabled: boolean }} NormalizedTrackChangesConfig + */ + +const ALLOWED_MODES = /** @type {const} */ (['review', 'original', 'final', 'off']); + +// Marks a config object we've already normalized so a second pass with the same +// object (e.g. a consumer reusing the config to mount another SuperDoc) doesn't +// warn on the legacy keys we wrote back during the first pass. +const NORMALIZED_MARKER = Symbol.for('@superdoc/trackChanges:normalized'); + +const warnedKeys = new Set(); + +function warnOnce(legacyPath, newPath) { + if (warnedKeys.has(legacyPath)) return; + warnedKeys.add(legacyPath); + console.warn(`[SuperDoc] ${legacyPath} is deprecated — use ${newPath} instead.`); +} + +function resolveBool(newVal, legacyVal, fallback) { + if (typeof newVal === 'boolean') return newVal; + if (typeof legacyVal === 'boolean') return legacyVal; + return fallback; +} + +function resolveMode(newVal, legacyVal, fallback) { + if (ALLOWED_MODES.includes(newVal)) return newVal; + if (ALLOWED_MODES.includes(legacyVal)) return legacyVal; + return fallback; +} + +function pickObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : null; +} + +/** + * Resolves track-changes configuration from the new canonical path + * (`config.modules.trackChanges`) and the two legacy paths + * (`config.trackChanges` for visibility, `config.layoutEngineOptions.trackedChanges` + * for mode/enabled), then mirrors the merged result back to all three + * paths so internal consumers that still read legacy keys keep working. + * + * Precedence per field: canonical > legacy > derived default. + * + * Emits a one-time deprecation warning per legacy key path that was + * populated by the caller. Suppresses warnings on a second pass over the + * same config object so write-through values don't look like new legacy + * usage. + * + * @param {object} config The SuperDoc config object (mutated in place) + * @returns {NormalizedTrackChangesConfig} + */ +export function normalizeTrackChangesConfig(config) { + const alreadyNormalized = config[NORMALIZED_MARKER] === true; + + if (!pickObject(config.modules)) { + config.modules = {}; + } + + const fromCanonical = pickObject(config.modules.trackChanges); + const fromLegacyVisible = pickObject(config.trackChanges); + const fromLegacyLayout = pickObject(config.layoutEngineOptions?.trackedChanges); + + if (!alreadyNormalized) { + if (fromLegacyVisible) { + warnOnce('config.trackChanges', 'config.modules.trackChanges'); + } + if (fromLegacyLayout) { + warnOnce('config.layoutEngineOptions.trackedChanges', 'config.modules.trackChanges'); + } + } + + const visible = resolveBool(fromCanonical?.visible, fromLegacyVisible?.visible, false); + + const enabled = resolveBool(fromCanonical?.enabled, fromLegacyLayout?.enabled, 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. + const isViewingMode = config.documentMode === 'viewing'; + const defaultMode = isViewingMode ? (visible ? 'review' : 'original') : 'review'; + const mode = resolveMode(fromCanonical?.mode, fromLegacyLayout?.mode, defaultMode); + + /** @type {NormalizedTrackChangesConfig} */ + const normalized = { visible, mode, enabled }; + + // 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. + config.modules.trackChanges = normalized; + config.trackChanges = { visible }; + if (!pickObject(config.layoutEngineOptions)) { + config.layoutEngineOptions = {}; + } + config.layoutEngineOptions.trackedChanges = { mode, enabled }; + + Object.defineProperty(config, NORMALIZED_MARKER, { + value: true, + writable: true, + configurable: true, + enumerable: false, + }); + + return normalized; +} + +/** + * Test-only hook: clears the deduplicated deprecation-warning set so + * tests can assert the warning fires on the first invocation. + */ +export function __resetDeprecationWarnings() { + warnedKeys.clear(); +} 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 new file mode 100644 index 0000000000..9d3b543bfc --- /dev/null +++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js @@ -0,0 +1,315 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { normalizeTrackChangesConfig, __resetDeprecationWarnings } from './normalize-track-changes-config.js'; + +describe('normalizeTrackChangesConfig', () => { + let warnSpy; + + beforeEach(() => { + __resetDeprecationWarnings(); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + describe('defaults (no user config)', () => { + it('fills in safe defaults when nothing is provided', () => { + const config = {}; + const result = normalizeTrackChangesConfig(config); + + expect(result).toEqual({ visible: false, mode: 'review', enabled: true }); + expect(config.modules.trackChanges).toEqual(result); + expect(config.trackChanges).toEqual({ visible: false }); + expect(config.layoutEngineOptions.trackedChanges).toEqual({ mode: 'review', enabled: true }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('defaults mode to "original" in viewing mode when visibility is off', () => { + const config = { documentMode: 'viewing' }; + const result = normalizeTrackChangesConfig(config); + + expect(result.mode).toBe('original'); + expect(result.visible).toBe(false); + }); + + it('defaults mode to "review" in viewing mode when visibility is on', () => { + const config = { + documentMode: 'viewing', + modules: { trackChanges: { visible: true } }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result.mode).toBe('review'); + expect(result.visible).toBe(true); + }); + }); + + describe('new canonical path (config.modules.trackChanges)', () => { + it('reads visible/mode/enabled from the new path without warnings', () => { + const config = { + modules: { + trackChanges: { visible: true, mode: 'original', enabled: false }, + }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result).toEqual({ visible: true, mode: 'original', enabled: false }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('preserves the normalized values on the canonical path', () => { + const config = { + modules: { trackChanges: { visible: true } }, + }; + normalizeTrackChangesConfig(config); + + expect(config.modules.trackChanges.visible).toBe(true); + expect(config.modules.trackChanges.mode).toBe('review'); + expect(config.modules.trackChanges.enabled).toBe(true); + }); + }); + + describe('legacy config.trackChanges (visibility alias)', () => { + it('accepts visible via the legacy key and emits one deprecation warning', () => { + const config = { trackChanges: { visible: true } }; + const result = normalizeTrackChangesConfig(config); + + expect(result.visible).toBe(true); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/config\.trackChanges/); + expect(warnSpy.mock.calls[0][0]).toMatch(/config\.modules\.trackChanges/); + }); + + it('mirrors the resolved visible back onto the legacy key', () => { + const config = { trackChanges: { visible: true } }; + normalizeTrackChangesConfig(config); + + expect(config.trackChanges).toEqual({ visible: true }); + }); + + it('warns only once across multiple normalizer calls', () => { + normalizeTrackChangesConfig({ trackChanges: { visible: true } }); + normalizeTrackChangesConfig({ trackChanges: { visible: false } }); + + const visibleWarnings = warnSpy.mock.calls.filter( + (call) => /config\.trackChanges\b/.test(call[0]) && !/layoutEngineOptions/.test(call[0]), + ); + expect(visibleWarnings).toHaveLength(1); + }); + }); + + describe('legacy config.layoutEngineOptions.trackedChanges', () => { + it('accepts mode/enabled via the legacy key and emits one deprecation warning', () => { + const config = { + layoutEngineOptions: { trackedChanges: { mode: 'original', enabled: false } }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result.mode).toBe('original'); + expect(result.enabled).toBe(false); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/layoutEngineOptions\.trackedChanges/); + }); + + it('mirrors resolved mode/enabled back onto the legacy key', () => { + const config = { + layoutEngineOptions: { trackedChanges: { mode: 'original', enabled: false } }, + }; + normalizeTrackChangesConfig(config); + + expect(config.layoutEngineOptions.trackedChanges).toEqual({ mode: 'original', enabled: false }); + }); + + it('does not clobber sibling layoutEngineOptions fields', () => { + const config = { + layoutEngineOptions: { flowMode: 'semantic', trackedChanges: { mode: 'original' } }, + }; + normalizeTrackChangesConfig(config); + + expect(config.layoutEngineOptions.flowMode).toBe('semantic'); + expect(config.layoutEngineOptions.trackedChanges.mode).toBe('original'); + }); + }); + + describe('precedence: new > legacy', () => { + it('prefers modules.trackChanges.visible over config.trackChanges.visible', () => { + const config = { + modules: { trackChanges: { visible: false } }, + trackChanges: { visible: true }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result.visible).toBe(false); + }); + + it('prefers modules.trackChanges.mode over layoutEngineOptions.trackedChanges.mode', () => { + const config = { + modules: { trackChanges: { mode: 'review' } }, + layoutEngineOptions: { trackedChanges: { mode: 'original' } }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result.mode).toBe('review'); + }); + + it('falls through to the legacy value when the new path omits the field', () => { + const config = { + modules: { trackChanges: { visible: true } }, // no mode/enabled + layoutEngineOptions: { trackedChanges: { mode: 'original', enabled: false } }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result).toEqual({ visible: true, mode: 'original', enabled: false }); + }); + }); + + describe('defensive parsing', () => { + it('ignores non-object legacy values', () => { + const config = { + trackChanges: 'not-an-object', + layoutEngineOptions: { trackedChanges: null }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result).toEqual({ visible: false, mode: 'review', enabled: true }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('ignores array-typed modules/canonical/legacy objects', () => { + const config = { + modules: [], + trackChanges: [], + layoutEngineOptions: { trackedChanges: [] }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result).toEqual({ visible: false, mode: 'review', enabled: true }); + expect(Array.isArray(config.modules)).toBe(false); + }); + + it('treats a null canonical object as missing', () => { + const config = { + modules: { trackChanges: null }, + trackChanges: { visible: true }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result.visible).toBe(true); + }); + + it('coerces invalid mode values to the derived default', () => { + const config = { + modules: { trackChanges: { mode: 'bogus' } }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result.mode).toBe('review'); + }); + + it('coerces non-boolean visible/enabled to the derived default', () => { + const config = { + modules: { trackChanges: { visible: 'yes', enabled: 0 } }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result.visible).toBe(false); + expect(result.enabled).toBe(true); + }); + }); + + describe('extended mode values (final / off)', () => { + it('preserves mode: "final" supplied via the legacy layout path', () => { + const config = { + layoutEngineOptions: { trackedChanges: { mode: 'final', enabled: true } }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result.mode).toBe('final'); + expect(config.layoutEngineOptions.trackedChanges.mode).toBe('final'); + }); + + it('preserves mode: "off" supplied via the legacy layout path', () => { + const config = { + layoutEngineOptions: { trackedChanges: { mode: 'off' } }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result.mode).toBe('off'); + }); + + it('accepts mode: "final" on the canonical path', () => { + const config = { + modules: { trackChanges: { mode: 'final' } }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result.mode).toBe('final'); + }); + }); + + describe('conflicting legacy buckets', () => { + it('warns for both legacy paths and merges their fields independently', () => { + const config = { + trackChanges: { visible: true }, + layoutEngineOptions: { trackedChanges: { mode: 'original', enabled: false } }, + }; + const result = normalizeTrackChangesConfig(config); + + expect(result).toEqual({ visible: true, mode: 'original', enabled: false }); + 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); + expect(messages.some((m) => /layoutEngineOptions\.trackedChanges/.test(m))).toBe(true); + }); + + it('warns on the legacy bucket even when the canonical value wins', () => { + const config = { + modules: { trackChanges: { visible: false } }, + trackChanges: { visible: true }, + }; + normalizeTrackChangesConfig(config); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/config\.trackChanges/); + }); + }); + + describe('idempotency on config reuse', () => { + it('does not re-warn when the same config object is normalized twice', () => { + const config = { + modules: { trackChanges: { visible: true } }, + }; + + normalizeTrackChangesConfig(config); + expect(warnSpy).not.toHaveBeenCalled(); + + // Second pass on the SAME object — the write-through populated the legacy + // paths on the first call, but that shouldn't look like new legacy usage. + normalizeTrackChangesConfig(config); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('still warns on a fresh config object even after a previous one was normalized', () => { + normalizeTrackChangesConfig({ modules: { trackChanges: { visible: true } } }); + + __resetDeprecationWarnings(); + warnSpy.mockClear(); + + const freshConfig = { trackChanges: { visible: true } }; + normalizeTrackChangesConfig(freshConfig); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('produces stable values across repeated normalizations of the same config', () => { + const config = { + modules: { trackChanges: { visible: true, mode: 'final' } }, + }; + const first = normalizeTrackChangesConfig(config); + const second = normalizeTrackChangesConfig(config); + + expect(first).toEqual({ visible: true, mode: 'final', enabled: true }); + expect(second).toEqual(first); + }); + }); +}); diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index c9e81a8a94..6c31b4e0db 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -529,6 +529,21 @@ * @property {ContextMenuConfig} [contextMenu] Context menu module configuration * @property {Object} [slashMenu] @deprecated Use contextMenu instead * @property {SurfacesModuleConfig} [surfaces] Surface system configuration + * @property {TrackChangesModuleConfig} [trackChanges] Track changes module configuration + */ + +/** + * @typedef {Object} TrackChangesModuleConfig + * Canonical configuration for the track-changes module. Supersedes the top-level + * `config.trackChanges` and `config.layoutEngineOptions.trackedChanges` keys, + * which remain supported as deprecated aliases. + * @property {boolean} [visible=false] Whether tracked-change indicators are shown in viewing mode + * @property {'review' | 'original' | 'final' | 'off'} [mode] Rendering mode for tracked changes (see `TrackedChangesMode` in `@superdoc/contracts`). + * - 'review': show insertions and deletions inline (default for editing/suggesting) + * - 'original': show the document as it existed before tracked changes (default for viewing when `visible` is false) + * - '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 */ /** @@ -637,7 +652,7 @@ * - 'semantic': continuous semantic flow without visible pagination boundaries * @property {Object} [layoutEngineOptions.semanticOptions] Internal-only semantic mode tuning options. * This shape is intentionally not a stable public API in v1. - * @property {Object} [layoutEngineOptions.trackedChanges] Optional override for paginated track-changes rendering (e.g., `{ mode: 'final' }` to force final view or `{ enabled: false }` to strip metadata entirely) + * @property {Object} [layoutEngineOptions.trackedChanges] @deprecated Use `modules.trackChanges` instead. Optional override for paginated track-changes rendering (e.g., `{ mode: 'original' }` or `{ enabled: false }`). * @property {(editor: Editor) => void} [onEditorBeforeCreate] Callback before an editor is created * @property {(editor: Editor) => void} [onEditorCreate] Callback after an editor is created * @property {(params: EditorTransactionEvent) => void} [onTransaction] Callback when a transaction is made @@ -661,7 +676,7 @@ * @property {string} [title] The title of the SuperDoc * @property {Object[]} [conversations] The conversations to load * @property {{ visible?: boolean }} [comments] Toggle comment visibility when `documentMode` is `viewing` (default: false) - * @property {{ visible?: boolean }} [trackChanges] Toggle tracked-change visibility when `documentMode` is `viewing` (default: false) + * @property {{ visible?: boolean }} [trackChanges] @deprecated Use `modules.trackChanges.visible` instead. Toggle tracked-change visibility when `documentMode` is `viewing` (default: false). * @property {boolean} [isLocked] Whether the SuperDoc is locked * @property {function(File): Promise} [handleImageUpload] The function to handle image uploads * @property {User} [lockedBy] The user who locked the SuperDoc From 86b3e549f1af8f74d1b08076e12d01b8e332582d Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:06:13 -0300 Subject: [PATCH 2/9] feat(track-changes): add pairReplacements mode matching Word/ECMA-376 (SD-2607) (#2849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(track-changes): add pairReplacements mode matching Word/ECMA-376 (SD-2607) 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. * 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 | 15 ++++ .../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 ++++++++ .../src/editors/v1/core/types/EditorConfig.ts | 13 +++ .../track-changes-extension.test.js | 83 +++++++++++++++++++ .../extensions/track-changes/track-changes.js | 43 +++++++--- 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 + 13 files changed, 253 insertions(+), 31 deletions(-) diff --git a/apps/docs/core/superdoc/configuration.mdx b/apps/docs/core/superdoc/configuration.mdx index 0150bd0b23..63f5ba1c83 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. + @@ -229,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 fb653c0be1..8d876602c0 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.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/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 96498b49f9..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 @@ -1896,6 +1896,89 @@ 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('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 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. */ /** From a27e6fcbfb2c7e40b43af46413f7519e437f4d16 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 17:48:50 -0300 Subject: [PATCH 3/9] fix(types): tighten JSDoc signatures so type-check passes (SD-2608) the normalizer and tracked-change mapper emit strict typedefs but several helper signatures were relying on implicit any, and the NORMALIZED_MARKER symbol indexed an under-typed config object. CI type-check rejected these once @ts-check saw the full types. - normalize-track-changes-config.js: explicit JSDoc param types on helpers, Record on config so module/track-chains resolve, symbol cast for the NORMALIZED_MARKER access - trackedChangeIdMapper.js: nested walk now propagates pairReplacements instead of emitting an under-typed WalkContext --- .../v2/importer/trackedChangeIdMapper.js | 10 ++++- .../helpers/normalize-track-changes-config.js | 40 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) 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 f190122f35..e4b42ca833 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 @@ -107,8 +107,14 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { if (element.elements) { // Descend with an isolated context so content inside a tracked change - // cannot clear the outer replacement candidate. - walkElements(element.elements, idMap, { lastTrackedChange: null }, /* insideTrackedChange */ true); + // cannot clear the outer replacement candidate. Inherit pairReplacements + // so nested changes honor the caller's choice if pairing ever applies. + walkElements( + element.elements, + idMap, + { lastTrackedChange: null, pairReplacements: context.pairReplacements }, + /* insideTrackedChange */ true, + ); } } else { // Content-bearing elements break replacement pairing. Only non-content 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 1dc9bf60da..d80706e377 100644 --- a/packages/superdoc/src/core/helpers/normalize-track-changes-config.js +++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.js @@ -5,35 +5,62 @@ * @typedef {{ visible: boolean, mode: TrackChangesMode, enabled: boolean, pairReplacements: boolean }} NormalizedTrackChangesConfig */ -const ALLOWED_MODES = /** @type {const} */ (['review', 'original', 'final', 'off']); +/** @type {ReadonlyArray} */ +const ALLOWED_MODES = ['review', 'original', 'final', 'off']; // Marks a config object we've already normalized so a second pass with the same // object (e.g. a consumer reusing the config to mount another SuperDoc) doesn't // warn on the legacy keys we wrote back during the first pass. const NORMALIZED_MARKER = Symbol.for('@superdoc/trackChanges:normalized'); +/** @type {Set} */ const warnedKeys = new Set(); +/** + * @param {string} legacyPath + * @param {string} newPath + */ function warnOnce(legacyPath, newPath) { if (warnedKeys.has(legacyPath)) return; warnedKeys.add(legacyPath); console.warn(`[SuperDoc] ${legacyPath} is deprecated — use ${newPath} instead.`); } +/** + * @param {unknown} newVal + * @param {unknown} legacyVal + * @param {boolean} fallback + * @returns {boolean} + */ function resolveBool(newVal, legacyVal, fallback) { if (typeof newVal === 'boolean') return newVal; if (typeof legacyVal === 'boolean') return legacyVal; return fallback; } +/** + * @param {unknown} newVal + * @param {unknown} legacyVal + * @param {TrackChangesMode} fallback + * @returns {TrackChangesMode} + */ function resolveMode(newVal, legacyVal, fallback) { - if (ALLOWED_MODES.includes(newVal)) return newVal; - if (ALLOWED_MODES.includes(legacyVal)) return legacyVal; + if (typeof newVal === 'string' && ALLOWED_MODES.includes(/** @type {TrackChangesMode} */ (newVal))) { + return /** @type {TrackChangesMode} */ (newVal); + } + if (typeof legacyVal === 'string' && ALLOWED_MODES.includes(/** @type {TrackChangesMode} */ (legacyVal))) { + return /** @type {TrackChangesMode} */ (legacyVal); + } return fallback; } +/** + * @param {unknown} value + * @returns {Record | null} + */ function pickObject(value) { - return value && typeof value === 'object' && !Array.isArray(value) ? value : null; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return /** @type {Record} */ (value); } /** @@ -50,11 +77,11 @@ function pickObject(value) { * same config object so write-through values don't look like new legacy * usage. * - * @param {object} config The SuperDoc config object (mutated in place) + * @param {Record} config The SuperDoc config object (mutated in place) * @returns {NormalizedTrackChangesConfig} */ export function normalizeTrackChangesConfig(config) { - const alreadyNormalized = config[NORMALIZED_MARKER] === true; + const alreadyNormalized = /** @type {Record} */ (config)[NORMALIZED_MARKER] === true; if (!pickObject(config.modules)) { config.modules = {}; @@ -85,6 +112,7 @@ export function normalizeTrackChangesConfig(config) { // document without an explicit mode falls back to 'original' unless the // consumer asked for tracked changes to be visible. const isViewingMode = config.documentMode === 'viewing'; + /** @type {TrackChangesMode} */ const defaultMode = isViewingMode ? (visible ? 'review' : 'original') : 'review'; const mode = resolveMode(fromCanonical?.mode, fromLegacyLayout?.mode, defaultMode); From ab81823cac17f52750cf2a7b6c35b99f10d21e1d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 18:13:10 -0300 Subject: [PATCH 4/9] =?UTF-8?q?refactor(track-changes):=20rename=20pairRep?= =?UTF-8?q?lacements=20=E2=86=92=20replacements=20enum=20(SD-2608)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pairReplacements leaked editor-speak and forced consumers to mentally invert a boolean to get the Word-style model. rename to a self-documenting enum that sits alongside future per-behavior siblings under modules.trackChanges. - public surface: `modules.trackChanges.replacements: 'paired' | 'independent'` default `'paired'` (no behavior change for existing consumers) - plumbing: Editor.options.trackedChanges.replacements → SuperConverter trackedChangesOptions → buildTrackedChangeIdMap; parallel path through trackedTransaction → replaceStep so user-driven replacements (the Firefox coalescing path) also honor the flag - replaceStep now passes id: undefined to markDeletion in 'independent' mode so the deletion mints its own id — closing the UI-typing gap - docs: configuration.mdx ParamField + example use the enum; track-changes extension doc gets a new "Revision model" section explaining paired vs independent with snippets - tests: normalizer, mapper, extension, and a new behavior spec (tracked-change-independent-replacement.spec.ts) exercise the enum end-to-end across chromium/firefox/webkit; harness + fixture accept a `replacements` URL param so tests can flip the flag --- apps/docs/core/superdoc/configuration.mdx | 8 +- apps/docs/extensions/track-changes.mdx | 37 +++++ .../src/editors/v1/core/Editor.ts | 1 + .../v1/core/super-converter/SuperConverter.js | 4 +- .../v2/importer/docxImporter.js | 2 +- .../v2/importer/trackedChangeIdMapper.js | 26 +-- .../v2/importer/trackedChangeIdMapper.test.js | 8 +- .../src/editors/v1/core/types/EditorConfig.ts | 8 +- .../track-changes-extension.test.js | 6 +- .../extensions/track-changes/track-changes.js | 31 ++-- .../trackChangesHelpers/replaceStep.js | 19 ++- .../trackChangesHelpers/trackedTransaction.js | 5 +- .../helpers/normalize-track-changes-config.js | 23 ++- .../normalize-track-changes-config.test.js | 42 ++--- packages/superdoc/src/core/types/index.js | 4 +- tests/behavior/fixtures/superdoc.ts | 7 + tests/behavior/harness/main.ts | 15 +- ...ked-change-independent-replacement.spec.ts | 154 ++++++++++++++++++ 18 files changed, 330 insertions(+), 70 deletions(-) create mode 100644 tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts diff --git a/apps/docs/core/superdoc/configuration.mdx b/apps/docs/core/superdoc/configuration.mdx index 63f5ba1c83..530d0866bd 100644 --- a/apps/docs/core/superdoc/configuration.mdx +++ b/apps/docs/core/superdoc/configuration.mdx @@ -215,8 +215,10 @@ 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. + + How a tracked replacement (adjacent insertion + deletion created by typing over selected text) surfaces in the UI and API. + - `'paired'` (default, Google Docs model): the two halves share one id and resolve together with a single accept/reject click. + - `'independent'` (Microsoft Word / ECMA-376 §17.13.5 model): each insertion and each deletion has its own id, is addressable on its own, and resolves independently. @@ -239,7 +241,7 @@ new SuperDoc({ selector: '#editor', document: 'contract.docx', modules: { - trackChanges: { pairReplacements: false }, + trackChanges: { replacements: 'independent' }, }, }); ``` diff --git a/apps/docs/extensions/track-changes.mdx b/apps/docs/extensions/track-changes.mdx index f86de2f950..de35ada5f5 100644 --- a/apps/docs/extensions/track-changes.mdx +++ b/apps/docs/extensions/track-changes.mdx @@ -284,6 +284,43 @@ const superdoc = new SuperDoc({ ``` +## Revision model + +SuperDoc supports two models for how a tracked replacement (typing over selected text) is represented. Pick the one that matches the editor your users expect. + +| Model | `modules.trackChanges.replacements` | Behavior | +| --- | --- | --- | +| **Paired** (default — Google Docs) | `'paired'` | The insertion and deletion share one id. One accept/reject click resolves both halves. One comment bubble per replacement. | +| **Independent** (Microsoft Word / ECMA-376 §17.13.5) | `'independent'` | Each insertion and each deletion has its own id. Accept/reject resolves one side at a time. A replacement produces two sidebar rows. | + +Both modes round-trip cleanly through DOCX import and export — the OOXML always emits one `` / `` per mark per ECMA-376. The difference is how SuperDoc's UI and public API surface them at runtime. + +### Paired (default) + +```javascript +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + // default: modules.trackChanges.replacements === 'paired' +}); +``` + +### Independent (Word-style) + +```javascript +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + modules: { + trackChanges: { replacements: 'independent' }, + }, +}); +``` + +When `replacements: 'independent'` is set, `editor.doc.trackChanges.list()` returns one entry per revision and `decide({ id })` resolves exactly that one side. The other side of the replacement stays in the document, still addressable by its own id — useful when you're building a custom sidebar and want each revision as a separate row. + +See the [Configuration reference](/core/superdoc/configuration#track-changes-module) for the full `modules.trackChanges` shape. + ## Full example { tr: transactionToApply, state: prevState, user: this.options.user!, + replacements: this.options.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired', }) : transactionToApply; 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 3b9d7a69c7..4e453eb2de 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 @@ -211,8 +211,8 @@ class SuperConverter { /** * 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} + * `replacements` can be toggled per SuperDoc instance. + * @type {{ replacements?: 'paired' | 'independent' } | null} */ this.trackedChangesOptions = params?.trackedChangesOptions || null; 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 c743bb8653..f4a2706bf7 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 @@ -153,7 +153,7 @@ export const createDocumentJson = (docx, converter, editor) => { patchNumberingDefinitions(docx); const numbering = getNumberingDefinitions(docx); converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, { - pairReplacements: converter.trackedChangesOptions?.pairReplacements !== false, + replacements: converter.trackedChangesOptions?.replacements ?? 'paired', }); const comments = importCommentData({ docx, nodeListHandler, converter, editor }); const footnotes = importFootnoteData({ 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 e4b42ca833..0a9d6637eb 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 @@ -2,8 +2,9 @@ import { v4 as uuidv4 } from 'uuid'; /** + * @typedef {'paired' | 'independent'} TrackChangesReplacements * @typedef {{ type: string, author: string, date: string, internalId: string }} TrackedChangeEntry - * @typedef {{ lastTrackedChange: TrackedChangeEntry | null, pairReplacements: boolean }} WalkContext + * @typedef {{ lastTrackedChange: TrackedChangeEntry | null, replacements: TrackChangesReplacements }} WalkContext */ const TRACKED_CHANGE_NAMES = new Set(['w:ins', 'w:del']); @@ -44,8 +45,9 @@ function isReplacementPair(previous, current) { } /** - * Assigns an internal UUID to a tracked change element. Adjacent replacement - * halves (w:del + w:ins with matching author/date) share the same UUID. + * Assigns an internal UUID to a tracked change element. In paired mode, + * adjacent replacement halves (w:del + w:ins with matching author/date) + * share the same UUID. * * @param {object} element XML element (w:ins or w:del) * @param {Map} idMap Accumulates Word ID → internal UUID @@ -70,7 +72,9 @@ function assignInternalId(element, idMap, context, insideTrackedChange) { date: element.attributes?.['w:date'] ?? '', }; - if (context.pairReplacements && context.lastTrackedChange && isReplacementPair(context.lastTrackedChange, current)) { + const shouldPair = context.replacements === 'paired'; + + if (shouldPair && 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. @@ -107,12 +111,12 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { if (element.elements) { // Descend with an isolated context so content inside a tracked change - // cannot clear the outer replacement candidate. Inherit pairReplacements + // cannot clear the outer replacement candidate. Inherit `replacements` // so nested changes honor the caller's choice if pairing ever applies. walkElements( element.elements, idMap, - { lastTrackedChange: null, pairReplacements: context.pairReplacements }, + { lastTrackedChange: null, replacements: context.replacements }, /* insideTrackedChange */ true, ); } @@ -134,10 +138,10 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { * Builds a map from OOXML `w:id` values to stable internal UUIDs by scanning * `word/document.xml`. * - * When `pairReplacements` is `true` (the default), Word tracked replacements + * When `replacements` is `'paired'` (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`, + * resolve them as one logical change. When `replacements` is `'independent'`, * each `w:id` maps to its own UUID — matching the ECMA-376 §17.13.5 model * where every `` and `` is an independent revision. * @@ -145,16 +149,16 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { * helpers, and the tracked-change resolver — see a fully populated map. * * @param {object} docx Parsed DOCX package - * @param {{ pairReplacements?: boolean }} [options] + * @param {{ replacements?: TrackChangesReplacements }} [options] * @returns {Map} Word `w:id` → internal UUID */ 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 replacements = options.replacements === 'independent' ? 'independent' : 'paired'; const idMap = new Map(); - walkElements(body.elements, idMap, { lastTrackedChange: null, pairReplacements }); + walkElements(body.elements, idMap, { lastTrackedChange: null, replacements }); 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 060ccd4591..806ee8de63 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 @@ -256,7 +256,7 @@ describe('buildTrackedChangeIdMap', () => { expect(idMap.get('0')).toBe(idMap.get('1')); }); - describe('pairReplacements: false (unpaired mode)', () => { + describe("replacements: 'independent' (Word / ECMA-376 model)", () => { it('keeps adjacent w:del + w:ins with matching author/date as independent ids', () => { const docx = createDocx( paragraph( @@ -265,7 +265,7 @@ describe('buildTrackedChangeIdMap', () => { ), ); - const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false }); + const idMap = buildTrackedChangeIdMap(docx, { replacements: 'independent' }); expect(idMap.size).toBe(2); expect(idMap.get('10')).toBeTruthy(); @@ -276,7 +276,7 @@ describe('buildTrackedChangeIdMap', () => { 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 }); + const idMap = buildTrackedChangeIdMap(docx, { replacements: 'independent' }); expect(idMap.size).toBe(2); expect(idMap.get('1')).not.toBe(idMap.get('2')); @@ -285,7 +285,7 @@ describe('buildTrackedChangeIdMap', () => { it('treats real Word replacement siblings as independent', () => { const docx = createDocx(paragraph(wordDelete('0', 'test '), wordInsert('1', 'abc '))); - const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false }); + const idMap = buildTrackedChangeIdMap(docx, { replacements: 'independent' }); expect(idMap.get('0')).not.toBe(idMap.get('1')); }); 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 72fb590a95..cbeffd870a 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -375,7 +375,13 @@ export interface EditorOptions { visible?: boolean; mode?: 'review' | 'original' | 'final' | 'off'; enabled?: boolean; - pairReplacements?: boolean; + /** + * How a tracked replacement (ins + del) surfaces in the UI and API. + * `'paired'` (default) groups both halves under one id and resolves them + * together. `'independent'` gives each half its own id, matching the + * Microsoft Word / ECMA-376 §17.13.5 revision model. + */ + replacements?: 'paired' | 'independent'; }; /** Whether this is a new file */ 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 a91788c952..581296ba52 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,7 +1896,7 @@ 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', () => { + it("gives each replacement mark its own ID when replacements='independent'", () => { const doc = createDoc('Hello world'); const state = createState(doc); @@ -1917,7 +1917,7 @@ describe('TrackChanges extension commands', () => { editor: { options: { user: { name: 'Default', email: 'default@example.com' }, - trackedChanges: { pairReplacements: false }, + trackedChanges: { replacements: 'independent' }, }, commands: { addCommentReply: vi.fn() }, }, @@ -1934,7 +1934,7 @@ describe('TrackChanges extension commands', () => { mode: 'text', content: '

Hello world

', user: { name: 'Track Tester', email: 'track@example.com' }, - trackedChanges: { pairReplacements: false }, + trackedChanges: { replacements: 'independent' }, }); try { 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 4d6c6b2603..f33f7040bd 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,7 +12,13 @@ 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; +/** + * Reads the `replacements` mode from editor.options.trackedChanges. + * Defaults to `'paired'` when unset; anything other than the exact + * `'independent'` string is treated as paired to be defensive. + */ +const readReplacementsMode = (editor) => + editor?.options?.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired'; export const TrackChanges = Extension.create({ name: 'trackChanges', @@ -322,17 +328,18 @@ export const TrackChanges = Extension.create({ // - 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); + // - For a replacement: in `'paired'` mode both halves share the + // primary id (Google-Docs-like one-click resolve). In + // `'independent'` mode (modules.trackChanges.replacements: + // 'independent'), 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 replacementsMode = readReplacementsMode(editor); + const pairedReplacements = replacementsMode === 'paired'; const isReplacement = from !== to && text; const primaryId = id ?? uuidv4(); const insertionId = primaryId; - const deletionId = pairReplacements || !isReplacement ? primaryId : null; + const deletionId = pairedReplacements || !isReplacement ? primaryId : null; const changeId = primaryId; let insertPos = to; // Default insert position is after the selection @@ -682,9 +689,9 @@ const getChangesByIdToResolve = (state, 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. + // - The paired opposite-type mark when replacements='paired' (shared id). + // In 'independent' 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/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js index 2de39dce35..16b1fd0c42 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js @@ -152,7 +152,18 @@ const normalizeReplaceStepSingleCharDelete = ({ step, doc }) => { * @param {import('prosemirror-transform').ReplaceStep} options.originalStep Original step. * @param {number} options.originalStepIndex Original step index. */ -export const replaceStep = ({ state, tr, step, newTr, map, user, date, originalStep, originalStepIndex }) => { +export const replaceStep = ({ + state, + tr, + step, + newTr, + map, + user, + date, + originalStep, + originalStepIndex, + replacements = 'paired', +}) => { const originalRange = { from: step.from, to: step.to, sliceSize: step.slice.content.size }; step = normalizeReplaceStepSingleCharDelete({ step, doc: newTr.doc }); const stepWasNormalized = @@ -310,7 +321,11 @@ export const replaceStep = ({ state, tr, step, newTr, map, user, date, originalS to: step.to, user, date, - id: meta.insertedMark?.attrs?.id, + // SD-2607: in 'paired' mode (default), share the insertion's id so the + // two halves of a user-driven replacement resolve together. In + // 'independent' mode, pass undefined so markDeletion mints its own id + // — making the deletion an independent revision per ECMA-376 §17.13.5. + id: replacements === 'paired' ? meta.insertedMark?.attrs?.id : undefined, }); meta.deletionNodes = deletionNodes; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js index 360b250728..3b3af70cc1 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js @@ -309,10 +309,10 @@ const getPendingDeadKeyPlaceholder = ({ tr, newTr, user }) => { /** * Tracked transaction to track changes. - * @param {{ tr: import('prosemirror-state').Transaction; state: import('prosemirror-state').EditorState; user: import('@core/types/EditorConfig.js').User }} params + * @param {{ tr: import('prosemirror-state').Transaction; state: import('prosemirror-state').EditorState; user: import('@core/types/EditorConfig.js').User; replacements?: 'paired' | 'independent' }} params * @returns {import('prosemirror-state').Transaction} Modified transaction. */ -export const trackedTransaction = ({ tr, state, user }) => { +export const trackedTransaction = ({ tr, state, user, replacements = 'paired' }) => { const onlyInputTypeMeta = ['inputType', 'uiEvent', 'paste', 'pointer', 'composition']; const notAllowedMeta = ['historyUndo', 'historyRedo', 'acceptReject']; const isProgrammaticInput = tr.getMeta('inputType') === 'programmatic'; @@ -361,6 +361,7 @@ export const trackedTransaction = ({ tr, state, user }) => { date, originalStep, originalStepIndex, + replacements, }); } else if (step instanceof AddMarkStep) { addMarkStep({ 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 d80706e377..e1dab9f30e 100644 --- a/packages/superdoc/src/core/helpers/normalize-track-changes-config.js +++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.js @@ -2,12 +2,16 @@ /** * @typedef {'review' | 'original' | 'final' | 'off'} TrackChangesMode - * @typedef {{ visible: boolean, mode: TrackChangesMode, enabled: boolean, pairReplacements: boolean }} NormalizedTrackChangesConfig + * @typedef {'paired' | 'independent'} TrackChangesReplacements + * @typedef {{ visible: boolean, mode: TrackChangesMode, enabled: boolean, replacements: TrackChangesReplacements }} NormalizedTrackChangesConfig */ /** @type {ReadonlyArray} */ const ALLOWED_MODES = ['review', 'original', 'final', 'off']; +/** @type {ReadonlyArray} */ +const ALLOWED_REPLACEMENTS = ['paired', 'independent']; + // Marks a config object we've already normalized so a second pass with the same // object (e.g. a consumer reusing the config to mount another SuperDoc) doesn't // warn on the legacy keys we wrote back during the first pass. @@ -54,6 +58,17 @@ function resolveMode(newVal, legacyVal, fallback) { return fallback; } +/** + * @param {unknown} value + * @returns {TrackChangesReplacements | null} + */ +function coerceReplacements(value) { + if (typeof value === 'string' && ALLOWED_REPLACEMENTS.includes(/** @type {TrackChangesReplacements} */ (value))) { + return /** @type {TrackChangesReplacements} */ (value); + } + return null; +} + /** * @param {unknown} value * @returns {Record | null} @@ -104,9 +119,9 @@ export function normalizeTrackChangesConfig(config) { const enabled = resolveBool(fromCanonical?.enabled, fromLegacyLayout?.enabled, true); - // Replacement pairing is only surfaced on the canonical path. The legacy + // Replacement behavior 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); + const replacements = coerceReplacements(fromCanonical?.replacements) ?? 'paired'; // Default mode derives from documentMode + visibility so a viewing-mode // document without an explicit mode falls back to 'original' unless the @@ -117,7 +132,7 @@ export function normalizeTrackChangesConfig(config) { const mode = resolveMode(fromCanonical?.mode, fromLegacyLayout?.mode, defaultMode); /** @type {NormalizedTrackChangesConfig} */ - const normalized = { visible, mode, enabled, pairReplacements }; + const normalized = { visible, mode, enabled, replacements }; // 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 8e496b32b2..bbe41d35b2 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, pairReplacements: true }); + expect(result).toEqual({ visible: false, mode: 'review', enabled: true, replacements: 'paired' }); 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, pairReplacements: true }); + expect(result).toEqual({ visible: true, mode: 'original', enabled: false, replacements: 'paired' }); expect(warnSpy).not.toHaveBeenCalled(); }); @@ -160,7 +160,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: true, mode: 'original', enabled: false, pairReplacements: true }); + expect(result).toEqual({ visible: true, mode: 'original', enabled: false, replacements: 'paired' }); }); }); @@ -172,7 +172,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: false, mode: 'review', enabled: true, pairReplacements: true }); + expect(result).toEqual({ visible: false, mode: 'review', enabled: true, replacements: 'paired' }); expect(warnSpy).not.toHaveBeenCalled(); }); @@ -184,7 +184,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: false, mode: 'review', enabled: true, pairReplacements: true }); + expect(result).toEqual({ visible: false, mode: 'review', enabled: true, replacements: 'paired' }); expect(Array.isArray(config.modules)).toBe(false); }); @@ -218,41 +218,41 @@ describe('normalizeTrackChangesConfig', () => { }); }); - describe('pairReplacements flag', () => { - it('defaults to true when not supplied', () => { + describe("replacements: 'paired' | 'independent'", () => { + it("defaults to 'paired' when not supplied", () => { const result = normalizeTrackChangesConfig({}); - expect(result.pairReplacements).toBe(true); + expect(result.replacements).toBe('paired'); }); - it('accepts pairReplacements: false on the canonical path', () => { + it("accepts replacements: 'independent' on the canonical path", () => { const result = normalizeTrackChangesConfig({ - modules: { trackChanges: { pairReplacements: false } }, + modules: { trackChanges: { replacements: 'independent' } }, }); - expect(result.pairReplacements).toBe(false); + expect(result.replacements).toBe('independent'); }); - it('mirrors pairReplacements onto the canonical path write-through', () => { + it('mirrors the resolved replacements onto the canonical path write-through', () => { const config = { - modules: { trackChanges: { pairReplacements: false } }, + modules: { trackChanges: { replacements: 'independent' } }, }; normalizeTrackChangesConfig(config); - expect(config.modules.trackChanges.pairReplacements).toBe(false); + expect(config.modules.trackChanges.replacements).toBe('independent'); }); - it('coerces non-boolean pairReplacements to the default (true)', () => { + it("coerces invalid values to the default ('paired')", () => { const result = normalizeTrackChangesConfig({ - modules: { trackChanges: { pairReplacements: 'no' } }, + modules: { trackChanges: { replacements: 'whatever' } }, }); - expect(result.pairReplacements).toBe(true); + expect(result.replacements).toBe('paired'); }); it('is not derivable from any legacy key (no alias)', () => { - // Even if a legacy key is set, pairReplacements stays at its default. + // Legacy keys never carried this knob — it stays at its default. const result = normalizeTrackChangesConfig({ trackChanges: { visible: true }, layoutEngineOptions: { trackedChanges: { mode: 'original' } }, }); - expect(result.pairReplacements).toBe(true); + expect(result.replacements).toBe('paired'); }); }); @@ -294,7 +294,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: true, mode: 'original', enabled: false, pairReplacements: true }); + expect(result).toEqual({ visible: true, mode: 'original', enabled: false, replacements: 'paired' }); 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); @@ -346,7 +346,7 @@ describe('normalizeTrackChangesConfig', () => { const first = normalizeTrackChangesConfig(config); const second = normalizeTrackChangesConfig(config); - expect(first).toEqual({ visible: true, mode: 'final', enabled: true, pairReplacements: true }); + expect(first).toEqual({ visible: true, mode: 'final', enabled: true, replacements: 'paired' }); expect(second).toEqual(first); }); }); diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index 5e6a6ef810..2d9429668d 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -544,7 +544,9 @@ * - '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. + * @property {'paired' | 'independent'} [replacements='paired'] How a tracked replacement (adjacent insertion + deletion created by typing over selected text) surfaces in the UI and API. + * - `'paired'` (default, Google Docs model): the two halves share one id and resolve together with a single accept/reject click. + * - `'independent'` (Microsoft Word / ECMA-376 §17.13.5 model): each insertion and each deletion has its own id, is addressable on its own, and resolves independently. */ /** diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index 273d716100..c76268b50f 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -11,6 +11,12 @@ interface HarnessConfig { toolbar?: 'none' | 'full'; comments?: 'off' | 'on' | 'panel' | 'readonly' | 'disabled'; trackChanges?: boolean; + /** + * Forwards `modules.trackChanges.replacements` to SuperDoc when set. + * Default (unset) leaves the replacement model at its built-in `'paired'`. + * Use `'independent'` to exercise Word / ECMA-376-style separate revisions. + */ + replacements?: 'paired' | 'independent'; showCaret?: boolean; showSelection?: boolean; allowSelectionInViewMode?: boolean; @@ -45,6 +51,7 @@ function buildHarnessUrl(config: HarnessConfig = {}): string { if (config.toolbar) params.set('toolbar', config.toolbar); if (config.comments) params.set('comments', config.comments); if (config.trackChanges) params.set('trackChanges', '1'); + if (config.replacements) params.set('replacements', config.replacements); if (config.showCaret !== undefined) params.set('showCaret', config.showCaret ? '1' : '0'); if (config.showSelection !== undefined) params.set('showSelection', config.showSelection ? '1' : '0'); if (config.allowSelectionInViewMode) params.set('allowSelectionInViewMode', '1'); diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index d337534d73..b2f77519e9 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -27,6 +27,8 @@ const showSelection = params.get('showSelection') === '1'; const toolbar = params.get('toolbar'); const comments = params.get('comments'); const trackChanges = params.get('trackChanges') === '1'; +const replacementsParam = params.get('replacements'); +const replacements: 'paired' | 'independent' = replacementsParam === 'independent' ? 'independent' : 'paired'; const allowSelectionInViewMode = params.get('allowSelectionInViewMode') === '1'; const documentMode = params.get('documentMode') as 'editing' | 'viewing' | 'suggesting' | null; const contentOverride = params.get('contentOverride') ?? undefined; @@ -103,9 +105,16 @@ function init(file?: File, content?: ContentOverrideInput) { config.modules = { ...(config.modules ?? {}), comments: false }; } - // Track changes - if (trackChanges) { - config.trackChanges = { visible: true }; + // Track changes — use the canonical modules.trackChanges surface so the + // harness can exercise the replacements enum end-to-end. + if (trackChanges || replacementsParam) { + config.modules = { + ...(config.modules ?? {}), + trackChanges: { + ...(trackChanges ? { visible: true } : {}), + replacements, + }, + }; } // Selection in viewing mode diff --git a/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts b/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts new file mode 100644 index 0000000000..6d774d3c5e --- /dev/null +++ b/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts @@ -0,0 +1,154 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + acceptTrackChange, + assertDocumentApiReady, + listTrackChanges, + rejectTrackChange, +} from '../../helpers/document-api.js'; +import type { TrackChangeType } from '../../helpers/document-api.js'; + +test.use({ + config: { + toolbar: 'full', + comments: 'panel', + trackChanges: true, + // SD-2607: Word / ECMA-376-style independent revisions — each insertion + // and each deletion has its own id, and accept/reject resolves one side + // at a time. + replacements: 'independent', + }, +}); + +type TrackedSegment = { + from: number; + id: string; + text: string; + to: number; + type: TrackChangeType; +}; + +async function listTrackedSegments(page: Page): Promise { + return page.evaluate(() => { + const segments: Array<{ from: number; id: string; text: string; to: number; type: TrackChangeType }> = []; + const editor = (window as any).editor; + editor.state.doc.descendants((node: any, pos: number) => { + if (!node?.isText || !node.text) return; + const trackedMark = (node.marks ?? []).find( + (mark: any) => mark.type?.name === 'trackInsert' || mark.type?.name === 'trackDelete', + ); + if (!trackedMark) return; + segments.push({ + from: Number(pos), + id: String(trackedMark.attrs?.id ?? ''), + text: String(node.text), + to: Number(pos + node.nodeSize), + type: trackedMark.type.name === 'trackDelete' ? 'delete' : 'insert', + }); + }); + return segments; + }); +} + +test.describe("trackedChanges.replacements='independent'", () => { + test('UI replacement produces two independent tracked revisions', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('The quick brown fox'); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await superdoc.tripleClickLine(0); + await superdoc.waitForStable(); + await superdoc.type('The speedy brown fox'); + await superdoc.waitForStable(); + + await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(2); + + const listed = await listTrackChanges(superdoc.page); + const insertions = listed.changes.filter((c: any) => c.type === 'insert'); + const deletions = listed.changes.filter((c: any) => c.type === 'delete'); + + expect(insertions.length).toBeGreaterThanOrEqual(1); + expect(deletions.length).toBeGreaterThanOrEqual(1); + + // Headline guarantee: every ins/del revision is addressable by its own + // id. No two revisions share an id when replacements is 'independent'. + const allIds = listed.changes.map((c: any) => c.id); + const uniqueIds = new Set(allIds); + expect(uniqueIds.size).toBe(allIds.length); + }); + + test('accepting the insertion leaves the deletion addressable on its own', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('The lazy dog'); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await superdoc.tripleClickLine(0); + await superdoc.waitForStable(); + await superdoc.type('The sleepy cat'); + await superdoc.waitForStable(); + + await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(2); + + const before = await listTrackChanges(superdoc.page); + const insertion = before.changes.find((c: any) => c.type === 'insert'); + const deletion = before.changes.find((c: any) => c.type === 'delete'); + expect(insertion, "expected an insertion with replacements='independent'").toBeTruthy(); + expect(deletion, "expected a deletion with replacements='independent'").toBeTruthy(); + + await acceptTrackChange(superdoc.page, { id: insertion!.id }); + await superdoc.waitForStable(); + + const segmentsAfterAccept = await listTrackedSegments(superdoc.page); + const remainingDeletes = segmentsAfterAccept.filter((s) => s.type === 'delete'); + const remainingInserts = segmentsAfterAccept.filter((s) => s.type === 'insert'); + expect(remainingDeletes.length).toBeGreaterThanOrEqual(1); + expect(remainingInserts.length).toBe(0); + + await rejectTrackChange(superdoc.page, { id: deletion!.id }); + await superdoc.waitForStable(); + + await expect.poll(() => listTrackedSegments(superdoc.page)).toEqual([]); + }); + + test('rejecting the deletion leaves the insertion addressable on its own', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Replace ME now'); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await superdoc.tripleClickLine(0); + await superdoc.waitForStable(); + await superdoc.type('Replace it now'); + await superdoc.waitForStable(); + + await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(2); + + const before = await listTrackChanges(superdoc.page); + const insertion = before.changes.find((c: any) => c.type === 'insert'); + const deletion = before.changes.find((c: any) => c.type === 'delete'); + expect(insertion).toBeTruthy(); + expect(deletion).toBeTruthy(); + + await rejectTrackChange(superdoc.page, { id: deletion!.id }); + await superdoc.waitForStable(); + + const segmentsAfterReject = await listTrackedSegments(superdoc.page); + const remainingDeletes = segmentsAfterReject.filter((s) => s.type === 'delete'); + const remainingInserts = segmentsAfterReject.filter((s) => s.type === 'insert'); + expect(remainingDeletes.length).toBe(0); + expect(remainingInserts.length).toBeGreaterThanOrEqual(1); + + await acceptTrackChange(superdoc.page, { id: insertion!.id }); + await superdoc.waitForStable(); + + await expect.poll(() => listTrackedSegments(superdoc.page)).toEqual([]); + }); +}); From 6dccc2c3f484ffb797909a594e0ecedea8dd27a4 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 18:18:21 -0300 Subject: [PATCH 5/9] docs(track-changes): migrate from extensions to modules (SD-2608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit with SuperDoc moving away from ProseMirror and extensions no longer appearing in the public nav, track changes reads better as a first-class module page alongside comments. the new modules/track-changes.mdx consolidates what consumers actually need: configuration, revision model (paired vs independent), the Document API (editor.doc.trackChanges.*), editor commands, events, permissions, and DOCX import/export. - new: apps/docs/modules/track-changes.mdx modeled on modules/comments.mdx - nav: docs.json lists modules/track-changes after modules/comments - redirect: /extensions/track-changes → /modules/track-changes so bookmarked URLs and external links keep working - deleted: apps/docs/extensions/track-changes.mdx (was hidden anyway) - updated: modules/overview, extensions/overview, and superdoc/configuration cross-links point at the new page --- apps/docs/core/superdoc/configuration.mdx | 2 +- apps/docs/docs.json | 5 + apps/docs/extensions/overview.mdx | 2 +- apps/docs/extensions/track-changes.mdx | 338 -------------- apps/docs/modules/overview.mdx | 6 +- apps/docs/modules/track-changes.mdx | 514 ++++++++++++++++++++++ 6 files changed, 524 insertions(+), 343 deletions(-) delete mode 100644 apps/docs/extensions/track-changes.mdx create mode 100644 apps/docs/modules/track-changes.mdx diff --git a/apps/docs/core/superdoc/configuration.mdx b/apps/docs/core/superdoc/configuration.mdx index 530d0866bd..855e0bf2bb 100644 --- a/apps/docs/core/superdoc/configuration.mdx +++ b/apps/docs/core/superdoc/configuration.mdx @@ -112,7 +112,7 @@ new SuperDoc({ - `viewing` - Read-only display - `suggesting` - Track changes enabled - See the [Track Changes extension](/extensions/track-changes) for accept/reject commands, and the [runnable example](https://github.com/superdoc-dev/superdoc/tree/main/examples/features/track-changes) for a complete workflow. + See the [Track Changes module](/modules/track-changes) for accept/reject commands, the Document API, and configuration. The [runnable example](https://github.com/superdoc-dev/superdoc/tree/main/examples/features/track-changes) shows a complete workflow.
diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 8c9d8df79f..d7ac1c5269 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -154,6 +154,7 @@ ] }, "modules/comments", + "modules/track-changes", { "group": "Toolbar", "tag": "NEW", @@ -455,6 +456,10 @@ "source": "/extensions/slash-menu", "destination": "/extensions/context-menu" }, + { + "source": "/extensions/track-changes", + "destination": "/modules/track-changes" + }, { "source": "/guides/breaking-changes-v1", "destination": "/guides/migration/breaking-changes-v1" diff --git a/apps/docs/extensions/overview.mdx b/apps/docs/extensions/overview.mdx index 76eb84d7c9..584d03ecb2 100644 --- a/apps/docs/extensions/overview.mdx +++ b/apps/docs/extensions/overview.mdx @@ -53,7 +53,7 @@ Basic document capabilities: ### Advanced features Complex functionality: -- **[Track Changes](/extensions/track-changes)** - Revision tracking +- **[Track Changes](/modules/track-changes)** - Revision tracking - **[Comments](/extensions/comments)** - Discussions - **[Field Annotation](/extensions/field-annotation)** - Form fields - **[Document Section](/extensions/document-section)** - Locked sections diff --git a/apps/docs/extensions/track-changes.mdx b/apps/docs/extensions/track-changes.mdx deleted file mode 100644 index de35ada5f5..0000000000 --- a/apps/docs/extensions/track-changes.mdx +++ /dev/null @@ -1,338 +0,0 @@ ---- -hidden: true -title: Track Changes extension -sidebarTitle: Track Changes ---- - -Track Changes records all edits with author attribution and timestamps, matching Microsoft Word's revision tracking. - -## Usage - -Enable through document mode: - -```javascript -superdoc.setDocumentMode('suggesting'); // Enable tracking -superdoc.setDocumentMode('editing'); // Disable tracking -``` - -Or toggle programmatically: - - -```javascript Usage -editor.commands.enableTrackChanges() -editor.commands.disableTrackChanges() -editor.commands.toggleTrackChanges() -``` - -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; - -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - editor.commands.enableTrackChanges() - editor.commands.disableTrackChanges() - editor.commands.toggleTrackChanges() - }, -}); -``` - - -## Commands - -### Accept changes - - -```javascript Usage -// Accept at current selection -editor.commands.acceptTrackedChangeBySelection() - -// Accept a specific change by ID -editor.commands.acceptTrackedChangeById('change-123') - -// Accept a change object (with start/end positions) -editor.commands.acceptTrackedChange({ trackedChange: { start: 10, end: 50 } }) - -// Accept changes in a range -editor.commands.acceptTrackedChangesBetween(10, 50) - -// Accept all changes in the document -editor.commands.acceptAllTrackedChanges() - -// Toolbar-aware accept (uses active thread or selection) -editor.commands.acceptTrackedChangeFromToolbar() -``` - -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; - -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - // Accept at current selection - editor.commands.acceptTrackedChangeBySelection() - - // Accept a specific change by ID - editor.commands.acceptTrackedChangeById('change-123') - - // Accept a change object (with start/end positions) - editor.commands.acceptTrackedChange({ trackedChange: { start: 10, end: 50 } }) - - // Accept changes in a range - editor.commands.acceptTrackedChangesBetween(10, 50) - - // Accept all changes in the document - editor.commands.acceptAllTrackedChanges() - - // Toolbar-aware accept (uses active thread or selection) - editor.commands.acceptTrackedChangeFromToolbar() - }, -}); -``` - - -### Reject changes - - -```javascript Usage -// Reject at current selection -editor.commands.rejectTrackedChangeOnSelection() - -// Reject a specific change by ID -editor.commands.rejectTrackedChangeById('change-123') - -// Reject a change object -editor.commands.rejectTrackedChange({ trackedChange: { start: 10, end: 50 } }) - -// Reject changes in a range -editor.commands.rejectTrackedChangesBetween(10, 50) - -// Reject all changes in the document -editor.commands.rejectAllTrackedChanges() - -// Toolbar-aware reject -editor.commands.rejectTrackedChangeFromToolbar() -``` - -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; - -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - // Reject at current selection - editor.commands.rejectTrackedChangeOnSelection() - - // Reject a specific change by ID - editor.commands.rejectTrackedChangeById('change-123') - - // Reject a change object - editor.commands.rejectTrackedChange({ trackedChange: { start: 10, end: 50 } }) - - // Reject changes in a range - editor.commands.rejectTrackedChangesBetween(10, 50) - - // Reject all changes in the document - editor.commands.rejectAllTrackedChanges() - - // Toolbar-aware reject - editor.commands.rejectTrackedChangeFromToolbar() - }, -}); -``` - - -### Insert tracked change programmatically - -Use `insertTrackedChange` to add tracked edits from external sources (e.g., AI suggestions): - - -```javascript Usage -editor.commands.insertTrackedChange({ - from: 10, - to: 25, - text: 'replacement text', - comment: 'AI suggestion: improved wording' -}) -``` - -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; - -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - editor.commands.insertTrackedChange({ - from: 10, - to: 25, - text: 'replacement text', - comment: 'AI suggestion: improved wording' - }) - }, -}); -``` - - -**Parameters:** - - - Object with `from`, `to`, `text`, `user`, `comment`, `addToHistory`, `emitCommentEvent` - - -### View modes - - -```javascript Usage -// Show document as it was before changes -editor.commands.toggleTrackChangesShowOriginal() -editor.commands.enableTrackChangesShowOriginal() -editor.commands.disableTrackChangesShowOriginal() - -// Show document as if all changes were accepted -editor.commands.toggleTrackChangesShowFinal() -editor.commands.enableTrackChangesShowFinal() -``` - -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; - -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - // Show document as it was before changes - editor.commands.toggleTrackChangesShowOriginal() - editor.commands.enableTrackChangesShowOriginal() - editor.commands.disableTrackChangesShowOriginal() - - // Show document as if all changes were accepted - editor.commands.toggleTrackChangesShowFinal() - editor.commands.enableTrackChangesShowFinal() - }, -}); -``` - - -## Helpers - -```javascript -import { trackChangesHelpers } from 'superdoc'; - -// Get all tracked changes in the document -const changes = trackChangesHelpers.getTrackChanges(editor.state); -// Returns: [{ mark, from, to }, ...] - -// Get a specific change by ID -const change = trackChangesHelpers.getTrackChanges(editor.state, 'change-123'); -``` - -## Change types - -| Type | Mark | Visual | -|------|------|--------| -| Insertion | `trackInsert` | Green underline | -| Deletion | `trackDelete` | Red strikethrough | -| Format change | `trackFormat` | Records before/after formatting | - -Each change includes author name, email, timestamp, and a unique ID. - -## Export behavior - -Changes export to DOCX as Word revisions: - - -```javascript Usage -// Export with changes preserved -await superdoc.export(); - -// Accept all first, then export clean -editor.commands.acceptAllTrackedChanges(); -await superdoc.export(); -``` - -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; - -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - // Export with changes preserved - await superdoc.export(); - - // Accept all first, then export clean - editor.commands.acceptAllTrackedChanges(); - await superdoc.export(); - }, -}); -``` - - -## Revision model - -SuperDoc supports two models for how a tracked replacement (typing over selected text) is represented. Pick the one that matches the editor your users expect. - -| Model | `modules.trackChanges.replacements` | Behavior | -| --- | --- | --- | -| **Paired** (default — Google Docs) | `'paired'` | The insertion and deletion share one id. One accept/reject click resolves both halves. One comment bubble per replacement. | -| **Independent** (Microsoft Word / ECMA-376 §17.13.5) | `'independent'` | Each insertion and each deletion has its own id. Accept/reject resolves one side at a time. A replacement produces two sidebar rows. | - -Both modes round-trip cleanly through DOCX import and export — the OOXML always emits one `` / `` per mark per ECMA-376. The difference is how SuperDoc's UI and public API surface them at runtime. - -### Paired (default) - -```javascript -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - // default: modules.trackChanges.replacements === 'paired' -}); -``` - -### Independent (Word-style) - -```javascript -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - modules: { - trackChanges: { replacements: 'independent' }, - }, -}); -``` - -When `replacements: 'independent'` is set, `editor.doc.trackChanges.list()` returns one entry per revision and `decide({ id })` resolves exactly that one side. The other side of the replacement stays in the document, still addressable by its own id — useful when you're building a custom sidebar and want each revision as a separate row. - -See the [Configuration reference](/core/superdoc/configuration#track-changes-module) for the full `modules.trackChanges` shape. - -## Full example - - - Runnable example with mode switching, accept/reject, and comments sidebar - - -## Source code - -import { SourceCodeLink } from '/snippets/components/source-code-link.jsx' - - diff --git a/apps/docs/modules/overview.mdx b/apps/docs/modules/overview.mdx index d40a7de9a8..e2326b85e8 100644 --- a/apps/docs/modules/overview.mdx +++ b/apps/docs/modules/overview.mdx @@ -29,6 +29,9 @@ const superdoc = new SuperDoc({ Threaded discussions and annotations + + Word-style revision tracking with accept/reject + Customizable formatting controls @@ -53,9 +56,6 @@ Each module is configured via `modules.` in the [SuperDoc configuration](/ These features are configured at the top level rather than through `modules`, but are commonly used alongside modules. - - Accept/reject workflow with `documentMode: 'suggesting'` - Provider-based spell check on the layout-engine editor surface diff --git a/apps/docs/modules/track-changes.mdx b/apps/docs/modules/track-changes.mdx new file mode 100644 index 0000000000..5cd619ad14 --- /dev/null +++ b/apps/docs/modules/track-changes.mdx @@ -0,0 +1,514 @@ +--- +title: Track Changes +keywords: "word track changes, document revisions, accept reject edits, docx tracked changes, review workflow, suggesting mode" +--- + +Word-style revision tracking: record every edit with author, timestamp, and a unique id. Accept or reject changes one at a time, by selection, or in bulk. Changes round-trip through DOCX as native Word revisions. + +## Quick start + +```javascript +const superdoc = new SuperDoc({ + selector: "#editor", + document: "contract.docx", + documentMode: "suggesting", // start in tracked-edit mode + user: { + name: "John Smith", + email: "john@company.com", + }, + modules: { + trackChanges: { + visible: true, // show tracked-change indicators in viewing mode + }, + }, + onCommentsUpdate: ({ type, comment }) => { + if (comment?.trackedChange) { + console.log("Tracked-change event:", type, comment.trackedChangeType); + } + }, +}); +``` + + + Track changes is always available on the editor. Setting `documentMode: 'suggesting'` (or calling `superdoc.setDocumentMode('suggesting')` later) is what causes new edits to be recorded as revisions. + + +## Configuration + + + Show tracked-change markup and bubbles when `documentMode` is `viewing`. + + + + Rendering mode for tracked changes. + + + - `'review'` — show insertions and deletions inline (default for editing/suggesting) + - `'original'` — show the document as it existed before tracked changes (default for viewing when `visible` is `false`) + - `'final'` — show the document with all changes applied + - `'off'` — suppress tracked-change rendering entirely + + + + + Whether the layout engine treats tracked changes as active. Disable to render the document without any revision UI. + + + + How a tracked replacement (typing over selected text) surfaces in the UI and API. See [Revision model](#revision-model) below. + + + - `'paired'` (default, Google Docs model) — the insertion and deletion share one id and resolve with a single accept/reject click. + - `'independent'` (Microsoft Word / ECMA-376 §17.13.5) — each insertion and each deletion has its own id, is addressable on its own, and resolves independently. + + + +## Viewing mode visibility + +Tracked-change markup is hidden by default when `documentMode` is `viewing`. Use `modules.trackChanges.visible` to control what renders in read-only mode. + +```javascript +new SuperDoc({ + selector: "#viewer", + document: "contract.docx", + documentMode: "viewing", + modules: { + trackChanges: { visible: true }, + }, +}); +``` + + + The top-level `trackChanges` key still works as a deprecated alias for `modules.trackChanges` and emits a one-time console warning. + + +## Revision model + +SuperDoc supports two models for how a tracked replacement (insertion paired with deletion, created when the user types over selected text) is represented. Pick the one that matches the editor your users expect. + +| Model | `modules.trackChanges.replacements` | Behavior | +| --- | --- | --- | +| **Paired** (default — Google Docs) | `'paired'` | Both halves share one id. One accept/reject click resolves the replacement as a single change. One sidebar row per replacement. | +| **Independent** (Microsoft Word / ECMA-376 §17.13.5) | `'independent'` | Each insertion and each deletion has its own id. Accept/reject resolves one side at a time. A replacement produces two sidebar rows. | + +Both modes round-trip cleanly through DOCX import and export — the OOXML always emits one `` / `` per mark. The difference is only how SuperDoc's UI and public API surface the revisions at runtime. + +### Paired (default) + +```javascript +const superdoc = new SuperDoc({ + selector: "#editor", + document: yourFile, + // default: modules.trackChanges.replacements === 'paired' +}); +``` + +### Independent (Word-style) + +```javascript +const superdoc = new SuperDoc({ + selector: "#editor", + document: yourFile, + modules: { + trackChanges: { replacements: "independent" }, + }, +}); +``` + +When `replacements: 'independent'` is set, `editor.doc.trackChanges.list()` returns one entry per revision and `decide({ id })` resolves exactly that one side. The other half of the replacement stays in the document and is still addressable by its own id — useful when you're building a custom sidebar and want each revision as a separate row. + +## Toggling tracked edits + +Control tracked-edit recording via document mode: + +```javascript +superdoc.setDocumentMode("suggesting"); // record new edits as tracked changes +superdoc.setDocumentMode("editing"); // stop recording +superdoc.setDocumentMode("viewing"); // read-only +``` + +Or toggle from the active editor's commands: + + + +```javascript Usage +superdoc.activeEditor.commands.enableTrackChanges(); +superdoc.activeEditor.commands.disableTrackChanges(); +superdoc.activeEditor.commands.toggleTrackChanges(); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: (superdoc) => { + const editor = superdoc.activeEditor; + editor.commands.enableTrackChanges(); + }, +}); +``` + + + +## Document API + +The stable, framework-agnostic way to list and resolve tracked changes is through the Document API on the active editor: + +```javascript +const editor = superdoc.activeEditor; + +// List every tracked change. In 'paired' mode a replacement is one entry; +// in 'independent' mode it's two (one insert, one delete). +const { items } = editor.doc.trackChanges.list(); + +// Fetch a single change by id. +const change = editor.doc.trackChanges.get({ id: items[0].id }); + +// Accept or reject by id. +editor.doc.trackChanges.decide({ decision: "accept", target: { id: change.id } }); + +// Accept or reject everything in the document. +editor.doc.trackChanges.decide({ decision: "accept", target: { scope: "all" } }); +``` + +Every entry returned by `list()` / `get()` includes: + +| Field | Type | Description | +| --- | --- | --- | +| `id` | string | SuperDoc's internal id for the revision. Stable across calls. | +| `type` | `'insert' \| 'delete' \| 'format'` | The revision kind. In `'paired'` mode a replacement reports as `'insert'` (both halves grouped). | +| `author` | string | Display name of the reviewer. | +| `authorEmail` | string | Email of the reviewer. | +| `date` | string | ISO timestamp. | +| `excerpt` | string | The affected text content (insertion or deletion content). | +| `wordRevisionIds` | object | Original Word `w:id` values from the source DOCX, keyed by `insert`/`delete`/`format` for provenance. | + + + These operations are documented individually under the [Document API reference](/document-api/reference/track-changes/list): `list`, `get`, and `decide`. + + +## Editor commands + +These commands live on `superdoc.activeEditor.commands` and are useful when you need to drive accept/reject from a custom toolbar, context menu, or keyboard shortcut. + +### Accept + + + +```javascript Usage +// Accept the change(s) at the current selection +editor.commands.acceptTrackedChangeBySelection(); + +// Accept by id (the id from trackChanges.list()) +editor.commands.acceptTrackedChangeById("change-123"); + +// Accept every change between two positions +editor.commands.acceptTrackedChangesBetween(10, 50); + +// Accept everything in the document +editor.commands.acceptAllTrackedChanges(); + +// Accept whatever the toolbar has as active +editor.commands.acceptTrackedChangeFromToolbar(); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: (superdoc) => { + const editor = superdoc.activeEditor; + editor.commands.acceptTrackedChangeBySelection(); + editor.commands.acceptAllTrackedChanges(); + }, +}); +``` + + + +### Reject + + + +```javascript Usage +editor.commands.rejectTrackedChangeOnSelection(); +editor.commands.rejectTrackedChangeById("change-123"); +editor.commands.rejectTrackedChangesBetween(10, 50); +editor.commands.rejectAllTrackedChanges(); +editor.commands.rejectTrackedChangeFromToolbar(); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: (superdoc) => { + const editor = superdoc.activeEditor; + editor.commands.rejectTrackedChangeById("change-123"); + editor.commands.rejectAllTrackedChanges(); + }, +}); +``` + + + +### Insert a tracked change programmatically + +Add tracked edits from external sources (AI suggestions, batch jobs, etc.) without going through keyboard input: + + + +```javascript Usage +editor.commands.insertTrackedChange({ + from: 10, + to: 25, + text: "replacement text", + comment: "AI suggestion: improved wording", +}); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: (superdoc) => { + const editor = superdoc.activeEditor; + editor.commands.insertTrackedChange({ + from: 10, + to: 25, + text: "replacement text", + comment: "AI suggestion: improved wording", + }); + }, +}); +``` + + + + + + + Start position of the range affected by the change. + + + End position. When `from === to`, the change is a pure insertion; otherwise the range is marked deleted and the insertion text replaces it. + + + Replacement text. Omit (or leave empty) for a pure deletion. + + + Explicit id for the change. Defaults to a generated UUID. + + + Author override (`{ name, email, image? }`). Defaults to the editor's `user` option. + + + Optional comment to attach to the new tracked change. + + + Whether the transaction is recorded in the undo stack. + + + Whether to emit a `trackedChange` event through `onCommentsUpdate`. + + + + +### View modes + +Temporarily render the document without applying revisions — useful for previewing the accepted or original state: + + + +```javascript Usage +// Show the document as it was before tracked changes +editor.commands.enableTrackChangesShowOriginal(); +editor.commands.disableTrackChangesShowOriginal(); +editor.commands.toggleTrackChangesShowOriginal(); + +// Show the document with all changes accepted +editor.commands.enableTrackChangesShowFinal(); +editor.commands.toggleTrackChangesShowFinal(); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: (superdoc) => { + const editor = superdoc.activeEditor; + editor.commands.enableTrackChangesShowOriginal(); + }, +}); +``` + + + +## Change types + +| Type | Visual | Mark | +| --- | --- | --- | +| Insertion | Underlined insertion in the author's review color | `trackInsert` | +| Deletion | Strikethrough in the author's review color | `trackDelete` | +| Format change | Author-colored indicator on the affected range | `trackFormat` | + +Each change carries `author`, `authorEmail`, `date`, and a unique `id`. Imported Word revisions also preserve the original `w:id` as `wordRevisionIds` for provenance. + +## Events + +Tracked-change events are delivered through the same `onCommentsUpdate` callback as comment events. Filter by `comment.trackedChange`: + +```javascript +onCommentsUpdate: ({ type, comment }) => { + if (!comment?.trackedChange) return; + + switch (type) { + case "add": + await saveTrackedChange(comment); + break; + case "change-accepted": + await markAccepted(comment.commentId); + break; + case "change-rejected": + await markRejected(comment.commentId); + break; + } +}; +``` + + + Event type: `add`, `update`, `deleted`, `resolved`, `selected`, `change-accepted`, or `change-rejected`. + + + + Payload describing the tracked change. + + + + Always `true` for tracked-change events. + + + `'trackInsert'`, `'trackDelete'`, `'trackFormat'`, or `'both'` (paired replacement). + + + The inserted or affected text. + + + The deleted text (for deletions and replacements). + + + Author display name. + + + Author email. + + + Unix timestamp. + + + Original Word `w:id` when the change was imported from DOCX. + + + + + + Events fire once per user action, not once per mark. A tracked replacement in paired mode emits one event; in independent mode it still emits one event covering both halves. To enumerate current revisions, use `editor.doc.trackChanges.list()` — not the event stream. + + +## Permissions + +Who can accept or reject a change is governed by the same `permissionResolver` used for comments. Return `false` from the resolver to block an action: + +```javascript +modules: { + comments: { + permissionResolver: ({ permission, trackedChange, currentUser, defaultDecision }) => { + if ( + permission === "REJECT_OTHER" && + trackedChange?.attrs?.authorEmail !== currentUser?.email + ) { + return false; + } + return defaultDecision; + }, + }, +} +``` + +Tracked-change permission types: + +| Permission | Description | +| --- | --- | +| `REJECT_OWN` | Reject your own tracked changes | +| `REJECT_OTHER` | Reject other users' tracked changes | + +See [Comments → Permission resolver](/modules/comments#permission-resolver) for the full list of permission types and resolver behavior. + +## Word import/export + +Tracked changes round-trip cleanly through DOCX: + + + +```javascript Usage +// Export with revisions preserved — Word will open the file and show +// insertions/deletions under Review. +const blob = await superdoc.export(); + +// Accept all first, then export a clean copy. +editor.commands.acceptAllTrackedChanges(); +const cleanBlob = await superdoc.export(); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: async (superdoc) => { + const blob = await superdoc.export(); + }, +}); +``` + + + +Imported Word revisions preserve their original `w:id` values as `wordRevisionIds` on each entry returned by `editor.doc.trackChanges.list()` — useful when you need to correlate SuperDoc revisions with an upstream Word document or an external review system. + + + Round-trip support today covers inserted run content (``), deleted run content (``), and run-level format changes (``). Paragraph-level property changes, tracked table row/cell edits, and tracked moves are on the roadmap. + + +## Full example + + + Runnable example: mode switching, accept/reject, comments sidebar, and import/export. + From 09189448ca5a2e80d7bbbbae22dad356fb370035 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 18:27:50 -0300 Subject: [PATCH 6/9] docs(track-changes): correct docs against codebase + lead with Document API (SD-2608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audited every claim on modules/track-changes.mdx against packages/ and fixed the drift. the Document API is now the primary surface; legacy editor commands are grouped under a single deprecated section. corrections: - event payload is flat ({ type: 'trackedChange', event, changeId, ... }), not nested under { type, comment }. fields trackedChangeType, trackedChangeText, deletedText, trackedChangeDisplayType, author, authorEmail, date, importedAuthor come straight from comments-plugin.js:1134 - event values pulled from shared/common/event-types.ts: add, update, deleted, resolved, selected, change-accepted, change-rejected - permissions include RESOLVE_OWN / RESOLVE_OTHER (accept) alongside REJECT_OWN / REJECT_OTHER (permission-helpers.js:3-12) - trackChanges.list() returns .items (not .changes) per DiscoveryOutput - removed disableTrackChangesShowFinal() — not implemented - mark names confirmed: trackInsert / trackDelete / trackFormat restructuring: - Document API section moved up and gets the full TrackChangeInfo shape - editor.commands usage moved to a "Legacy editor commands" section at the bottom, wrapped in a Warning banner pointing at the Document API - voice aligned with brand.md: shorter sentences, concrete examples, "Import it. Edit it. Export it. Nothing lost." over abstract claims no code changes. --- apps/docs/modules/track-changes.mdx | 543 ++++++++++++---------------- 1 file changed, 236 insertions(+), 307 deletions(-) diff --git a/apps/docs/modules/track-changes.mdx b/apps/docs/modules/track-changes.mdx index 5cd619ad14..b1eac9f34a 100644 --- a/apps/docs/modules/track-changes.mdx +++ b/apps/docs/modules/track-changes.mdx @@ -3,7 +3,7 @@ title: Track Changes keywords: "word track changes, document revisions, accept reject edits, docx tracked changes, review workflow, suggesting mode" --- -Word-style revision tracking: record every edit with author, timestamp, and a unique id. Accept or reject changes one at a time, by selection, or in bulk. Changes round-trip through DOCX as native Word revisions. +Word-style revision tracking. Every edit carries an author, a timestamp, and an id. Accept or reject one at a time, by selection, or in bulk. Changes round-trip through DOCX as native Word revisions — import, edit, export, nothing lost. ## Quick start @@ -11,50 +11,50 @@ Word-style revision tracking: record every edit with author, timestamp, and a un const superdoc = new SuperDoc({ selector: "#editor", document: "contract.docx", - documentMode: "suggesting", // start in tracked-edit mode + documentMode: "suggesting", // record new edits as tracked changes user: { name: "John Smith", email: "john@company.com", }, modules: { trackChanges: { - visible: true, // show tracked-change indicators in viewing mode + visible: true, }, }, - onCommentsUpdate: ({ type, comment }) => { - if (comment?.trackedChange) { - console.log("Tracked-change event:", type, comment.trackedChangeType); + onCommentsUpdate: (payload) => { + if (payload.type === "trackedChange") { + console.log(payload.event, payload.trackedChangeType, payload.changeId); } }, }); ``` - Track changes is always available on the editor. Setting `documentMode: 'suggesting'` (or calling `superdoc.setDocumentMode('suggesting')` later) is what causes new edits to be recorded as revisions. + Tracked-edit recording follows `documentMode`. Set `documentMode: 'suggesting'` (or call `superdoc.setDocumentMode('suggesting')` later) to start recording new edits as revisions. ## Configuration - Show tracked-change markup and bubbles when `documentMode` is `viewing`. + Show tracked-change markup when `documentMode` is `viewing`. - Rendering mode for tracked changes. + Rendering mode. - - `'review'` — show insertions and deletions inline (default for editing/suggesting) - - `'original'` — show the document as it existed before tracked changes (default for viewing when `visible` is `false`) - - `'final'` — show the document with all changes applied - - `'off'` — suppress tracked-change rendering entirely + - `'review'` — show insertions and deletions inline (default for editing and suggesting). + - `'original'` — show the document as it was before tracked changes (default for viewing when `visible` is `false`). + - `'final'` — show the document with all changes applied. + - `'off'` — suppress tracked-change rendering entirely. - Whether the layout engine treats tracked changes as active. Disable to render the document without any revision UI. + Whether the layout engine treats tracked changes as active. Turn off to render the document without any revision UI. - How a tracked replacement (typing over selected text) surfaces in the UI and API. See [Revision model](#revision-model) below. + How a tracked replacement (typing over selected text) surfaces in the API and UI. See [Revision model](#revision-model). - - `'paired'` (default, Google Docs model) — the insertion and deletion share one id and resolve with a single accept/reject click. - - `'independent'` (Microsoft Word / ECMA-376 §17.13.5) — each insertion and each deletion has its own id, is addressable on its own, and resolves independently. + - `'paired'` (default, Google Docs model) — the insertion and deletion share one id and resolve together. + - `'independent'` (Microsoft Word / ECMA-376 §17.13.5) — each side has its own id and resolves on its own. ## Viewing mode visibility -Tracked-change markup is hidden by default when `documentMode` is `viewing`. Use `modules.trackChanges.visible` to control what renders in read-only mode. +Tracked-change markup is hidden by default when `documentMode` is `'viewing'`. Flip `modules.trackChanges.visible` to show it in read-only mode. ```javascript new SuperDoc({ @@ -86,19 +86,19 @@ new SuperDoc({ ``` - The top-level `trackChanges` key still works as a deprecated alias for `modules.trackChanges` and emits a one-time console warning. + The top-level `trackChanges` key still works as a deprecated alias for `modules.trackChanges` and prints a one-time console warning. ## Revision model -SuperDoc supports two models for how a tracked replacement (insertion paired with deletion, created when the user types over selected text) is represented. Pick the one that matches the editor your users expect. +SuperDoc supports two models for how a tracked replacement (an insertion paired with a deletion, created when a user types over selected text) shows up in the API and UI. Pick the one that matches the editor your users expect. | Model | `modules.trackChanges.replacements` | Behavior | | --- | --- | --- | -| **Paired** (default — Google Docs) | `'paired'` | Both halves share one id. One accept/reject click resolves the replacement as a single change. One sidebar row per replacement. | +| **Paired** (default — Google Docs) | `'paired'` | Both halves share one id. One accept/reject resolves both. One sidebar row per replacement. | | **Independent** (Microsoft Word / ECMA-376 §17.13.5) | `'independent'` | Each insertion and each deletion has its own id. Accept/reject resolves one side at a time. A replacement produces two sidebar rows. | -Both modes round-trip cleanly through DOCX import and export — the OOXML always emits one `` / `` per mark. The difference is only how SuperDoc's UI and public API surface the revisions at runtime. +Both modes round-trip cleanly through DOCX — the OOXML always emits one `` / `` per mark. The difference is how the API surfaces the revisions at runtime. ### Paired (default) @@ -122,57 +122,25 @@ const superdoc = new SuperDoc({ }); ``` -When `replacements: 'independent'` is set, `editor.doc.trackChanges.list()` returns one entry per revision and `decide({ id })` resolves exactly that one side. The other half of the replacement stays in the document and is still addressable by its own id — useful when you're building a custom sidebar and want each revision as a separate row. - -## Toggling tracked edits - -Control tracked-edit recording via document mode: - -```javascript -superdoc.setDocumentMode("suggesting"); // record new edits as tracked changes -superdoc.setDocumentMode("editing"); // stop recording -superdoc.setDocumentMode("viewing"); // read-only -``` - -Or toggle from the active editor's commands: - - - -```javascript Usage -superdoc.activeEditor.commands.enableTrackChanges(); -superdoc.activeEditor.commands.disableTrackChanges(); -superdoc.activeEditor.commands.toggleTrackChanges(); -``` - -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; - -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - editor.commands.enableTrackChanges(); - }, -}); -``` - - +With `replacements: 'independent'`, `editor.doc.trackChanges.list()` returns one entry per revision and `decide({ id })` resolves exactly that one side. The other half of the replacement stays in the document, still addressable by its own id — useful when you're building a custom sidebar and want each revision as a separate row. ## Document API -The stable, framework-agnostic way to list and resolve tracked changes is through the Document API on the active editor: +Use the Document API to list, read, and resolve tracked changes. It's stable, typed, framework-agnostic, and works the same in the visual editor and headless mode. ```javascript const editor = superdoc.activeEditor; // List every tracked change. In 'paired' mode a replacement is one entry; // in 'independent' mode it's two (one insert, one delete). -const { items } = editor.doc.trackChanges.list(); +const result = editor.doc.trackChanges.list(); + +for (const item of result.items) { + console.log(item.id, item.type, item.author, item.excerpt); +} // Fetch a single change by id. -const change = editor.doc.trackChanges.get({ id: items[0].id }); +const change = editor.doc.trackChanges.get({ id: result.items[0].id }); // Accept or reject by id. editor.doc.trackChanges.decide({ decision: "accept", target: { id: change.id } }); @@ -181,265 +149,147 @@ editor.doc.trackChanges.decide({ decision: "accept", target: { id: change.id } } editor.doc.trackChanges.decide({ decision: "accept", target: { scope: "all" } }); ``` -Every entry returned by `list()` / `get()` includes: - -| Field | Type | Description | -| --- | --- | --- | -| `id` | string | SuperDoc's internal id for the revision. Stable across calls. | -| `type` | `'insert' \| 'delete' \| 'format'` | The revision kind. In `'paired'` mode a replacement reports as `'insert'` (both halves grouped). | -| `author` | string | Display name of the reviewer. | -| `authorEmail` | string | Email of the reviewer. | -| `date` | string | ISO timestamp. | -| `excerpt` | string | The affected text content (insertion or deletion content). | -| `wordRevisionIds` | object | Original Word `w:id` values from the source DOCX, keyed by `insert`/`delete`/`format` for provenance. | - - - These operations are documented individually under the [Document API reference](/document-api/reference/track-changes/list): `list`, `get`, and `decide`. - - -## Editor commands - -These commands live on `superdoc.activeEditor.commands` and are useful when you need to drive accept/reject from a custom toolbar, context menu, or keyboard shortcut. - -### Accept - - - -```javascript Usage -// Accept the change(s) at the current selection -editor.commands.acceptTrackedChangeBySelection(); - -// Accept by id (the id from trackChanges.list()) -editor.commands.acceptTrackedChangeById("change-123"); - -// Accept every change between two positions -editor.commands.acceptTrackedChangesBetween(10, 50); - -// Accept everything in the document -editor.commands.acceptAllTrackedChanges(); - -// Accept whatever the toolbar has as active -editor.commands.acceptTrackedChangeFromToolbar(); -``` - -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; - -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - editor.commands.acceptTrackedChangeBySelection(); - editor.commands.acceptAllTrackedChanges(); - }, -}); -``` - - - -### Reject - - - -```javascript Usage -editor.commands.rejectTrackedChangeOnSelection(); -editor.commands.rejectTrackedChangeById("change-123"); -editor.commands.rejectTrackedChangesBetween(10, 50); -editor.commands.rejectAllTrackedChanges(); -editor.commands.rejectTrackedChangeFromToolbar(); -``` - -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; - -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - editor.commands.rejectTrackedChangeById("change-123"); - editor.commands.rejectAllTrackedChanges(); - }, -}); -``` - - - -### Insert a tracked change programmatically - -Add tracked edits from external sources (AI suggestions, batch jobs, etc.) without going through keyboard input: - - - -```javascript Usage -editor.commands.insertTrackedChange({ - from: 10, - to: 25, - text: "replacement text", - comment: "AI suggestion: improved wording", -}); -``` - -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; - -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - editor.commands.insertTrackedChange({ - from: 10, - to: 25, - text: "replacement text", - comment: "AI suggestion: improved wording", - }); - }, -}); -``` - - - - - - - Start position of the range affected by the change. - - - End position. When `from === to`, the change is a pure insertion; otherwise the range is marked deleted and the insertion text replaces it. - - - Replacement text. Omit (or leave empty) for a pure deletion. - - - Explicit id for the change. Defaults to a generated UUID. - - - Author override (`{ name, email, image? }`). Defaults to the editor's `user` option. - - - Optional comment to attach to the new tracked change. - - - Whether the transaction is recorded in the undo stack. - - - Whether to emit a `trackedChange` event through `onCommentsUpdate`. - +Every entry returned by `list()` and `get()` has this shape: + + + + + SuperDoc's internal id for the revision. Stable across calls. + + + Entity address — `{ kind: 'entity', entityType: 'trackedChange', entityId: id }`. + + + Revision kind. In `'paired'` mode a replacement reports as `'insert'` (both halves grouped). In `'independent'` mode the insertion and deletion are separate entries. + + + Display name of the reviewer. + + + Email of the reviewer. + + + Reviewer avatar URL, when provided. + + + ISO timestamp of the revision. + + + The affected text. + + + Original Word `w:id` values from the source DOCX, keyed by `insert` / `delete` / `format`. Useful for correlating SuperDoc revisions with upstream systems. + - + -### View modes +`list()` accepts an optional query with `limit`, `offset`, and `type` (`'insert' | 'delete' | 'format'`) for pagination and filtering. See the full reference: -Temporarily render the document without applying revisions — useful for previewing the accepted or original state: +- [`trackChanges.list`](/document-api/reference/track-changes/list) +- [`trackChanges.get`](/document-api/reference/track-changes/get) +- [`trackChanges.decide`](/document-api/reference/track-changes/decide) - - -```javascript Usage -// Show the document as it was before tracked changes -editor.commands.enableTrackChangesShowOriginal(); -editor.commands.disableTrackChangesShowOriginal(); -editor.commands.toggleTrackChangesShowOriginal(); - -// Show the document with all changes accepted -editor.commands.enableTrackChangesShowFinal(); -editor.commands.toggleTrackChangesShowFinal(); -``` +## Toggling tracked edits -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; +Control recording via document mode: -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: (superdoc) => { - const editor = superdoc.activeEditor; - editor.commands.enableTrackChangesShowOriginal(); - }, -}); +```javascript +superdoc.setDocumentMode("suggesting"); // record new edits as tracked changes +superdoc.setDocumentMode("editing"); // stop recording +superdoc.setDocumentMode("viewing"); // read-only ``` - - ## Change types -| Type | Visual | Mark | +| Type | Mark | Visual | | --- | --- | --- | -| Insertion | Underlined insertion in the author's review color | `trackInsert` | -| Deletion | Strikethrough in the author's review color | `trackDelete` | -| Format change | Author-colored indicator on the affected range | `trackFormat` | +| Insertion | `trackInsert` | Underlined in the reviewer's color | +| Deletion | `trackDelete` | Strikethrough in the reviewer's color | +| Format change | `trackFormat` | Records the before/after formatting on the run | -Each change carries `author`, `authorEmail`, `date`, and a unique `id`. Imported Word revisions also preserve the original `w:id` as `wordRevisionIds` for provenance. +Each mark carries `id`, `author`, `authorEmail`, `date`, and — for imports from Word — the original `w:id` as `sourceId` so you can round-trip revision provenance. ## Events -Tracked-change events are delivered through the same `onCommentsUpdate` callback as comment events. Filter by `comment.trackedChange`: +Tracked-change events are delivered through the same `onCommentsUpdate` callback as comment events. The top-level `type` field tells them apart; filter on `type === 'trackedChange'` and read the flat payload. ```javascript -onCommentsUpdate: ({ type, comment }) => { - if (!comment?.trackedChange) return; +onCommentsUpdate: (payload) => { + if (payload.type !== "trackedChange") return; - switch (type) { + switch (payload.event) { case "add": - await saveTrackedChange(comment); + // New tracked change created. + break; + case "update": + // Existing tracked change updated (e.g. reply added, author edited). break; case "change-accepted": - await markAccepted(comment.commentId); + await markAccepted(payload.changeId); break; case "change-rejected": - await markRejected(comment.commentId); + await markRejected(payload.changeId); + break; + case "resolved": + // Tracked-change comment thread resolved. break; } }; ``` - - Event type: `add`, `update`, `deleted`, `resolved`, `selected`, `change-accepted`, or `change-rejected`. - - - - Payload describing the tracked change. - - - - Always `true` for tracked-change events. - - - `'trackInsert'`, `'trackDelete'`, `'trackFormat'`, or `'both'` (paired replacement). - - +### Payload fields + + + + + Always `'trackedChange'` for tracked-change events. Comment events use other values. + + + Event kind: `'add'`, `'update'`, `'deleted'`, `'resolved'`, `'selected'`, `'change-accepted'`, or `'change-rejected'`. + + + The tracked-change id (matches `TrackChangeInfo.id` returned by `trackChanges.list()`). + + + The mark type. `'both'` is used for a paired replacement (insertion and deletion together). + + The inserted or affected text. - - - The deleted text (for deletions and replacements). - - - Author display name. - - - Author email. - - - Unix timestamp. - - - Original Word `w:id` when the change was imported from DOCX. - + + + The deleted text, for deletions and replacements. + + + A human-facing summary derived from the change (e.g. `'hyperlinkAdded'`, `'formatChanged'`). + + + Display name. + + + Email. + + + Avatar URL, when provided. + + + ISO timestamp. + + + Source document id. + + + Original author info from the imported DOCX, when available — `{ name }`. + - + - Events fire once per user action, not once per mark. A tracked replacement in paired mode emits one event; in independent mode it still emits one event covering both halves. To enumerate current revisions, use `editor.doc.trackChanges.list()` — not the event stream. + Events fire once per user action, not once per mark. A tracked replacement in paired mode emits one event with `trackedChangeType: 'both'`. To enumerate the current set of revisions, use `editor.doc.trackChanges.list()` — not the event stream. ## Permissions -Who can accept or reject a change is governed by the same `permissionResolver` used for comments. Return `false` from the resolver to block an action: +Accept and reject permissions are governed by the same `permissionResolver` used for comments. Return `false` from the resolver to block an action. ```javascript modules: { @@ -461,6 +311,8 @@ Tracked-change permission types: | Permission | Description | | --- | --- | +| `RESOLVE_OWN` | Accept your own tracked changes | +| `RESOLVE_OTHER` | Accept other users' tracked changes | | `REJECT_OWN` | Reject your own tracked changes | | `REJECT_OTHER` | Reject other users' tracked changes | @@ -468,40 +320,117 @@ See [Comments → Permission resolver](/modules/comments#permission-resolver) fo ## Word import/export -Tracked changes round-trip cleanly through DOCX: - - +Tracked changes round-trip through DOCX as native Word revisions. Import it. Edit it. Export it. Nothing lost. -```javascript Usage -// Export with revisions preserved — Word will open the file and show -// insertions/deletions under Review. +```javascript +// Export with revisions preserved. Word opens the file and shows the +// insertions and deletions under Review. const blob = await superdoc.export(); // Accept all first, then export a clean copy. -editor.commands.acceptAllTrackedChanges(); +superdoc.activeEditor.doc.trackChanges.decide({ + decision: "accept", + target: { scope: "all" }, +}); const cleanBlob = await superdoc.export(); ``` -```javascript Full Example -import { SuperDoc } from 'superdoc'; -import 'superdoc/style.css'; +Imported Word revisions preserve their original `w:id` values as `wordRevisionIds` on each `TrackChangeInfo` entry, so you can correlate SuperDoc revisions with the source document or an external review system. -const superdoc = new SuperDoc({ - selector: '#editor', - document: yourFile, - onReady: async (superdoc) => { - const blob = await superdoc.export(); - }, + + Round-trip support today covers inserted run content (``), deleted run content (``), and run-level format changes (``). Paragraph-level property changes, tracked table row and cell edits, and tracked moves are on the roadmap — they import as accepted content today. + + +## Legacy editor commands + + + **Deprecated**. Use the [Document API](#document-api) (`editor.doc.trackChanges.list()`, `editor.doc.trackChanges.decide(...)`) instead. The commands below remain available but will be removed in a future release. + + +These legacy commands live on `superdoc.activeEditor.commands` and predate the Document API. They're still used by the built-in toolbar and a handful of keyboard shortcuts. + +### Enable and toggle + +```javascript +superdoc.activeEditor.commands.enableTrackChanges(); +superdoc.activeEditor.commands.disableTrackChanges(); +superdoc.activeEditor.commands.toggleTrackChanges(); +``` + +### Accept + +```javascript +superdoc.activeEditor.commands.acceptTrackedChangeBySelection(); +superdoc.activeEditor.commands.acceptTrackedChangeById("change-123"); +superdoc.activeEditor.commands.acceptTrackedChangesBetween(10, 50); +superdoc.activeEditor.commands.acceptAllTrackedChanges(); +superdoc.activeEditor.commands.acceptTrackedChangeFromToolbar(); +``` + +### Reject + +```javascript +superdoc.activeEditor.commands.rejectTrackedChangeOnSelection(); +superdoc.activeEditor.commands.rejectTrackedChangeById("change-123"); +superdoc.activeEditor.commands.rejectTrackedChangesBetween(10, 50); +superdoc.activeEditor.commands.rejectAllTrackedChanges(); +superdoc.activeEditor.commands.rejectTrackedChangeFromToolbar(); +``` + +### Insert a tracked change programmatically + +```javascript +superdoc.activeEditor.commands.insertTrackedChange({ + from: 10, + to: 25, + text: "replacement text", + comment: "AI suggestion: improved wording", }); ``` - + + + + Start of the range. + + + End of the range. When `from === to`, the change is a pure insertion. + + + Replacement text. Omit or leave empty for a pure deletion. + + + Explicit id for the change. Defaults to a generated UUID. + + + Author override (`{ name, email, image? }`). Defaults to the editor's `user` option. + + + Optional comment to attach. + + + Record the transaction in the undo stack. + + + Emit a `trackedChange` event through `onCommentsUpdate`. + + + + +### View modes -Imported Word revisions preserve their original `w:id` values as `wordRevisionIds` on each entry returned by `editor.doc.trackChanges.list()` — useful when you need to correlate SuperDoc revisions with an upstream Word document or an external review system. +Temporarily render the document without applying revisions — useful for previewing the accepted or original state: - - Round-trip support today covers inserted run content (``), deleted run content (``), and run-level format changes (``). Paragraph-level property changes, tracked table row/cell edits, and tracked moves are on the roadmap. - +```javascript +// Show the document as it was before tracked changes. +superdoc.activeEditor.commands.enableTrackChangesShowOriginal(); +superdoc.activeEditor.commands.disableTrackChangesShowOriginal(); +superdoc.activeEditor.commands.toggleTrackChangesShowOriginal(); + +// Show the document with all changes accepted. +superdoc.activeEditor.commands.enableTrackChangesShowFinal(); +superdoc.activeEditor.commands.toggleTrackChangesShowFinal(); +``` ## Full example @@ -510,5 +439,5 @@ Imported Word revisions preserve their original `w:id` values as `wordRevisionId icon="github" href="https://github.com/superdoc-dev/superdoc/tree/main/examples/features/track-changes" > - Runnable example: mode switching, accept/reject, comments sidebar, and import/export. + Runnable example: mode switching, accept and reject, comments sidebar, DOCX import and export. From a7bd5bf3997768e41ca0f3638731174351628244 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 18:33:50 -0300 Subject: [PATCH 7/9] docs(track-changes): use Lucide play icon for the example card (SD-2608) --- apps/docs/modules/track-changes.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/modules/track-changes.mdx b/apps/docs/modules/track-changes.mdx index b1eac9f34a..d9cbb9f903 100644 --- a/apps/docs/modules/track-changes.mdx +++ b/apps/docs/modules/track-changes.mdx @@ -436,7 +436,7 @@ superdoc.activeEditor.commands.toggleTrackChangesShowFinal(); Runnable example: mode switching, accept and reject, comments sidebar, DOCX import and export. From fdc173bfc188cecf566afabea9d5298bf5a97a5c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 18:36:49 -0300 Subject: [PATCH 8/9] docs(track-changes): use git-compare icon to match module theming (SD-2608) --- apps/docs/modules/track-changes.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/modules/track-changes.mdx b/apps/docs/modules/track-changes.mdx index d9cbb9f903..d61a2b8c43 100644 --- a/apps/docs/modules/track-changes.mdx +++ b/apps/docs/modules/track-changes.mdx @@ -436,7 +436,7 @@ superdoc.activeEditor.commands.toggleTrackChangesShowFinal(); Runnable example: mode switching, accept and reject, comments sidebar, DOCX import and export. From 6c9d009f8412a4bdbf2a5dbac2e8e81b5d4ef271 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 19:00:06 -0300 Subject: [PATCH 9/9] feat(dev-app): add tracked-replacements mode toggle (SD-2608) expose modules.trackChanges.replacements in the SuperDoc dev app so developers can test both 'paired' (Google Docs) and 'independent' (Word) revision models without editing code. - reads the initial value from ?replacements=independent in the URL so test links can pin a specific mode - switching via the header +