Skip to content

Commit e92ef6a

Browse files
committed
feat(examples): vanilla custom UI tracked-changes example (SD-2929)
Third focused minimal example under examples/editor/custom-ui/. Single file (~140 lines) demonstrating the tracked-changes review panel that pairs naturally with the comments composer. Patterns on display: - ui.trackChanges.observe(snapshot => ...) drives the panel list AND the bulk-action enable state from one subscription. - ui.trackChanges.accept(id) / .reject(id) for per-change decisions; .acceptAll() / .rejectAll() for bulk; .next() / .previous() / .scrollTo(id) for navigation. The snapshot's activeId reflects whichever change is under the cursor. - ui.document.observe + setMode('editing' | 'suggesting') so the user can toggle between editing normally and recording each edit as a tracked change. - modules.trackChanges: { replacements: 'independent' } surfaces typed-over selections as separate insert + delete review items instead of one composite, matching what most review UIs render. - ui.createScope() collects every subscription so the whole surface tears down cleanly on ui.destroy(). Wired into examples/manifest.json and the custom-ui CI smoke matrix. Stacked on caio/sd-2929-vanilla-custom-ui-comments.
1 parent a448478 commit e92ef6a

12 files changed

Lines changed: 313 additions & 23 deletions

File tree

.github/workflows/ci-examples.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ jobs:
200200
strategy:
201201
fail-fast: false
202202
matrix:
203-
surface: [toolbar, comments]
203+
surface: [toolbar, comments, track-changes]
204204
framework: [vanilla]
205205
steps:
206206
- name: Restore workspace

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Build your own toolbar, comments sidebar, and review panel against the `superdoc
4141
|---------|------|
4242
| [toolbar/vanilla](./editor/custom-ui/toolbar/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/overview) |
4343
| [comments/vanilla](./editor/custom-ui/comments/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/comments) |
44+
| [track-changes/vanilla](./editor/custom-ui/track-changes/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/track-changes) |
4445

4546
### Theming
4647

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Custom UI: vanilla tracked changes
2+
3+
A custom SuperDoc tracked-changes review panel in plain TypeScript. Single file, no framework, copy-paste into your own app.
4+
5+
## What this teaches
6+
7+
- `ui.trackChanges.observe(snapshot => ...)` to render the review list from a single subscription.
8+
- `ui.trackChanges.accept(id)` / `.reject(id)` for per-change decisions.
9+
- `ui.trackChanges.acceptAll()` / `.rejectAll()` for bulk decisions.
10+
- `ui.trackChanges.next()` / `.previous()` / `.scrollTo(id)` for navigation, plus the live `activeId` so the panel highlights the change under the cursor.
11+
- `ui.document.observe` + `setMode('editing' | 'suggesting')` so the user can toggle between editing normally and recording tracked changes.
12+
- `ui.createScope()` for lifecycle, plus `ui.destroy()` cascading on tear-down.
13+
14+
## Run
15+
16+
```bash
17+
pnpm install
18+
pnpm dev
19+
```
20+
21+
Switch to **Suggest** mode and edit the document. Each insertion or deletion becomes a tracked change in the right-hand panel. Accept and reject decisions round-trip through Word.
22+
23+
The `predev` script builds the local `superdoc` workspace package so type imports resolve from `dist/`. From a published `npm` install this step is unnecessary.
24+
25+
## See also
26+
27+
- Docs: [Custom UI > Track changes](https://docs.superdoc.dev/editor/custom-ui/track-changes)
28+
- Sibling examples: [`toolbar/vanilla`](../../toolbar/vanilla), [`comments/vanilla`](../../comments/vanilla)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>SuperDoc Custom UI: vanilla tracked changes</title>
7+
</head>
8+
<body>
9+
<div class="app">
10+
<section class="editor-area">
11+
<div id="mode-toggle" class="mode"></div>
12+
<div id="editor"></div>
13+
</section>
14+
<aside class="sidebar">
15+
<div class="sidebar-head">
16+
<h2>Tracked changes</h2>
17+
<div class="bulk">
18+
<button id="prev" disabled>Prev</button>
19+
<button id="next" disabled>Next</button>
20+
<button id="reject-all" disabled>Reject all</button>
21+
<button id="accept-all" class="primary" disabled>Accept all</button>
22+
</div>
23+
</div>
24+
<ul id="changes" class="changes"></ul>
25+
</aside>
26+
</div>
27+
<script type="module" src="/src/main.ts"></script>
28+
</body>
29+
</html>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "@superdoc-examples/custom-ui-track-changes-vanilla",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"predev": "pnpm --filter superdoc build",
7+
"dev": "vite",
8+
"build": "tsc --noEmit && vite build",
9+
"preview": "vite preview"
10+
},
11+
"dependencies": {
12+
"superdoc": "workspace:*"
13+
},
14+
"devDependencies": {
15+
"typescript": "catalog:",
16+
"vite": "catalog:"
17+
}
18+
}
Binary file not shown.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Custom tracked-changes review panel (vanilla TypeScript), single file.
3+
*
4+
* Patterns to notice:
5+
*
6+
* - `ui.trackChanges.observe(snapshot => ...)` drives the panel
7+
* list AND the bulk-action enable state from one subscription.
8+
* The snapshot carries `items`, `total`, and `activeId`.
9+
* - `ui.trackChanges.accept(id) / .reject(id)` resolve one change.
10+
* `.acceptAll() / .rejectAll()` are the bulk verbs.
11+
* `.next() / .previous() / .scrollTo(id)` drive navigation;
12+
* `activeId` reflects whichever change is under the cursor.
13+
* - `ui.document.observe + setMode('editing' | 'suggesting')` lets
14+
* the user toggle between editing normally and recording each
15+
* edit as a tracked change. Without Suggest mode, the panel is
16+
* empty by design.
17+
* - `ui.createScope()` collects every subscription so the whole
18+
* surface tears down cleanly on `ui.destroy()`.
19+
*
20+
* `trackChanges.replacements: 'independent'` tells the engine to
21+
* surface a typed-over selection as two separate review items
22+
* (insert + delete) instead of one composite. Matches what most
23+
* review UIs want to render.
24+
*/
25+
26+
import { SuperDoc } from 'superdoc';
27+
import { createSuperDocUI, type TrackChangesSlice } from 'superdoc/ui';
28+
import 'superdoc/style.css';
29+
import './style.css';
30+
31+
const superdoc = new SuperDoc({
32+
selector: '#editor',
33+
document: '/test_file.docx',
34+
documentMode: 'suggesting',
35+
user: { name: 'Alex Rivera', email: 'alex@example.com' },
36+
modules: { trackChanges: { replacements: 'independent' } },
37+
});
38+
39+
const ui = createSuperDocUI({ superdoc });
40+
const scope = ui.createScope();
41+
42+
const modeEl = document.querySelector<HTMLElement>('#mode-toggle')!;
43+
const list = document.querySelector<HTMLUListElement>('#changes')!;
44+
const prevBtn = document.querySelector<HTMLButtonElement>('#prev')!;
45+
const nextBtn = document.querySelector<HTMLButtonElement>('#next')!;
46+
const acceptAllBtn = document.querySelector<HTMLButtonElement>('#accept-all')!;
47+
const rejectAllBtn = document.querySelector<HTMLButtonElement>('#reject-all')!;
48+
49+
// Edit / Suggest toggle. ui.document carries `mode`; setMode flips
50+
// the routed editor and fires the same `document-mode-change` event
51+
// the React wrapper consumes.
52+
modeEl.innerHTML = `
53+
<button data-mode="editing">Edit</button>
54+
<button data-mode="suggesting">Suggest</button>
55+
`;
56+
modeEl.addEventListener('click', (e) => {
57+
const t = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-mode]');
58+
if (!t) return;
59+
ui.document.setMode(t.dataset.mode as 'editing' | 'suggesting');
60+
});
61+
scope.add(
62+
ui.document.observe((doc) => {
63+
modeEl.querySelectorAll<HTMLButtonElement>('button[data-mode]').forEach((b) => {
64+
b.classList.toggle('active', b.dataset.mode === doc.mode);
65+
b.disabled = !doc.ready;
66+
});
67+
}),
68+
);
69+
70+
// Bulk action wiring.
71+
prevBtn.addEventListener('click', () => {
72+
const id = ui.trackChanges.previous();
73+
if (id) void ui.trackChanges.scrollTo(id);
74+
});
75+
nextBtn.addEventListener('click', () => {
76+
const id = ui.trackChanges.next();
77+
if (id) void ui.trackChanges.scrollTo(id);
78+
});
79+
acceptAllBtn.addEventListener('click', () => ui.trackChanges.acceptAll());
80+
rejectAllBtn.addEventListener('click', () => ui.trackChanges.rejectAll());
81+
82+
// One subscription drives the list AND the bulk-action enable state.
83+
scope.add(
84+
ui.trackChanges.observe((snapshot) => render(snapshot)),
85+
);
86+
87+
function render(snapshot: TrackChangesSlice): void {
88+
const empty = snapshot.items.length === 0;
89+
prevBtn.disabled = empty;
90+
nextBtn.disabled = empty;
91+
acceptAllBtn.disabled = empty;
92+
rejectAllBtn.disabled = empty;
93+
94+
list.innerHTML = '';
95+
if (empty) {
96+
const li = document.createElement('li');
97+
li.className = 'empty';
98+
li.textContent = 'No tracked changes. Switch to Suggest mode and edit the document.';
99+
list.appendChild(li);
100+
return;
101+
}
102+
103+
for (const { id, change } of snapshot.items) {
104+
const kind = change.type === 'insert' ? 'insertion' : change.type === 'delete' ? 'deletion' : 'format';
105+
const author = change.author ?? change.authorEmail ?? 'Unknown';
106+
const li = document.createElement('li');
107+
li.className = `card${id === snapshot.activeId ? ' active' : ''}`;
108+
li.dataset.id = id;
109+
li.innerHTML = `
110+
<span class="kind ${kind}">${kind}</span>
111+
<div class="author">${escape(author)}</div>
112+
${change.excerpt ? `<div class="excerpt">"${escape(change.excerpt)}"</div>` : ''}
113+
<div class="actions">
114+
<button data-action="accept" class="primary">Accept</button>
115+
<button data-action="reject" class="danger">Reject</button>
116+
</div>
117+
`;
118+
li.addEventListener('click', () => void ui.trackChanges.scrollTo(id));
119+
li.querySelector('[data-action="accept"]')?.addEventListener('click', (e) => {
120+
e.stopPropagation();
121+
ui.trackChanges.accept(id);
122+
});
123+
li.querySelector('[data-action="reject"]')?.addEventListener('click', (e) => {
124+
e.stopPropagation();
125+
ui.trackChanges.reject(id);
126+
});
127+
list.appendChild(li);
128+
}
129+
}
130+
131+
function escape(s: string): string {
132+
return s.replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[ch]!);
133+
}
134+
135+
const teardown = () => {
136+
ui.destroy();
137+
superdoc.destroy();
138+
};
139+
window.addEventListener('beforeunload', teardown);
140+
if (import.meta.hot) import.meta.hot.dispose(teardown);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
:root {
2+
--bg: #fff;
3+
--bg-muted: #f7f7f8;
4+
--border: #e4e4e7;
5+
--text: #18181b;
6+
--text-muted: #71717a;
7+
--accent: #2563eb;
8+
--accent-soft: #eff6ff;
9+
--insert: #16a34a;
10+
--insert-soft: #dcfce7;
11+
--delete: #dc2626;
12+
--delete-soft: #fee2e2;
13+
}
14+
15+
* { box-sizing: border-box; }
16+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; color: var(--text); background: var(--bg-muted); }
17+
button { font: inherit; cursor: pointer; }
18+
19+
.app { display: grid; grid-template-columns: 1fr 340px; height: 100vh; }
20+
.editor-area { overflow: auto; padding: 8px 12px; }
21+
.sidebar { background: var(--bg); border-left: 1px solid var(--border); overflow-y: auto; padding: 12px 16px; }
22+
23+
.mode { display: flex; gap: 4px; margin-bottom: 8px; }
24+
.mode button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
25+
.mode button.active { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
26+
.mode button:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; }
27+
28+
.sidebar-head { margin-bottom: 12px; }
29+
.sidebar-head h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 8px; }
30+
.bulk { display: flex; flex-wrap: wrap; gap: 4px; }
31+
.bulk button { padding: 4px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
32+
.bulk button.primary { border-color: var(--accent); color: var(--accent); }
33+
.bulk button:disabled { opacity: 0.5; cursor: not-allowed; }
34+
35+
.changes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
36+
.changes .empty { color: var(--text-muted); font-size: 12px; }
37+
.card { border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; background: var(--bg); cursor: pointer; }
38+
.card:hover { border-color: var(--accent); }
39+
.card.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
40+
41+
.kind { display: inline-block; font-size: 11px; padding: 2px 6px; border-radius: 3px; text-transform: capitalize; margin-bottom: 4px; }
42+
.kind.insertion { background: var(--insert-soft); color: var(--insert); }
43+
.kind.deletion { background: var(--delete-soft); color: var(--delete); }
44+
.kind.format { background: var(--bg-muted); color: var(--text-muted); }
45+
46+
.author { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
47+
.excerpt { border-left: 2px solid var(--accent); padding-left: 8px; font-style: italic; color: var(--text-muted); font-size: 12px; margin: 4px 0; }
48+
.actions { display: flex; gap: 6px; margin-top: 8px; }
49+
.actions button { padding: 4px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
50+
.actions button.primary { border-color: var(--insert); color: var(--insert); }
51+
.actions button.danger { border-color: var(--delete); color: var(--delete); }
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"module": "ESNext",
5+
"moduleResolution": "bundler",
6+
"strict": true,
7+
"esModuleInterop": true,
8+
"skipLibCheck": true,
9+
"forceConsistentCasingInFileNames": true,
10+
"resolveJsonModule": true,
11+
"isolatedModules": true,
12+
"noEmit": true,
13+
"types": ["vite/client"]
14+
},
15+
"include": ["src"]
16+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { defineConfig } from 'vite';
2+
3+
export default defineConfig({});

0 commit comments

Comments
 (0)