Skip to content

Commit 83013e4

Browse files
authored
feat: add per-author tracked change colors (#3559)
1 parent fc1480e commit 83013e4

42 files changed

Lines changed: 1101 additions & 61 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/editor/built-in-ui/track-changes.mdx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,46 @@ const superdoc = new SuperDoc({
7575
</Expandable>
7676
</ParamField>
7777

78+
<ParamField path="modules.trackChanges.authorColors" type="Object">
79+
Resolve one highlight color per tracked-change author. This replaces app-side CSS overrides like `[data-track-change-author]` selectors.
80+
81+
<Expandable title="Fields">
82+
<ParamField path="modules.trackChanges.authorColors.enabled" type="boolean" default="true">
83+
Set to `false` to keep the default insert, delete, and format colors.
84+
</ParamField>
85+
<ParamField path="modules.trackChanges.authorColors.overrides" type="Record<string, string>">
86+
Exact color overrides keyed by author email or author name. Email matches first, then name.
87+
</ParamField>
88+
<ParamField path="modules.trackChanges.authorColors.resolve" type="(author) => string | undefined">
89+
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.
90+
</ParamField>
91+
</Expandable>
92+
</ParamField>
93+
94+
```javascript
95+
new SuperDoc({
96+
selector: "#editor",
97+
document: "contract.docx",
98+
modules: {
99+
trackChanges: {
100+
visible: true,
101+
authorColors: {
102+
overrides: {
103+
"alice@example.com": "#1f6feb",
104+
"Bob Reviewer": "#d1242f",
105+
},
106+
resolve: (author) => {
107+
if (author.email?.endsWith("@outside-counsel.com")) return "#8250df";
108+
return undefined; // SuperDoc assigns a stable fallback color
109+
},
110+
},
111+
},
112+
},
113+
});
114+
```
115+
116+
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.
117+
78118
## Viewing mode visibility
79119
80120
Tracked-change markup is hidden by default when `documentMode` is `'viewing'`. Flip `modules.trackChanges.visible` to show it in read-only mode.

apps/docs/editor/custom-ui/track-changes.mdx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,25 @@ description: 'Build your own track-changes review panel. Accept, reject, navigat
1212
import { useSuperDocTrackChanges, useSuperDocUI } from 'superdoc/ui/react';
1313

1414
export function ReviewPanel() {
15-
const { items, total } = useSuperDocTrackChanges();
15+
const { items, total, authors } = useSuperDocTrackChanges();
1616
const ui = useSuperDocUI();
1717

1818
return (
1919
<aside>
2020
<h2>Track changes · {total}</h2>
21+
<div>
22+
{authors.map((author) => (
23+
<span key={`${author.email ?? ''}:${author.name ?? ''}`}>
24+
<span style={{ background: author.color }} />
25+
{author.name ?? author.email}
26+
</span>
27+
))}
28+
</div>
2129
{items.map((item) => (
2230
<ChangeRow
2331
key={item.id}
2432
change={item.change}
33+
authorColor={item.authorColor}
2534
onAccept={() => ui?.trackChanges.accept(item.id)}
2635
onReject={() => ui?.trackChanges.reject(item.id)}
2736
/>
@@ -31,7 +40,37 @@ export function ReviewPanel() {
3140
}
3241
```
3342

34-
`items` mirrors `editor.doc.trackChanges.list()`. Each item carries `id` plus the full `change` record (type, author, excerpt, address).
43+
`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.
44+
45+
## Per-author colors
46+
47+
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.
48+
49+
```tsx
50+
<SuperDocEditor
51+
document="/contract.docx"
52+
modules={{
53+
trackChanges: {
54+
visible: true,
55+
authorColors: {
56+
overrides: {
57+
'alice@example.com': '#1f6feb',
58+
'Bob Reviewer': '#d1242f',
59+
},
60+
resolve: (author) => {
61+
if (author.email?.endsWith('@outside-counsel.com')) return '#8250df';
62+
return undefined;
63+
},
64+
},
65+
},
66+
}}
67+
hideToolbar
68+
contained
69+
onReady={({ superdoc }) => setSuperDoc(superdoc)}
70+
/>
71+
```
72+
73+
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.
3574

3675
## Accept and reject
3776

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

115-
`TrackChangesItem` is `{ id, change }`. The `change` shape mirrors `editor.doc.trackChanges.list()`: `type`, `author`, `authorEmail`, `excerpt`, `address`, etc.
155+
`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`.
116156

117157
## Theming
118158

119-
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.
159+
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.
120160

121161
## Trade-offs
122162

apps/docs/editor/superdoc/configuration.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,20 @@ new SuperDoc({
234234
- `'paired'` (default, Google Docs model): the two halves share one id and resolve together with a single accept/reject click.
235235
- `'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.
236236
</ParamField>
237+
<ParamField path="modules.trackChanges.authorColors" type="Object">
238+
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.
239+
<Expandable title="properties">
240+
<ParamField path="modules.trackChanges.authorColors.enabled" type="boolean" default="true">
241+
Set to `false` to use the default tracked-change colors.
242+
</ParamField>
243+
<ParamField path="modules.trackChanges.authorColors.overrides" type="Record<string, string>">
244+
Exact color overrides keyed by author email or author name. Email matches first, then name.
245+
</ParamField>
246+
<ParamField path="modules.trackChanges.authorColors.resolve" type="(author) => string | undefined">
247+
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.
248+
</ParamField>
249+
</Expandable>
250+
</ParamField>
237251
</Expandable>
238252
</ParamField>
239253

@@ -248,6 +262,30 @@ new SuperDoc({
248262
});
249263
```
250264

265+
Assign per-author tracked-change colors without injecting CSS selectors:
266+
267+
```javascript
268+
new SuperDoc({
269+
selector: '#editor',
270+
document: 'contract.docx',
271+
modules: {
272+
trackChanges: {
273+
visible: true,
274+
authorColors: {
275+
overrides: {
276+
'alice@example.com': '#1f6feb',
277+
'Bob Reviewer': '#d1242f',
278+
},
279+
resolve: (author) => {
280+
if (author.email?.endsWith('@outside-counsel.com')) return '#8250df';
281+
return undefined; // SuperDoc assigns a stable fallback color
282+
},
283+
},
284+
},
285+
},
286+
});
287+
```
288+
251289
Opt into Microsoft Word / ECMA-376-style independent revisions, where each insertion and each deletion has its own id and resolves on its own:
252290
253291
```javascript
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { composeAuthorColorResolver, fallbackAuthorColor, stampTrackedChangeColors } from './author-colors.js';
3+
import type { FlowBlock, ParagraphBlock, TextRun } from './index.js';
4+
5+
describe('composeAuthorColorResolver', () => {
6+
it('returns undefined when config is missing or disabled', () => {
7+
expect(composeAuthorColorResolver(undefined)).toBeUndefined();
8+
expect(composeAuthorColorResolver(null)).toBeUndefined();
9+
expect(composeAuthorColorResolver({ enabled: false, overrides: { a: '#fff' } })).toBeUndefined();
10+
});
11+
12+
it('resolves overrides by email first, then name (exact match)', () => {
13+
const resolve = composeAuthorColorResolver({
14+
overrides: { 'a@x.test': '#111111', Alice: '#222222' },
15+
})!;
16+
expect(resolve({ email: 'a@x.test', name: 'Alice' })).toBe('#111111');
17+
expect(resolve({ name: 'Alice' })).toBe('#222222');
18+
});
19+
20+
it('falls through to resolve() when no override matches', () => {
21+
const resolve = composeAuthorColorResolver({
22+
overrides: { Bob: '#000000' },
23+
resolve: (author) => (author.name === 'Alice' ? '#abcabc' : undefined),
24+
})!;
25+
expect(resolve({ name: 'Alice' })).toBe('#abcabc');
26+
});
27+
28+
it('uses a deterministic fallback when overrides and resolve decline', () => {
29+
const resolve = composeAuthorColorResolver({ resolve: () => undefined })!;
30+
const first = resolve({ name: 'Discovered Author' });
31+
const second = resolve({ name: 'Discovered Author' });
32+
expect(first).toMatch(/^#[0-9a-f]{6}$/i);
33+
expect(first).toBe(second);
34+
expect(first).toBe(fallbackAuthorColor({ name: 'Discovered Author' }));
35+
});
36+
37+
it('does not throw when the host resolver throws', () => {
38+
const resolve = composeAuthorColorResolver({
39+
resolve: () => {
40+
throw new Error('boom');
41+
},
42+
})!;
43+
expect(resolve({ name: 'Alice' })).toMatch(/^#[0-9a-f]{6}$/i);
44+
});
45+
});
46+
47+
describe('stampTrackedChangeColors', () => {
48+
const makeParagraph = (run: TextRun): ParagraphBlock => ({
49+
kind: 'paragraph',
50+
id: 'p1',
51+
runs: [run],
52+
});
53+
54+
it('stamps color on every tracked-change layer from the author identity', () => {
55+
const run: TextRun = {
56+
kind: 'text',
57+
text: 'hi',
58+
fontFamily: 'Arial',
59+
fontSize: 12,
60+
trackedChanges: [
61+
{ kind: 'insert', id: 'tc1', author: 'Alice' },
62+
{ kind: 'format', id: 'tc2', author: 'Bob' },
63+
],
64+
};
65+
run.trackedChange = run.trackedChanges![0];
66+
67+
const blocks: FlowBlock[] = [makeParagraph(run)];
68+
stampTrackedChangeColors(blocks, composeAuthorColorResolver({ overrides: { Alice: '#123456', Bob: '#654321' } })!);
69+
70+
expect(run.trackedChanges![0]!.color).toBe('#123456');
71+
expect(run.trackedChanges![1]!.color).toBe('#654321');
72+
// trackedChange mirror (first layer) is colored too.
73+
expect(run.trackedChange!.color).toBe('#123456');
74+
});
75+
76+
it('clears stale colors when no resolver is provided', () => {
77+
const run: TextRun = {
78+
kind: 'text',
79+
text: 'hi',
80+
fontFamily: 'Arial',
81+
fontSize: 12,
82+
trackedChanges: [{ kind: 'insert', id: 'tc1', author: 'Alice', color: '#123456' }],
83+
};
84+
run.trackedChange = run.trackedChanges![0];
85+
86+
stampTrackedChangeColors([makeParagraph(run)], undefined);
87+
88+
expect(run.trackedChanges![0]!.color).toBeUndefined();
89+
expect(run.trackedChange!.color).toBeUndefined();
90+
});
91+
92+
it('leaves runs without tracked changes untouched', () => {
93+
const run: TextRun = { kind: 'text', text: 'plain', fontFamily: 'Arial', fontSize: 12 };
94+
stampTrackedChangeColors([makeParagraph(run)], composeAuthorColorResolver({ overrides: {} })!);
95+
expect((run as TextRun).color).toBeUndefined();
96+
});
97+
});

0 commit comments

Comments
 (0)