Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/docs/core/superdoc/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ new SuperDoc({
<ParamField path="modules.trackChanges.enabled" type="boolean" default="true">
Whether the layout engine treats tracked changes as active.
</ParamField>
<ParamField path="modules.trackChanges.pairReplacements" type="boolean" default="true">
When `true` (default), a tracked replacement (insertion paired with deletion) is resolved as a single change with one accept/reject action — closer to the Google Docs model. When `false`, each insertion and each deletion is an independent change with its own id, matching Microsoft Word and ECMA-376 §17.13.5.
</ParamField>
</Expandable>
</ParamField>

Expand All @@ -229,6 +232,18 @@ new SuperDoc({
});
```

Opt into Microsoft Word / ECMA-376-style independent revisions, where each insertion and each deletion has its own id and resolves on its own:

```javascript
new SuperDoc({
selector: '#editor',
document: 'contract.docx',
modules: {
trackChanges: { pairReplacements: false },
},
});
```

### Toolbar module

<ParamField path="modules.toolbar" type="Object">
Expand Down
1 change: 1 addition & 0 deletions packages/super-editor/src/editors/v1/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2093,6 +2093,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
mockWindow: this.options.mockWindow ?? null,
mockDocument: this.options.mockDocument ?? null,
isNewFile: this.options.isNewFile ?? false,
trackedChangesOptions: this.options.trackedChanges ?? null,
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ class SuperConverter {

this.fonts = params?.fonts || {};

/**
* Track-changes options forwarded from the editor. Consumed during
* import (e.g. by `buildTrackedChangeIdMap`) so behaviors like
* `pairReplacements` can be toggled per SuperDoc instance.
* @type {{ pairReplacements?: boolean } | null}
*/
this.trackedChangesOptions = params?.trackedChangesOptions || null;

this.addedMedia = {};
this.comments = [];
this.footnotes = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ export const createDocumentJson = (docx, converter, editor) => {

patchNumberingDefinitions(docx);
const numbering = getNumberingDefinitions(docx);
converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx);
converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, {
pairReplacements: converter.trackedChangesOptions?.pairReplacements !== false,
});
const comments = importCommentData({ docx, nodeListHandler, converter, editor });
const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering });
const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';

/**
* @typedef {{ type: string, author: string, date: string, internalId: string }} TrackedChangeEntry
* @typedef {{ lastTrackedChange: TrackedChangeEntry | null }} WalkContext
* @typedef {{ lastTrackedChange: TrackedChangeEntry | null, pairReplacements: boolean }} WalkContext
*/

const TRACKED_CHANGE_NAMES = new Set(['w:ins', 'w:del']);
Expand Down Expand Up @@ -70,7 +70,7 @@ function assignInternalId(element, idMap, context, insideTrackedChange) {
date: element.attributes?.['w:date'] ?? '',
};

if (context.lastTrackedChange && isReplacementPair(context.lastTrackedChange, current)) {
if (context.pairReplacements && context.lastTrackedChange && isReplacementPair(context.lastTrackedChange, current)) {
// Second half of a replacement — share the first half's UUID, but only
// if this w:id hasn't already been mapped. A reused id that was already
// part of an earlier pair must keep its original mapping.
Expand Down Expand Up @@ -128,23 +128,27 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) {
* Builds a map from OOXML `w:id` values to stable internal UUIDs by scanning
* `word/document.xml`.
*
* Word tracked replacements use separate `w:id` values for the delete and
* insert halves. This function detects adjacent opposite-type changes with
* matching author and date and maps both halves to the same internal UUID so
* the editor can resolve them as a single logical change.
* When `pairReplacements` is `true` (the default), Word tracked replacements
* are detected as adjacent opposite-type changes with matching author and
* date, and both halves map to the same internal UUID so the editor can
* resolve them as one logical change. When `pairReplacements` is `false`,
* each `w:id` maps to its own UUID — matching the ECMA-376 §17.13.5 model
* where every `<w:ins>` and `<w:del>` is an independent revision.
*
* Must run before comment import so all consumers — translators, comment
* helpers, and the tracked-change resolver — see a fully populated map.
*
* @param {object} docx Parsed DOCX package
* @param {{ pairReplacements?: boolean }} [options]
* @returns {Map<string, string>} Word `w:id` → internal UUID
*/
export function buildTrackedChangeIdMap(docx) {
export function buildTrackedChangeIdMap(docx, options = {}) {
const body = docx?.['word/document.xml']?.elements?.[0];
if (!body?.elements) return new Map();

const pairReplacements = options.pairReplacements !== false;
const idMap = new Map();
walkElements(body.elements, idMap, { lastTrackedChange: null });
walkElements(body.elements, idMap, { lastTrackedChange: null, pairReplacements });

return idMap;
}
Original file line number Diff line number Diff line change
Expand Up @@ -255,4 +255,39 @@ describe('buildTrackedChangeIdMap', () => {

expect(idMap.get('0')).toBe(idMap.get('1'));
});

describe('pairReplacements: false (unpaired mode)', () => {
it('keeps adjacent w:del + w:ins with matching author/date as independent ids', () => {
const docx = createDocx(
paragraph(
trackedChange('w:del', '10', 'Alice', '2024-01-01T00:00:00Z'),
trackedChange('w:ins', '11', 'Alice', '2024-01-01T00:00:00Z'),
),
);

const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false });

expect(idMap.size).toBe(2);
expect(idMap.get('10')).toBeTruthy();
expect(idMap.get('11')).toBeTruthy();
expect(idMap.get('10')).not.toBe(idMap.get('11'));
});

it('still maps each standalone tracked change to its own UUID', () => {
const docx = createDocx(paragraph(trackedChange('w:del', '1')), paragraph(trackedChange('w:ins', '2')));

const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false });

expect(idMap.size).toBe(2);
expect(idMap.get('1')).not.toBe(idMap.get('2'));
});

it('treats real Word replacement siblings as independent', () => {
const docx = createDocx(paragraph(wordDelete('0', 'test '), wordInsert('1', 'abc ')));

const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false });

expect(idMap.get('0')).not.toBe(idMap.get('1'));
});
});
});
13 changes: 13 additions & 0 deletions packages/super-editor/src/editors/v1/core/types/EditorConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,19 @@ export interface EditorOptions {
/** Comment highlight configuration */
comments?: CommentConfig;

/**
* Track-changes runtime configuration forwarded from the SuperDoc-level
* `modules.trackChanges` config. Read by the TrackChanges extension and
* by the SuperConverter during import. Fields are all optional; missing
* ones fall back to defaults resolved at SuperDoc construction time.
*/
trackedChanges?: {
visible?: boolean;
mode?: 'review' | 'original' | 'final' | 'off';
enabled?: boolean;
pairReplacements?: boolean;
};

/** Whether this is a new file */
isNewFile?: boolean;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1896,6 +1896,89 @@ describe('TrackChanges extension commands', () => {
expect(meta.insertedMark.attrs.id).toBe(meta.deletionMark.attrs.id);
});

it('gives each replacement mark its own ID when pairReplacements is false', () => {
const doc = createDoc('Hello world');
const state = createState(doc);

let dispatchedTr;
const dispatch = vi.fn((tr) => {
dispatchedTr = tr;
state.apply(tr);
});

commands.insertTrackedChange({
from: 7,
to: 12,
text: 'universe',
user: { name: 'Test', email: 'test@example.com' },
})({
state,
dispatch,
editor: {
options: {
user: { name: 'Default', email: 'default@example.com' },
trackedChanges: { pairReplacements: false },
},
commands: { addCommentReply: vi.fn() },
},
});

const meta = dispatchedTr.getMeta(TrackChangesBasePluginKey);
expect(meta.insertedMark).toBeDefined();
expect(meta.deletionMark).toBeDefined();
expect(meta.insertedMark.attrs.id).not.toBe(meta.deletionMark.attrs.id);
});

it('resolves only the targeted half of a replacement in unpaired mode', () => {
const { editor: interactionEditor } = initTestEditor({
mode: 'text',
content: '<p>Hello world</p>',
user: { name: 'Track Tester', email: 'track@example.com' },
trackedChanges: { pairReplacements: false },
});

try {
const worldRange = getSubstringRange(interactionEditor.state.doc, 'world');
interactionEditor.commands.insertTrackedChange({
from: worldRange.from,
to: worldRange.to,
text: 'universe',
});

// Gather both independent ids for the insertion and deletion halves.
const changes = [];
interactionEditor.state.doc.descendants((node) => {
node.marks.forEach((mark) => {
if (mark.type.name === TrackInsertMarkName || mark.type.name === TrackDeleteMarkName) {
changes.push({ type: mark.type.name, id: mark.attrs.id });
}
});
});
const insertion = changes.find((c) => c.type === TrackInsertMarkName);
const deletion = changes.find((c) => c.type === TrackDeleteMarkName);
expect(insertion).toBeDefined();
expect(deletion).toBeDefined();
expect(insertion.id).not.toBe(deletion.id);

// Accepting the insertion must not touch the deletion side.
interactionEditor.commands.acceptTrackedChangeById(insertion.id);
expect(getMarkedText(interactionEditor.state.doc, TrackInsertMarkName)).toBe('');
expect(getMarkedText(interactionEditor.state.doc, TrackDeleteMarkName)).toBe('world');

// The deletion is still independently resolvable by its own id.
// Rejecting the deletion keeps the original text (unmarking it);
// the previously accepted insertion stays. Both words coexist in
// the final doc, which is the point of treating them as
// independent revisions.
interactionEditor.commands.rejectTrackedChangeById(deletion.id);
expect(getMarkedText(interactionEditor.state.doc, TrackDeleteMarkName)).toBe('');
expect(interactionEditor.state.doc.textContent).toContain('universe');
expect(interactionEditor.state.doc.textContent).toContain('world');
} finally {
interactionEditor.destroy();
}
});

it('attaches comment to replacement using shared ID', () => {
const doc = createDoc('Hello world');
const state = createState(doc);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { CommentsPluginKey, createOrUpdateTrackedChangeComment } from '../commen
import { findMarkInRangeBySnapshot } from './trackChangesHelpers/markSnapshotHelpers.js';
import { hasExpandedSelection } from '@utils/selectionUtils.js';

const readPairReplacements = (editor) => editor?.options?.trackedChanges?.pairReplacements !== false;

export const TrackChanges = Extension.create({
name: 'trackChanges',

Expand Down Expand Up @@ -313,12 +315,26 @@ export const TrackChanges = Extension.create({
// Get marks from original position BEFORE any changes for format preservation
const marks = state.doc.resolve(from).marks();

// For replacements (both deletion and insertion), generate a shared ID upfront
// so the deletion and insertion marks are linked together
// id-minting strategy for a tracked insert/delete/replace:
// - One `primaryId` anchors the operation. When the caller supplies
// `id` (e.g. the Document API write adapter), that becomes the
// primary; otherwise we mint a fresh UUID.
// - The primary id is used for the insertion (pure insert) or the
// lone deletion (pure delete), and always as the `changeId` we
// report back — comment threads key off this id too.
// - For a replacement: in paired mode both halves share the
// primary id (Google-Docs-like one-click resolve). In unpaired
// mode (modules.trackChanges.pairReplacements: false), the
// insertion keeps the primary id and the deletion mints its own
// fresh id via markDeletion, so each revision is independently
// addressable per ECMA-376 §17.13.5.
const pairReplacements = readPairReplacements(editor);
const isReplacement = from !== to && text;
const sharedId = id ?? (isReplacement ? uuidv4() : null);
const primaryId = id ?? uuidv4();
const insertionId = primaryId;
const deletionId = pairReplacements || !isReplacement ? primaryId : null;

let changeId = sharedId;
const changeId = primaryId;
let insertPos = to; // Default insert position is after the selection
let deletionMark = null;
let deletionNodes = [];
Expand All @@ -331,13 +347,10 @@ export const TrackChanges = Extension.create({
to,
user: resolvedUser,
date,
id: sharedId,
id: deletionId,
});
deletionMark = result.deletionMark;
deletionNodes = result.nodes || [];
if (!changeId) {
changeId = deletionMark.attrs.id;
}
// Map the insert position through the deletion mapping
insertPos = result.deletionMap.map(to);
}
Expand All @@ -358,12 +371,8 @@ export const TrackChanges = Extension.create({
to: insertedTo,
user: resolvedUser,
date,
id: sharedId,
id: insertionId,
});

if (!changeId) {
changeId = insertedMark.attrs.id;
}
}

// Store metadata for external consumers (pass full mark objects for comments plugin)
Expand Down Expand Up @@ -669,6 +678,14 @@ const getChangesByIdToResolve = (state, id) => {
const matchingChange = trackedChanges[changeIndex];
const matchingId = matchingChange.mark.attrs.id;

// The neighbor walk collects every adjacent segment that shares the same id.
// This catches:
// - A single logical mark split across multiple segments (e.g. because
// surrounding text marks differ) — always correct to resolve together.
// - The paired opposite-type mark when pairReplacements is true (shared id).
// In unpaired mode, the ins/del halves have distinct ids so the walk stops
// at the revision boundary naturally — no special casing needed here.

const linkedBefore = [];
const linkedAfter = [];

Expand Down
1 change: 1 addition & 0 deletions packages/superdoc/src/SuperDoc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/**
* @typedef {'review' | 'original' | 'final' | 'off'} TrackChangesMode
* @typedef {{ visible: boolean, mode: TrackChangesMode, enabled: boolean }} NormalizedTrackChangesConfig
* @typedef {{ visible: boolean, mode: TrackChangesMode, enabled: boolean, pairReplacements: boolean }} NormalizedTrackChangesConfig
*/

const ALLOWED_MODES = /** @type {const} */ (['review', 'original', 'final', 'off']);
Expand Down Expand Up @@ -77,6 +77,10 @@ export function normalizeTrackChangesConfig(config) {

const enabled = resolveBool(fromCanonical?.enabled, fromLegacyLayout?.enabled, true);

// Replacement pairing is only surfaced on the canonical path. The legacy
// buckets never exposed this knob, so there's no alias to resolve.
const pairReplacements = resolveBool(fromCanonical?.pairReplacements, undefined, true);

// Default mode derives from documentMode + visibility so a viewing-mode
// document without an explicit mode falls back to 'original' unless the
// consumer asked for tracked changes to be visible.
Expand All @@ -85,7 +89,7 @@ export function normalizeTrackChangesConfig(config) {
const mode = resolveMode(fromCanonical?.mode, fromLegacyLayout?.mode, defaultMode);

/** @type {NormalizedTrackChangesConfig} */
const normalized = { visible, mode, enabled };
const normalized = { visible, mode, enabled, pairReplacements };

// Write-through to every path so all existing internal reads see the same
// resolved values without needing to migrate each call site in this pass.
Expand Down
Loading
Loading