diff --git a/apps/docs/core/superdoc/configuration.mdx b/apps/docs/core/superdoc/configuration.mdx
index 6433165c19..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.
@@ -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,56 @@ 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.
+
+
+ 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.
+
+
+
+
+```javascript
+new SuperDoc({
+ selector: '#editor',
+ document: 'contract.docx',
+ documentMode: 'viewing',
+ modules: {
+ trackChanges: { visible: true, mode: 'review' },
+ },
+});
+```
+
+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: { replacements: 'independent' },
+ },
+});
+```
+
### Toolbar module
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 f86de2f950..0000000000
--- a/apps/docs/extensions/track-changes.mdx
+++ /dev/null
@@ -1,301 +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();
- },
-});
-```
-
-
-## 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/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/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..d61a2b8c43
--- /dev/null
+++ b/apps/docs/modules/track-changes.mdx
@@ -0,0 +1,443 @@
+---
+title: Track Changes
+keywords: "word track changes, document revisions, accept reject edits, docx tracked changes, review workflow, suggesting mode"
+---
+
+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
+
+```javascript
+const superdoc = new SuperDoc({
+ selector: "#editor",
+ document: "contract.docx",
+ documentMode: "suggesting", // record new edits as tracked changes
+ user: {
+ name: "John Smith",
+ email: "john@company.com",
+ },
+ modules: {
+ trackChanges: {
+ visible: true,
+ },
+ },
+ onCommentsUpdate: (payload) => {
+ if (payload.type === "trackedChange") {
+ console.log(payload.event, payload.trackedChangeType, payload.changeId);
+ }
+ },
+});
+```
+
+
+ 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 when `documentMode` is `viewing`.
+
+
+
+ Rendering mode.
+
+
+ - `'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. Turn off to render the document without any revision UI.
+
+
+
+ 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 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'`. Flip `modules.trackChanges.visible` to show it 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 prints a one-time console warning.
+
+
+## Revision model
+
+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 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 — the OOXML always emits one `` / `` per mark. The difference is how the API surfaces 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" },
+ },
+});
+```
+
+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
+
+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 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: result.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()` 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.
+
+
+
+
+`list()` accepts an optional query with `limit`, `offset`, and `type` (`'insert' | 'delete' | 'format'`) for pagination and filtering. See the full reference:
+
+- [`trackChanges.list`](/document-api/reference/track-changes/list)
+- [`trackChanges.get`](/document-api/reference/track-changes/get)
+- [`trackChanges.decide`](/document-api/reference/track-changes/decide)
+
+## Toggling tracked edits
+
+Control recording via document mode:
+
+```javascript
+superdoc.setDocumentMode("suggesting"); // record new edits as tracked changes
+superdoc.setDocumentMode("editing"); // stop recording
+superdoc.setDocumentMode("viewing"); // read-only
+```
+
+## Change types
+
+| Type | Mark | Visual |
+| --- | --- | --- |
+| 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 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. The top-level `type` field tells them apart; filter on `type === 'trackedChange'` and read the flat payload.
+
+```javascript
+onCommentsUpdate: (payload) => {
+ if (payload.type !== "trackedChange") return;
+
+ switch (payload.event) {
+ case "add":
+ // New tracked change created.
+ break;
+ case "update":
+ // Existing tracked change updated (e.g. reply added, author edited).
+ break;
+ case "change-accepted":
+ await markAccepted(payload.changeId);
+ break;
+ case "change-rejected":
+ await markRejected(payload.changeId);
+ break;
+ case "resolved":
+ // Tracked-change comment thread resolved.
+ break;
+ }
+};
+```
+
+### 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.
+
+
+ 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 with `trackedChangeType: 'both'`. To enumerate the current set of revisions, use `editor.doc.trackChanges.list()` — not the event stream.
+
+
+## Permissions
+
+Accept and reject permissions are 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 |
+| --- | --- |
+| `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 |
+
+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 through DOCX as native Word revisions. Import it. Edit it. Export it. Nothing lost.
+
+```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.
+superdoc.activeEditor.doc.trackChanges.decide({
+ decision: "accept",
+ target: { scope: "all" },
+});
+const cleanBlob = await superdoc.export();
+```
+
+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.
+
+
+ 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
+
+Temporarily render the document without applying revisions — useful for previewing the accepted or original state:
+
+```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
+
+
+ Runnable example: mode switching, accept and reject, comments sidebar, DOCX import and export.
+
diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts
index 45c3972614..61cfe81c35 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,
});
}
}
@@ -2687,6 +2688,7 @@ export class Editor extends EventEmitter {
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 2a0e4bfe1b..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
@@ -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
+ * `replacements` can be toggled per SuperDoc instance.
+ * @type {{ replacements?: 'paired' | 'independent' } | 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..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
@@ -152,7 +152,9 @@ export const createDocumentJson = (docx, converter, editor) => {
patchNumberingDefinitions(docx);
const numbering = getNumberingDefinitions(docx);
- converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx);
+ converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, {
+ replacements: converter.trackedChangesOptions?.replacements ?? 'paired',
+ });
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..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 }} 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.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,8 +111,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 `replacements`
+ // so nested changes honor the caller's choice if pairing ever applies.
+ walkElements(
+ element.elements,
+ idMap,
+ { lastTrackedChange: null, replacements: context.replacements },
+ /* insideTrackedChange */ true,
+ );
}
} else {
// Content-bearing elements break replacement pairing. Only non-content
@@ -128,23 +138,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 `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 `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.
*
* 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 {{ replacements?: TrackChangesReplacements }} [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 replacements = options.replacements === 'independent' ? 'independent' : 'paired';
const idMap = new Map();
- walkElements(body.elements, idMap, { lastTrackedChange: null });
+ 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 8bc6ce61d9..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
@@ -255,4 +255,39 @@ describe('buildTrackedChangeIdMap', () => {
expect(idMap.get('0')).toBe(idMap.get('1'));
});
+
+ 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(
+ trackedChange('w:del', '10', 'Alice', '2024-01-01T00:00:00Z'),
+ trackedChange('w:ins', '11', 'Alice', '2024-01-01T00:00:00Z'),
+ ),
+ );
+
+ const idMap = buildTrackedChangeIdMap(docx, { replacements: 'independent' });
+
+ 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, { replacements: 'independent' });
+
+ 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, { 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 97b75f527b..cbeffd870a 100644
--- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts
+++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts
@@ -365,6 +365,25 @@ 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;
+ /**
+ * 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 */
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..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,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 replacements='independent'", () => {
+ 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: { replacements: 'independent' },
+ },
+ 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: { replacements: 'independent' },
+ });
+
+ 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..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,6 +12,14 @@ import { CommentsPluginKey, createOrUpdateTrackedChangeComment } from '../commen
import { findMarkInRangeBySnapshot } from './trackChangesHelpers/markSnapshotHelpers.js';
import { hasExpandedSelection } from '@utils/selectionUtils.js';
+/**
+ * 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',
@@ -313,12 +321,27 @@ 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
+ // `'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 sharedId = id ?? (isReplacement ? uuidv4() : null);
+ const primaryId = id ?? uuidv4();
+ const insertionId = primaryId;
+ const deletionId = pairedReplacements || !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 +354,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 +378,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 +685,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 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/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/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..e1dab9f30e
--- /dev/null
+++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.js
@@ -0,0 +1,162 @@
+// @ts-check
+
+/**
+ * @typedef {'review' | 'original' | 'final' | 'off'} TrackChangesMode
+ * @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.
+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 (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 {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}
+ */
+function pickObject(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ return /** @type {Record} */ (value);
+}
+
+/**
+ * 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 {Record} config The SuperDoc config object (mutated in place)
+ * @returns {NormalizedTrackChangesConfig}
+ */
+export function normalizeTrackChangesConfig(config) {
+ const alreadyNormalized = /** @type {Record} */ (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);
+
+ // Replacement behavior is only surfaced on the canonical path. The legacy
+ // buckets never exposed this knob, so there's no alias to resolve.
+ 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
+ // 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);
+
+ /** @type {NormalizedTrackChangesConfig} */
+ 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.
+ 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..bbe41d35b2
--- /dev/null
+++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js
@@ -0,0 +1,353 @@
+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, replacements: 'paired' });
+ 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, replacements: 'paired' });
+ 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, replacements: 'paired' });
+ });
+ });
+
+ 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, replacements: 'paired' });
+ 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, replacements: 'paired' });
+ 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("replacements: 'paired' | 'independent'", () => {
+ it("defaults to 'paired' when not supplied", () => {
+ const result = normalizeTrackChangesConfig({});
+ expect(result.replacements).toBe('paired');
+ });
+
+ it("accepts replacements: 'independent' on the canonical path", () => {
+ const result = normalizeTrackChangesConfig({
+ modules: { trackChanges: { replacements: 'independent' } },
+ });
+ expect(result.replacements).toBe('independent');
+ });
+
+ it('mirrors the resolved replacements onto the canonical path write-through', () => {
+ const config = {
+ modules: { trackChanges: { replacements: 'independent' } },
+ };
+ normalizeTrackChangesConfig(config);
+ expect(config.modules.trackChanges.replacements).toBe('independent');
+ });
+
+ it("coerces invalid values to the default ('paired')", () => {
+ const result = normalizeTrackChangesConfig({
+ modules: { trackChanges: { replacements: 'whatever' } },
+ });
+ expect(result.replacements).toBe('paired');
+ });
+
+ it('is not derivable from any legacy key (no alias)', () => {
+ // Legacy keys never carried this knob — it stays at its default.
+ const result = normalizeTrackChangesConfig({
+ trackChanges: { visible: true },
+ layoutEngineOptions: { trackedChanges: { mode: 'original' } },
+ });
+ expect(result.replacements).toBe('paired');
+ });
+ });
+
+ 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, 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);
+ 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, 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 c9e81a8a94..2d9429668d 100644
--- a/packages/superdoc/src/core/types/index.js
+++ b/packages/superdoc/src/core/types/index.js
@@ -529,6 +529,24 @@
* @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
+ * @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.
*/
/**
@@ -637,7 +655,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 +679,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
diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue
index 448e4ba0b0..1054f013c1 100644
--- a/packages/superdoc/src/dev/components/SuperdocDev.vue
+++ b/packages/superdoc/src/dev/components/SuperdocDev.vue
@@ -44,6 +44,9 @@ const testUserName = urlParams.get('name') || `SuperDoc ${Math.floor(1000 + Math
const userRole = urlParams.get('role') || 'editor';
const useLayoutEngine = ref(urlParams.get('layout') !== '0');
const useWebLayout = ref(urlParams.get('view') === 'web');
+// Tracked-change replacement model. 'paired' groups ins+del into one change
+// (Google Docs model); 'independent' keeps each as its own revision (Word / ECMA-376).
+const trackChangesReplacements = ref(urlParams.get('replacements') === 'independent' ? 'independent' : 'paired');
const useCollaboration = urlParams.get('collab') === '1';
const collabRoom = urlParams.get('room') || 'superdoc-dev-room';
const collabUrl = 'ws://localhost:8081/v1/collaboration';
@@ -676,9 +679,6 @@ const init = async () => {
comments: {
visible: true,
},
- trackChanges: {
- visible: true,
- },
toolbarGroups: ['left', 'center', 'right'],
pagination: useLayoutEngine.value && !useWebLayout.value,
viewOptions: { layout: useWebLayout.value ? 'web' : 'print' },
@@ -721,6 +721,10 @@ const init = async () => {
// suppressInternalExternal: true,
permissionResolver: commentPermissionResolver,
},
+ trackChanges: {
+ visible: true,
+ replacements: trackChangesReplacements.value,
+ },
toolbar: {
selector: 'toolbar',
toolbarGroups: ['left', 'center', 'right'],
@@ -1207,6 +1211,21 @@ const toggleViewLayout = () => {
window.location.href = url.toString();
};
+// Switching replacement model requires SuperDoc to re-mount so the
+// importer and runtime both pick up the new mode. Reload with ?replacements=…
+// so the change is deep-linkable too.
+const setReplacementsMode = (mode) => {
+ if (mode !== 'paired' && mode !== 'independent') return;
+ if (mode === trackChangesReplacements.value) return;
+ const url = new URL(window.location.href);
+ if (mode === 'paired') {
+ url.searchParams.delete('replacements');
+ } else {
+ url.searchParams.set('replacements', mode);
+ }
+ window.location.href = url.toString();
+};
+
const currentZoom = ref(100);
const ZOOM_STEP = 10;
const ZOOM_MIN = 25;
@@ -1379,6 +1398,17 @@ if (scrollTestMode.value) {
+