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) { +