diff --git a/.github/workflows/ci-examples.yml b/.github/workflows/ci-examples.yml index 3c94c34a9c..a7d6e46809 100644 --- a/.github/workflows/ci-examples.yml +++ b/.github/workflows/ci-examples.yml @@ -200,7 +200,7 @@ jobs: strategy: fail-fast: false matrix: - surface: [toolbar, comments] + surface: [toolbar, comments, track-changes] framework: [vanilla] steps: - name: Restore workspace diff --git a/examples/README.md b/examples/README.md index e45968bfec..89c8d97db4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 diff --git a/examples/editor/custom-ui/track-changes/vanilla/README.md b/examples/editor/custom-ui/track-changes/vanilla/README.md new file mode 100644 index 0000000000..0284753c05 --- /dev/null +++ b/examples/editor/custom-ui/track-changes/vanilla/README.md @@ -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) diff --git a/examples/editor/custom-ui/track-changes/vanilla/index.html b/examples/editor/custom-ui/track-changes/vanilla/index.html new file mode 100644 index 0000000000..507432ed46 --- /dev/null +++ b/examples/editor/custom-ui/track-changes/vanilla/index.html @@ -0,0 +1,29 @@ + + + + + + SuperDoc Custom UI: vanilla tracked changes + + +
+
+
+
+
+ +
+ + + diff --git a/examples/editor/custom-ui/track-changes/vanilla/package.json b/examples/editor/custom-ui/track-changes/vanilla/package.json new file mode 100644 index 0000000000..b0a86a05b2 --- /dev/null +++ b/examples/editor/custom-ui/track-changes/vanilla/package.json @@ -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:" + } +} diff --git a/examples/editor/custom-ui/track-changes/vanilla/public/test_file.docx b/examples/editor/custom-ui/track-changes/vanilla/public/test_file.docx new file mode 100644 index 0000000000..b1b8c8f5a7 Binary files /dev/null and b/examples/editor/custom-ui/track-changes/vanilla/public/test_file.docx differ diff --git a/examples/editor/custom-ui/track-changes/vanilla/src/main.ts b/examples/editor/custom-ui/track-changes/vanilla/src/main.ts new file mode 100644 index 0000000000..57cf47ad94 --- /dev/null +++ b/examples/editor/custom-ui/track-changes/vanilla/src/main.ts @@ -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('#mode-toggle')!; +const list = document.querySelector('#changes')!; +const prevBtn = document.querySelector('#prev')!; +const nextBtn = document.querySelector('#next')!; +const acceptAllBtn = document.querySelector('#accept-all')!; +const rejectAllBtn = document.querySelector('#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 = ` + + +`; +modeEl.addEventListener('click', (e) => { + const t = (e.target as HTMLElement).closest('button[data-mode]'); + if (!t) return; + ui.document.setMode(t.dataset.mode as 'editing' | 'suggesting'); +}); +scope.add( + ui.document.observe((doc) => { + modeEl.querySelectorAll('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 = ` + ${kind} +
${escape(author)}
+ ${change.excerpt ? `
"${escape(change.excerpt)}"
` : ''} +
+ + +
+ `; + 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]!); +} + +const teardown = () => { + ui.destroy(); + superdoc.destroy(); +}; +window.addEventListener('beforeunload', teardown); +if (import.meta.hot) import.meta.hot.dispose(teardown); diff --git a/examples/editor/custom-ui/track-changes/vanilla/src/style.css b/examples/editor/custom-ui/track-changes/vanilla/src/style.css new file mode 100644 index 0000000000..de3678f3bc --- /dev/null +++ b/examples/editor/custom-ui/track-changes/vanilla/src/style.css @@ -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); } diff --git a/examples/editor/custom-ui/track-changes/vanilla/tsconfig.json b/examples/editor/custom-ui/track-changes/vanilla/tsconfig.json new file mode 100644 index 0000000000..6d42803dda --- /dev/null +++ b/examples/editor/custom-ui/track-changes/vanilla/tsconfig.json @@ -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"] +} diff --git a/examples/editor/custom-ui/track-changes/vanilla/vite.config.ts b/examples/editor/custom-ui/track-changes/vanilla/vite.config.ts new file mode 100644 index 0000000000..c049f46e10 --- /dev/null +++ b/examples/editor/custom-ui/track-changes/vanilla/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({}); diff --git a/examples/manifest.json b/examples/manifest.json index 246264f471..8d188f9acf 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96cc6f5d94..a739e4bda7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1986,6 +1986,19 @@ importers: specifier: npm:rolldown-vite@7.3.1 version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + examples/editor/custom-ui/track-changes/vanilla: + dependencies: + superdoc: + specifier: workspace:* + version: link:../../../../../packages/superdoc + devDependencies: + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + examples/editor/spell-check/language-tool-self-hosted: dependencies: react: @@ -2295,7 +2308,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vue: specifier: 3.5.32 version: 3.5.32(typescript@5.9.3) @@ -2320,7 +2333,7 @@ importers: version: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) concurrently: specifier: 'catalog:' version: 9.2.1 @@ -2344,7 +2357,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) packages/document-api: {} @@ -33252,25 +33265,6 @@ snapshots: vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.32(typescript@5.9.3) - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.12 - debug: 4.4.3(supports-color@5.5.0) - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 - std-env: 3.10.0 - test-exclude: 7.0.2 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0