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
40 changes: 40 additions & 0 deletions apps/docs/editor/built-in-ui/track-changes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,46 @@ const superdoc = new SuperDoc({
</Expandable>
</ParamField>

<ParamField path="modules.trackChanges.authorColors" type="Object">
Resolve one highlight color per tracked-change author. This replaces app-side CSS overrides like `[data-track-change-author]` selectors.

<Expandable title="Fields">
<ParamField path="modules.trackChanges.authorColors.enabled" type="boolean" default="true">
Set to `false` to keep the default insert, delete, and format colors.
</ParamField>
<ParamField path="modules.trackChanges.authorColors.overrides" type="Record<string, string>">
Exact color overrides keyed by author email or author name. Email matches first, then name.
</ParamField>
<ParamField path="modules.trackChanges.authorColors.resolve" type="(author) => string | undefined">
Callback for authors not covered by `overrides`. Receives `{ name, email, image }`. Return any CSS color string, or `undefined` to use SuperDoc's deterministic fallback color.
</ParamField>
</Expandable>
</ParamField>

```javascript
new SuperDoc({
selector: "#editor",
document: "contract.docx",
modules: {
trackChanges: {
visible: true,
authorColors: {
overrides: {
"alice@example.com": "#1f6feb",
"Bob Reviewer": "#d1242f",
},
resolve: (author) => {
if (author.email?.endsWith("@outside-counsel.com")) return "#8250df";
return undefined; // SuperDoc assigns a stable fallback color
},
},
},
},
});
```

Per-author colors apply to insertion, deletion, and format-change highlights. SuperDoc derives lighter background variants from the same author color and exposes the resolved colors through the custom UI snapshot.

## 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.
Expand Down
48 changes: 44 additions & 4 deletions apps/docs/editor/custom-ui/track-changes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,25 @@ description: 'Build your own track-changes review panel. Accept, reject, navigat
import { useSuperDocTrackChanges, useSuperDocUI } from 'superdoc/ui/react';

export function ReviewPanel() {
const { items, total } = useSuperDocTrackChanges();
const { items, total, authors } = useSuperDocTrackChanges();
const ui = useSuperDocUI();

return (
<aside>
<h2>Track changes · {total}</h2>
<div>
{authors.map((author) => (
<span key={`${author.email ?? ''}:${author.name ?? ''}`}>
<span style={{ background: author.color }} />
{author.name ?? author.email}
</span>
))}
</div>
{items.map((item) => (
<ChangeRow
key={item.id}
change={item.change}
authorColor={item.authorColor}
onAccept={() => ui?.trackChanges.accept(item.id)}
onReject={() => ui?.trackChanges.reject(item.id)}
/>
Expand All @@ -31,7 +40,37 @@ export function ReviewPanel() {
}
```

`items` mirrors `editor.doc.trackChanges.list()`. Each item carries `id` plus the full `change` record (type, author, excerpt, address).
`items` mirrors `editor.doc.trackChanges.list()`. Each item carries `id` plus the full `change` record (type, author, excerpt, address). If you configure `modules.trackChanges.authorColors`, each item also exposes `authorColor`, and `authors` contains the unique authors in document order with their resolved colors.

## Per-author colors

Use `modules.trackChanges.authorColors` when your review UI needs a stable legend or when imported DOCX files can contain authors your app did not know ahead of time.

```tsx
<SuperDocEditor
document="/contract.docx"
modules={{
trackChanges: {
visible: true,
authorColors: {
overrides: {
'alice@example.com': '#1f6feb',
'Bob Reviewer': '#d1242f',
},
resolve: (author) => {
if (author.email?.endsWith('@outside-counsel.com')) return '#8250df';
return undefined;
},
},
},
}}
hideToolbar
contained
onReady={({ superdoc }) => setSuperDoc(superdoc)}
/>
```

SuperDoc uses the same resolver for the rendered document and the UI snapshot. That keeps custom cards, author legends, and document highlights in sync without app-side CSS selectors.

## Accept and reject

Expand Down Expand Up @@ -111,12 +150,13 @@ function ActiveAwareList() {
| `items` | `TrackChangesItem[]` | Tracked changes in document order. |
| `total` | `number` | Convenience count of `items.length`. |
| `activeId` | `string \| null` | Active change driven by selection or by `next / previous / scrollTo`. |
| `authors` | `TrackChangesAuthor[]` | Unique tracked-change authors in document order. Present with resolved `color` values when `modules.trackChanges.authorColors` is configured. |

`TrackChangesItem` is `{ id, change }`. The `change` shape mirrors `editor.doc.trackChanges.list()`: `type`, `author`, `authorEmail`, `excerpt`, `address`, etc.
`TrackChangesItem` is `{ id, change, authorColor? }`. The `change` shape mirrors `editor.doc.trackChanges.list()`: `type`, `author`, `authorEmail`, `excerpt`, `address`, etc. When author colors are configured, `change.authorColor` mirrors `item.authorColor`.

## Theming

Insertion and deletion highlights are themable via `--sd-tracked-changes-*` CSS variables (`--sd-tracked-changes-insert-background`, `--sd-tracked-changes-delete-border`, etc.). See [Theming overview](/editor/theming/overview) and [Custom themes](/editor/theming/custom-themes) for the full token list.
Insertion, deletion, and format-change highlights are themable via `--sd-tracked-changes-*` CSS variables (`--sd-tracked-changes-insert-background`, `--sd-tracked-changes-delete-border`, etc.). Per-author colors set those variables on the rendered tracked-change element. See [Theming overview](/editor/theming/overview) and [Custom themes](/editor/theming/custom-themes) for the full token list.

## Trade-offs

Expand Down
38 changes: 38 additions & 0 deletions apps/docs/editor/superdoc/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,20 @@ new SuperDoc({
- `'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.
</ParamField>
<ParamField path="modules.trackChanges.authorColors" type="Object">
Resolve one tracked-change highlight color per author. Use this when imported DOCX files contain reviewers your app does not know ahead of time, or when you want a stable author legend in a custom review UI.
<Expandable title="properties">
<ParamField path="modules.trackChanges.authorColors.enabled" type="boolean" default="true">
Set to `false` to use the default tracked-change colors.
</ParamField>
<ParamField path="modules.trackChanges.authorColors.overrides" type="Record<string, string>">
Exact color overrides keyed by author email or author name. Email matches first, then name.
</ParamField>
<ParamField path="modules.trackChanges.authorColors.resolve" type="(author) => string | undefined">
Callback for authors not covered by `overrides`. Receives `{ name, email, image }`. Return any CSS color string, or `undefined` to use SuperDoc's deterministic fallback color.
</ParamField>
</Expandable>
</ParamField>
</Expandable>
</ParamField>

Expand All @@ -248,6 +262,30 @@ new SuperDoc({
});
```

Assign per-author tracked-change colors without injecting CSS selectors:

```javascript
new SuperDoc({
selector: '#editor',
document: 'contract.docx',
modules: {
trackChanges: {
visible: true,
authorColors: {
overrides: {
'alice@example.com': '#1f6feb',
'Bob Reviewer': '#d1242f',
},
resolve: (author) => {
if (author.email?.endsWith('@outside-counsel.com')) return '#8250df';
return undefined; // SuperDoc assigns a stable fallback color
},
},
},
},
});
```

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
Expand Down
97 changes: 97 additions & 0 deletions packages/layout-engine/contracts/src/author-colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { composeAuthorColorResolver, fallbackAuthorColor, stampTrackedChangeColors } from './author-colors.js';
import type { FlowBlock, ParagraphBlock, TextRun } from './index.js';

describe('composeAuthorColorResolver', () => {
it('returns undefined when config is missing or disabled', () => {
expect(composeAuthorColorResolver(undefined)).toBeUndefined();
expect(composeAuthorColorResolver(null)).toBeUndefined();
expect(composeAuthorColorResolver({ enabled: false, overrides: { a: '#fff' } })).toBeUndefined();
});

it('resolves overrides by email first, then name (exact match)', () => {
const resolve = composeAuthorColorResolver({
overrides: { 'a@x.test': '#111111', Alice: '#222222' },
})!;
expect(resolve({ email: 'a@x.test', name: 'Alice' })).toBe('#111111');
expect(resolve({ name: 'Alice' })).toBe('#222222');
});

it('falls through to resolve() when no override matches', () => {
const resolve = composeAuthorColorResolver({
overrides: { Bob: '#000000' },
resolve: (author) => (author.name === 'Alice' ? '#abcabc' : undefined),
})!;
expect(resolve({ name: 'Alice' })).toBe('#abcabc');
});

it('uses a deterministic fallback when overrides and resolve decline', () => {
const resolve = composeAuthorColorResolver({ resolve: () => undefined })!;
const first = resolve({ name: 'Discovered Author' });
const second = resolve({ name: 'Discovered Author' });
expect(first).toMatch(/^#[0-9a-f]{6}$/i);
expect(first).toBe(second);
expect(first).toBe(fallbackAuthorColor({ name: 'Discovered Author' }));
});

it('does not throw when the host resolver throws', () => {
const resolve = composeAuthorColorResolver({
resolve: () => {
throw new Error('boom');
},
})!;
expect(resolve({ name: 'Alice' })).toMatch(/^#[0-9a-f]{6}$/i);
});
});

describe('stampTrackedChangeColors', () => {
const makeParagraph = (run: TextRun): ParagraphBlock => ({
kind: 'paragraph',
id: 'p1',
runs: [run],
});

it('stamps color on every tracked-change layer from the author identity', () => {
const run: TextRun = {
kind: 'text',
text: 'hi',
fontFamily: 'Arial',
fontSize: 12,
trackedChanges: [
{ kind: 'insert', id: 'tc1', author: 'Alice' },
{ kind: 'format', id: 'tc2', author: 'Bob' },
],
};
run.trackedChange = run.trackedChanges![0];

const blocks: FlowBlock[] = [makeParagraph(run)];
stampTrackedChangeColors(blocks, composeAuthorColorResolver({ overrides: { Alice: '#123456', Bob: '#654321' } })!);

expect(run.trackedChanges![0]!.color).toBe('#123456');
expect(run.trackedChanges![1]!.color).toBe('#654321');
// trackedChange mirror (first layer) is colored too.
expect(run.trackedChange!.color).toBe('#123456');
});

it('clears stale colors when no resolver is provided', () => {
const run: TextRun = {
kind: 'text',
text: 'hi',
fontFamily: 'Arial',
fontSize: 12,
trackedChanges: [{ kind: 'insert', id: 'tc1', author: 'Alice', color: '#123456' }],
};
run.trackedChange = run.trackedChanges![0];

stampTrackedChangeColors([makeParagraph(run)], undefined);

expect(run.trackedChanges![0]!.color).toBeUndefined();
expect(run.trackedChange!.color).toBeUndefined();
});

it('leaves runs without tracked changes untouched', () => {
const run: TextRun = { kind: 'text', text: 'plain', fontFamily: 'Arial', fontSize: 12 };
stampTrackedChangeColors([makeParagraph(run)], composeAuthorColorResolver({ overrides: {} })!);
expect((run as TextRun).color).toBeUndefined();
});
});
Loading
Loading