Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/ci-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ jobs:
strategy:
fail-fast: false
matrix:
surface: [toolbar, comments]
surface: [toolbar, comments, track-changes]
framework: [vanilla]
steps:
- name: Restore workspace
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Build your own toolbar, comments sidebar, and review panel against the `superdoc
|---------|------|
| [toolbar/vanilla](./editor/custom-ui/toolbar/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/overview) |
| [comments/vanilla](./editor/custom-ui/comments/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/comments) |
| [track-changes/vanilla](./editor/custom-ui/track-changes/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/track-changes) |

### Theming

Expand Down
28 changes: 28 additions & 0 deletions examples/editor/custom-ui/track-changes/vanilla/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Custom UI: vanilla tracked changes

A custom SuperDoc tracked-changes review panel in plain TypeScript. Single file, no framework, copy-paste into your own app.

## What this teaches

- `ui.trackChanges.observe(snapshot => ...)` to render the review list from a single subscription.
- `ui.trackChanges.accept(id)` / `.reject(id)` for per-change decisions.
- `ui.trackChanges.acceptAll()` / `.rejectAll()` for bulk decisions.
- `ui.trackChanges.next()` / `.previous()` / `.scrollTo(id)` for navigation, plus the live `activeId` so the panel highlights the change under the cursor.
- `ui.document.observe` + `setMode('editing' | 'suggesting')` so the user can toggle between editing normally and recording tracked changes.
- `ui.createScope()` for lifecycle, plus `ui.destroy()` cascading on tear-down.

## Run

```bash
pnpm install
pnpm dev
```

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.

The `predev` script builds the local `superdoc` workspace package so type imports resolve from `dist/`. From a published `npm` install this step is unnecessary.

## See also

- Docs: [Custom UI > Track changes](https://docs.superdoc.dev/editor/custom-ui/track-changes)
- Sibling examples: [`toolbar/vanilla`](../../toolbar/vanilla), [`comments/vanilla`](../../comments/vanilla)
29 changes: 29 additions & 0 deletions examples/editor/custom-ui/track-changes/vanilla/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SuperDoc Custom UI: vanilla tracked changes</title>
</head>
<body>
<div class="app">
<section class="editor-area">
<div id="mode-toggle" class="mode"></div>
<div id="editor"></div>
</section>
<aside class="sidebar">
<div class="sidebar-head">
<h2>Tracked changes</h2>
<div class="bulk">
<button id="prev" disabled>Prev</button>
<button id="next" disabled>Next</button>
<button id="reject-all" disabled>Reject all</button>
<button id="accept-all" class="primary" disabled>Accept all</button>
</div>
</div>
<ul id="changes" class="changes"></ul>
</aside>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions examples/editor/custom-ui/track-changes/vanilla/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@superdoc-examples/custom-ui-track-changes-vanilla",
"private": true,
"type": "module",
"scripts": {
"predev": "pnpm --filter superdoc build",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"superdoc": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:",
"vite": "catalog:"
}
}
Binary file not shown.
140 changes: 140 additions & 0 deletions examples/editor/custom-ui/track-changes/vanilla/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Custom tracked-changes review panel (vanilla TypeScript), single file.
*
* Patterns to notice:
*
* - `ui.trackChanges.observe(snapshot => ...)` drives the panel
* list AND the bulk-action enable state from one subscription.
* The snapshot carries `items`, `total`, and `activeId`.
* - `ui.trackChanges.accept(id) / .reject(id)` resolve one change.
* `.acceptAll() / .rejectAll()` are the bulk verbs.
* `.next() / .previous() / .scrollTo(id)` drive navigation;
* `activeId` reflects whichever change is under the cursor.
* - `ui.document.observe + setMode('editing' | 'suggesting')` lets
* the user toggle between editing normally and recording each
* edit as a tracked change. Without Suggest mode, the panel is
* empty by design.
* - `ui.createScope()` collects every subscription so the whole
* surface tears down cleanly on `ui.destroy()`.
*
* `trackChanges.replacements: 'independent'` tells the engine to
* surface a typed-over selection as two separate review items
* (insert + delete) instead of one composite. Matches what most
* review UIs want to render.
*/

import { SuperDoc } from 'superdoc';
import { createSuperDocUI, type TrackChangesSlice } from 'superdoc/ui';
import 'superdoc/style.css';
import './style.css';

const superdoc = new SuperDoc({
selector: '#editor',
document: '/test_file.docx',
documentMode: 'suggesting',
user: { name: 'Alex Rivera', email: 'alex@example.com' },
modules: { trackChanges: { replacements: 'independent' } },
});

const ui = createSuperDocUI({ superdoc });
const scope = ui.createScope();

const modeEl = document.querySelector<HTMLElement>('#mode-toggle')!;
const list = document.querySelector<HTMLUListElement>('#changes')!;
const prevBtn = document.querySelector<HTMLButtonElement>('#prev')!;
const nextBtn = document.querySelector<HTMLButtonElement>('#next')!;
const acceptAllBtn = document.querySelector<HTMLButtonElement>('#accept-all')!;
const rejectAllBtn = document.querySelector<HTMLButtonElement>('#reject-all')!;

// Edit / Suggest toggle. ui.document carries `mode`; setMode flips
// the routed editor and fires the same `document-mode-change` event
// the React wrapper consumes.
modeEl.innerHTML = `
<button data-mode="editing">Edit</button>
<button data-mode="suggesting">Suggest</button>
`;
modeEl.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-mode]');
if (!t) return;
ui.document.setMode(t.dataset.mode as 'editing' | 'suggesting');
});
scope.add(
ui.document.observe((doc) => {
modeEl.querySelectorAll<HTMLButtonElement>('button[data-mode]').forEach((b) => {
b.classList.toggle('active', b.dataset.mode === doc.mode);
b.disabled = !doc.ready;
});
}),
);

// Bulk action wiring.
prevBtn.addEventListener('click', () => {
const id = ui.trackChanges.previous();
if (id) void ui.trackChanges.scrollTo(id);
});
nextBtn.addEventListener('click', () => {
const id = ui.trackChanges.next();
if (id) void ui.trackChanges.scrollTo(id);
});
acceptAllBtn.addEventListener('click', () => ui.trackChanges.acceptAll());
rejectAllBtn.addEventListener('click', () => ui.trackChanges.rejectAll());

// One subscription drives the list AND the bulk-action enable state.
scope.add(
ui.trackChanges.observe((snapshot) => render(snapshot)),
);

function render(snapshot: TrackChangesSlice): void {
const empty = snapshot.items.length === 0;
prevBtn.disabled = empty;
nextBtn.disabled = empty;
acceptAllBtn.disabled = empty;
rejectAllBtn.disabled = empty;

list.innerHTML = '';
if (empty) {
const li = document.createElement('li');
li.className = 'empty';
li.textContent = 'No tracked changes. Switch to Suggest mode and edit the document.';
list.appendChild(li);
return;
}

for (const { id, change } of snapshot.items) {
const kind = change.type === 'insert' ? 'insertion' : change.type === 'delete' ? 'deletion' : 'format';
const author = change.author ?? change.authorEmail ?? 'Unknown';
const li = document.createElement('li');
li.className = `card${id === snapshot.activeId ? ' active' : ''}`;
li.dataset.id = id;
li.innerHTML = `
<span class="kind ${kind}">${kind}</span>
<div class="author">${escape(author)}</div>
${change.excerpt ? `<div class="excerpt">"${escape(change.excerpt)}"</div>` : ''}
<div class="actions">
<button data-action="accept" class="primary">Accept</button>
<button data-action="reject" class="danger">Reject</button>
</div>
`;
li.addEventListener('click', () => void ui.trackChanges.scrollTo(id));
li.querySelector('[data-action="accept"]')?.addEventListener('click', (e) => {
e.stopPropagation();
ui.trackChanges.accept(id);
});
li.querySelector('[data-action="reject"]')?.addEventListener('click', (e) => {
e.stopPropagation();
ui.trackChanges.reject(id);
});
list.appendChild(li);
}
}

function escape(s: string): string {
return s.replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[ch]!);
}

const teardown = () => {
ui.destroy();
superdoc.destroy();
};
window.addEventListener('beforeunload', teardown);
if (import.meta.hot) import.meta.hot.dispose(teardown);
51 changes: 51 additions & 0 deletions examples/editor/custom-ui/track-changes/vanilla/src/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
:root {
--bg: #fff;
--bg-muted: #f7f7f8;
--border: #e4e4e7;
--text: #18181b;
--text-muted: #71717a;
--accent: #2563eb;
--accent-soft: #eff6ff;
--insert: #16a34a;
--insert-soft: #dcfce7;
--delete: #dc2626;
--delete-soft: #fee2e2;
}

* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; color: var(--text); background: var(--bg-muted); }
button { font: inherit; cursor: pointer; }

.app { display: grid; grid-template-columns: 1fr 340px; height: 100vh; }
.editor-area { overflow: auto; padding: 8px 12px; }
.sidebar { background: var(--bg); border-left: 1px solid var(--border); overflow-y: auto; padding: 12px 16px; }

.mode { display: flex; gap: 4px; margin-bottom: 8px; }
.mode button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
.mode button.active { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.mode button:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; }

.sidebar-head { margin-bottom: 12px; }
.sidebar-head h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 8px; }
.bulk { display: flex; flex-wrap: wrap; gap: 4px; }
.bulk button { padding: 4px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
.bulk button.primary { border-color: var(--accent); color: var(--accent); }
.bulk button:disabled { opacity: 0.5; cursor: not-allowed; }

.changes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.changes .empty { color: var(--text-muted); font-size: 12px; }
.card { border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; background: var(--bg); cursor: pointer; }
.card:hover { border-color: var(--accent); }
.card.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }

.kind { display: inline-block; font-size: 11px; padding: 2px 6px; border-radius: 3px; text-transform: capitalize; margin-bottom: 4px; }
.kind.insertion { background: var(--insert-soft); color: var(--insert); }
.kind.deletion { background: var(--delete-soft); color: var(--delete); }
.kind.format { background: var(--bg-muted); color: var(--text-muted); }

.author { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.excerpt { border-left: 2px solid var(--accent); padding-left: 8px; font-style: italic; color: var(--text-muted); font-size: 12px; margin: 4px 0; }
.actions { display: flex; gap: 6px; margin-top: 8px; }
.actions button { padding: 4px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
.actions button.primary { border-color: var(--insert); color: var(--insert); }
.actions button.danger { border-color: var(--delete); color: var(--delete); }
16 changes: 16 additions & 0 deletions examples/editor/custom-ui/track-changes/vanilla/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'vite';

export default defineConfig({});
10 changes: 10 additions & 0 deletions examples/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@
"docs": "https://docs.superdoc.dev/editor/custom-ui/comments",
"ci": true
},
{
"id": "editor-custom-ui-track-changes-vanilla",
"title": "Custom tracked-changes panel (vanilla)",
"category": "Editor",
"surface": "Custom UI",
"sourceRepo": "superdoc-dev/superdoc",
"sourcePath": "examples/editor/custom-ui/track-changes/vanilla",
"docs": "https://docs.superdoc.dev/editor/custom-ui/track-changes",
"ci": true
},
{
"id": "editor-spell-check-typo-js",
"title": "Spell check with Typo.js",
Expand Down
38 changes: 16 additions & 22 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading