diff --git a/.github/workflows/ci-examples.yml b/.github/workflows/ci-examples.yml index ef83d02edf..3c94c34a9c 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] + surface: [toolbar, comments] framework: [vanilla] steps: - name: Restore workspace diff --git a/examples/README.md b/examples/README.md index 61130e8688..e45968bfec 100644 --- a/examples/README.md +++ b/examples/README.md @@ -40,6 +40,7 @@ Build your own toolbar, comments sidebar, and review panel against the `superdoc | Example | Docs | |---------|------| | [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) | ### Theming diff --git a/examples/editor/custom-ui/comments/vanilla/README.md b/examples/editor/custom-ui/comments/vanilla/README.md new file mode 100644 index 0000000000..9e432f295d --- /dev/null +++ b/examples/editor/custom-ui/comments/vanilla/README.md @@ -0,0 +1,25 @@ +# Custom UI: vanilla comments + +A custom SuperDoc comments sidebar in plain TypeScript. Single file, no framework, copy-paste into your own app. + +## What this teaches + +- `ui.selection.capture()` to freeze the editor selection at the moment the user clicks "Add comment", so the anchor survives the textarea taking focus. +- `ui.comments.createFromCapture(capture, { text })` to anchor the new comment against that frozen snapshot, not the live (now-empty) selection. +- `ui.comments.observe(snapshot => ...)` to render the sidebar list from a single subscription. +- Resolve, reopen, and reply per card via `ui.comments.resolve / .reopen / .reply`. +- `ui.createScope()` for lifecycle, plus `ui.destroy()` cascading on tear-down. + +## Run + +```bash +pnpm install +pnpm dev +``` + +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 > Comments](https://docs.superdoc.dev/editor/custom-ui/comments) +- Sibling examples: [`toolbar/vanilla`](../../toolbar/vanilla), [`track-changes/vanilla`](../../track-changes/vanilla) diff --git a/examples/editor/custom-ui/comments/vanilla/index.html b/examples/editor/custom-ui/comments/vanilla/index.html new file mode 100644 index 0000000000..6f3ebe1936 --- /dev/null +++ b/examples/editor/custom-ui/comments/vanilla/index.html @@ -0,0 +1,22 @@ + + + + + + SuperDoc Custom UI: vanilla comments + + +
+
+ +
+
+
+ +
+ + + diff --git a/examples/editor/custom-ui/comments/vanilla/package.json b/examples/editor/custom-ui/comments/vanilla/package.json new file mode 100644 index 0000000000..53efe94af2 --- /dev/null +++ b/examples/editor/custom-ui/comments/vanilla/package.json @@ -0,0 +1,18 @@ +{ + "name": "@superdoc-examples/custom-ui-comments-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/comments/vanilla/public/test_file.docx b/examples/editor/custom-ui/comments/vanilla/public/test_file.docx new file mode 100644 index 0000000000..b1b8c8f5a7 Binary files /dev/null and b/examples/editor/custom-ui/comments/vanilla/public/test_file.docx differ diff --git a/examples/editor/custom-ui/comments/vanilla/src/main.ts b/examples/editor/custom-ui/comments/vanilla/src/main.ts new file mode 100644 index 0000000000..71186b3f00 --- /dev/null +++ b/examples/editor/custom-ui/comments/vanilla/src/main.ts @@ -0,0 +1,154 @@ +/** + * Custom comments sidebar (vanilla TypeScript), single file. + * + * The load-bearing pattern is `ui.selection.capture()`: + * + * The user selects text, clicks Add comment, the textarea takes + * focus, and the editor's live selection visually clears. A + * composer that read the live selection at submit time would see + * `null` and refuse the create. `capture()` returns a frozen + * snapshot at the moment the composer opens, so + * `comments.createFromCapture(capture, { text })` anchors the new + * comment against the original selection regardless of where focus + * moves afterwards. + * + * The other patterns: + * + * - `ui.comments.observe(snapshot => ...)` drives the sidebar list + * from a single subscription. No event-wrapped shape. + * - `ui.comments.resolve / .reopen / .reply` route through the + * same Document API that powers DOCX import / export, so changes + * made here round-trip through Word. + * - `ui.createScope()` collects every subscription so the whole + * surface tears down cleanly on `ui.destroy()`. + */ + +import { SuperDoc } from 'superdoc'; +import { createSuperDocUI, type CommentsSlice, type SelectionCapture } from 'superdoc/ui'; +import 'superdoc/style.css'; +import './style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: '/test_file.docx', + documentMode: 'editing', + user: { name: 'Alex Rivera', email: 'alex@example.com' }, + modules: { comments: false }, // disable built-in comments UI; we render our own +}); + +const ui = createSuperDocUI({ superdoc }); +const scope = ui.createScope(); + +// DOM handles the example writes into. +const addBtn = document.querySelector('#add-comment')!; +const composerMount = document.querySelector('#composer-mount')!; +const list = document.querySelector('#comments')!; + +// Add-comment button is enabled only when the editor has a real +// positional selection. `ui.selection.observe` fires once +// synchronously and again on every selection change. +scope.add( + ui.selection.observe((sel) => { + addBtn.disabled = sel.empty || sel.selectionTarget == null; + }), +); + +// The composer mounts only when the user clicks Add comment, and +// captures the selection at that moment. +addBtn.addEventListener('click', () => openComposer()); + +function openComposer(): void { + // Capture the selection NOW. The textarea will steal focus next. + const capture = ui.selection.capture(); + if (!capture) return; + + composerMount.innerHTML = ` +
+
${capture.quotedText ? `"${escape(capture.quotedText)}"` : 'No text selection'}
+ +
+ + +
+
+ `; + + const ta = composerMount.querySelector('textarea')!; + const postBtn = composerMount.querySelector('button[data-action="post"]')!; + const cancelBtn = composerMount.querySelector('button[data-action="cancel"]')!; + + ta.addEventListener('input', () => { + postBtn.disabled = ta.value.trim().length === 0; + }); + ta.focus(); + + cancelBtn.addEventListener('click', closeComposer); + postBtn.addEventListener('click', () => post(capture, ta.value)); +} + +function closeComposer(): void { + composerMount.innerHTML = ''; +} + +function post(capture: SelectionCapture, raw: string): void { + const text = raw.trim(); + if (!text) return; + const receipt = ui.comments.createFromCapture(capture, { text }); + if (!receipt.success) { + console.error('[comments] create failed', receipt); + return; + } + closeComposer(); +} + +// Render the sidebar from the comments slice. One subscription, the +// whole list re-renders when the snapshot changes. For a real product +// you'd diff DOM; this example optimises for clarity. +scope.add( + ui.comments.observe((snapshot) => renderComments(snapshot)), +); + +function renderComments(snapshot: CommentsSlice): void { + list.innerHTML = ''; + if (snapshot.items.length === 0) { + const empty = document.createElement('li'); + empty.className = 'empty'; + empty.textContent = 'No comments yet. Select text and click Add comment.'; + list.appendChild(empty); + return; + } + for (const c of snapshot.items) { + if (c.parentCommentId) continue; // replies render under their root, below + const li = document.createElement('li'); + li.className = `card${c.status === 'resolved' ? ' resolved' : ''}`; + li.innerHTML = ` +
${escape(c.creatorName ?? c.creatorEmail ?? 'Unknown')}
+ ${c.anchoredText ? `
"${escape(c.anchoredText)}"
` : ''} +
${escape(c.text ?? '')}
+
+ ${c.status === 'resolved' + ? `` + : ``} +
+ `; + li.querySelector('[data-action="resolve"]')?.addEventListener('click', () => ui.comments.resolve(c.id)); + li.querySelector('[data-action="reopen"]')?.addEventListener('click', () => ui.comments.reopen(c.id)); + li.querySelector('[data-action="reply"]')?.addEventListener('click', () => { + const text = window.prompt('Reply:'); + if (text?.trim()) ui.comments.reply(c.id, { text: text.trim() }); + }); + list.appendChild(li); + } +} + +function escape(s: string): string { + return s.replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]!); +} + +// One teardown for the whole app. ui.destroy() cascades into the scope. +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/comments/vanilla/src/style.css b/examples/editor/custom-ui/comments/vanilla/src/style.css new file mode 100644 index 0000000000..fb57682f06 --- /dev/null +++ b/examples/editor/custom-ui/comments/vanilla/src/style.css @@ -0,0 +1,47 @@ +:root { + --bg: #fff; + --bg-muted: #f7f7f8; + --border: #e4e4e7; + --text: #18181b; + --text-muted: #71717a; + --accent: #2563eb; + --accent-soft: #eff6ff; + --resolved: #a1a1aa; +} + +* { 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, textarea { font: inherit; } +button { cursor: pointer; } + +.app { display: grid; grid-template-columns: 1fr 320px; 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; } +.sidebar h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 12px; } + +.add-btn { + height: 30px; padding: 0 12px; + background: transparent; border: 1px solid var(--border); border-radius: 4px; + color: var(--text); margin-bottom: 8px; +} +.add-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.add-btn:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; } + +.composer { border: 1px solid var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; } +.composer .quote { font-size: 12px; color: var(--text-muted); font-style: italic; border-left: 2px solid var(--accent); padding-left: 8px; margin-bottom: 8px; } +.composer textarea { width: 100%; min-height: 60px; resize: vertical; border: 1px solid var(--border); border-radius: 4px; padding: 8px; } +.composer .actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; } +.composer button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; } +.composer button.primary { border-color: var(--accent); background: var(--accent); color: #fff; } +.composer button.primary:disabled { opacity: 0.5; cursor: not-allowed; } + +.comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.comments .empty { color: var(--text-muted); font-size: 12px; } +.card { border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; background: var(--bg); } +.card.resolved { color: var(--resolved); background: var(--bg-muted); } +.card .author { font-weight: 600; font-size: 12px; } +.card .body { font-size: 13px; line-height: 1.4; margin: 4px 0; } +.card .quote { border-left: 2px solid var(--accent); padding-left: 8px; font-style: italic; color: var(--text-muted); font-size: 12px; margin: 4px 0; } +.card .actions { display: flex; gap: 6px; margin-top: 8px; } +.card .actions button { padding: 4px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; } +.card .actions button.primary { border-color: var(--accent); color: var(--accent); } diff --git a/examples/editor/custom-ui/comments/vanilla/tsconfig.json b/examples/editor/custom-ui/comments/vanilla/tsconfig.json new file mode 100644 index 0000000000..6d42803dda --- /dev/null +++ b/examples/editor/custom-ui/comments/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/comments/vanilla/vite.config.ts b/examples/editor/custom-ui/comments/vanilla/vite.config.ts new file mode 100644 index 0000000000..c049f46e10 --- /dev/null +++ b/examples/editor/custom-ui/comments/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 47b536be01..246264f471 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -129,6 +129,16 @@ "docs": "https://docs.superdoc.dev/editor/custom-ui/overview", "ci": true }, + { + "id": "editor-custom-ui-comments-vanilla", + "title": "Custom comments sidebar (vanilla)", + "category": "Editor", + "surface": "Custom UI", + "sourceRepo": "superdoc-dev/superdoc", + "sourcePath": "examples/editor/custom-ui/comments/vanilla", + "docs": "https://docs.superdoc.dev/editor/custom-ui/comments", + "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 c728edbb55..96cc6f5d94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1960,6 +1960,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/comments/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/custom-ui/toolbar/vanilla: dependencies: superdoc: @@ -2282,7 +2295,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) vue: specifier: 3.5.32 version: 3.5.32(typescript@5.9.3) @@ -2307,7 +2320,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.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(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)) concurrently: specifier: 'catalog:' version: 9.2.1 @@ -2331,7 +2344,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) packages/document-api: {} @@ -33239,6 +33252,25 @@ 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