Skip to content

Commit a448478

Browse files
committed
feat(examples): vanilla custom UI comments example (SD-2929)
Second focused minimal example under examples/editor/custom-ui/. Single file (~125 lines) demonstrating the comments composer pattern that the controller surfaces specifically solve: - ui.selection.capture() freezes the selection at the moment the composer opens. The textarea then takes focus and the editor's live selection visually clears, but the captured snapshot still has the original anchor. - ui.comments.createFromCapture(capture, { text }) anchors the new comment against that snapshot, not the live (now empty) selection. A composer that read the live selection at submit time would refuse the create. - ui.comments.observe(snapshot => ...) renders the sidebar list from a single subscription. - ui.comments.resolve / .reopen / .reply route through the same Document API that powers DOCX import / export, so changes here round-trip through Word. - 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-toolbar.
1 parent 567d5db commit a448478

12 files changed

Lines changed: 332 additions & 4 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]
203+
surface: [toolbar, comments]
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
@@ -40,6 +40,7 @@ Build your own toolbar, comments sidebar, and review panel against the `superdoc
4040
| Example | Docs |
4141
|---------|------|
4242
| [toolbar/vanilla](./editor/custom-ui/toolbar/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/overview) |
43+
| [comments/vanilla](./editor/custom-ui/comments/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/comments) |
4344

4445
### Theming
4546

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Custom UI: vanilla comments
2+
3+
A custom SuperDoc comments sidebar in plain TypeScript. Single file, no framework, copy-paste into your own app.
4+
5+
## What this teaches
6+
7+
- `ui.selection.capture()` to freeze the editor selection at the moment the user clicks "Add comment", so the anchor survives the textarea taking focus.
8+
- `ui.comments.createFromCapture(capture, { text })` to anchor the new comment against that frozen snapshot, not the live (now-empty) selection.
9+
- `ui.comments.observe(snapshot => ...)` to render the sidebar list from a single subscription.
10+
- Resolve, reopen, and reply per card via `ui.comments.resolve / .reopen / .reply`.
11+
- `ui.createScope()` for lifecycle, plus `ui.destroy()` cascading on tear-down.
12+
13+
## Run
14+
15+
```bash
16+
pnpm install
17+
pnpm dev
18+
```
19+
20+
The `predev` script builds the local `superdoc` workspace package so type imports resolve from `dist/`. From a published `npm` install this step is unnecessary.
21+
22+
## See also
23+
24+
- Docs: [Custom UI > Comments](https://docs.superdoc.dev/editor/custom-ui/comments)
25+
- Sibling examples: [`toolbar/vanilla`](../../toolbar/vanilla), [`track-changes/vanilla`](../../track-changes/vanilla)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 comments</title>
7+
</head>
8+
<body>
9+
<div class="app">
10+
<section class="editor-area">
11+
<button id="add-comment" class="add-btn" disabled>Add comment</button>
12+
<div id="composer-mount"></div>
13+
<div id="editor"></div>
14+
</section>
15+
<aside class="sidebar">
16+
<h2>Comments</h2>
17+
<ul id="comments" class="comments"></ul>
18+
</aside>
19+
</div>
20+
<script type="module" src="/src/main.ts"></script>
21+
</body>
22+
</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-comments-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+
}
16.3 KB
Binary file not shown.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Custom comments sidebar (vanilla TypeScript), single file.
3+
*
4+
* The load-bearing pattern is `ui.selection.capture()`:
5+
*
6+
* The user selects text, clicks Add comment, the textarea takes
7+
* focus, and the editor's live selection visually clears. A
8+
* composer that read the live selection at submit time would see
9+
* `null` and refuse the create. `capture()` returns a frozen
10+
* snapshot at the moment the composer opens, so
11+
* `comments.createFromCapture(capture, { text })` anchors the new
12+
* comment against the original selection regardless of where focus
13+
* moves afterwards.
14+
*
15+
* The other patterns:
16+
*
17+
* - `ui.comments.observe(snapshot => ...)` drives the sidebar list
18+
* from a single subscription. No event-wrapped shape.
19+
* - `ui.comments.resolve / .reopen / .reply` route through the
20+
* same Document API that powers DOCX import / export, so changes
21+
* made here round-trip through Word.
22+
* - `ui.createScope()` collects every subscription so the whole
23+
* surface tears down cleanly on `ui.destroy()`.
24+
*/
25+
26+
import { SuperDoc } from 'superdoc';
27+
import { createSuperDocUI, type CommentsSlice, type SelectionCapture } 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: 'editing',
35+
user: { name: 'Alex Rivera', email: 'alex@example.com' },
36+
modules: { comments: false }, // disable built-in comments UI; we render our own
37+
});
38+
39+
const ui = createSuperDocUI({ superdoc });
40+
const scope = ui.createScope();
41+
42+
// DOM handles the example writes into.
43+
const addBtn = document.querySelector<HTMLButtonElement>('#add-comment')!;
44+
const composerMount = document.querySelector<HTMLElement>('#composer-mount')!;
45+
const list = document.querySelector<HTMLUListElement>('#comments')!;
46+
47+
// Add-comment button is enabled only when the editor has a real
48+
// positional selection. `ui.selection.observe` fires once
49+
// synchronously and again on every selection change.
50+
scope.add(
51+
ui.selection.observe((sel) => {
52+
addBtn.disabled = sel.empty || sel.selectionTarget == null;
53+
}),
54+
);
55+
56+
// The composer mounts only when the user clicks Add comment, and
57+
// captures the selection at that moment.
58+
addBtn.addEventListener('click', () => openComposer());
59+
60+
function openComposer(): void {
61+
// Capture the selection NOW. The textarea will steal focus next.
62+
const capture = ui.selection.capture();
63+
if (!capture) return;
64+
65+
composerMount.innerHTML = `
66+
<div class="composer">
67+
<div class="quote">${capture.quotedText ? `"${escape(capture.quotedText)}"` : '<em>No text selection</em>'}</div>
68+
<textarea autofocus placeholder="Write a comment…"></textarea>
69+
<div class="actions">
70+
<button data-action="cancel">Cancel</button>
71+
<button data-action="post" class="primary" disabled>Post</button>
72+
</div>
73+
</div>
74+
`;
75+
76+
const ta = composerMount.querySelector<HTMLTextAreaElement>('textarea')!;
77+
const postBtn = composerMount.querySelector<HTMLButtonElement>('button[data-action="post"]')!;
78+
const cancelBtn = composerMount.querySelector<HTMLButtonElement>('button[data-action="cancel"]')!;
79+
80+
ta.addEventListener('input', () => {
81+
postBtn.disabled = ta.value.trim().length === 0;
82+
});
83+
ta.focus();
84+
85+
cancelBtn.addEventListener('click', closeComposer);
86+
postBtn.addEventListener('click', () => post(capture, ta.value));
87+
}
88+
89+
function closeComposer(): void {
90+
composerMount.innerHTML = '';
91+
}
92+
93+
function post(capture: SelectionCapture, raw: string): void {
94+
const text = raw.trim();
95+
if (!text) return;
96+
const receipt = ui.comments.createFromCapture(capture, { text });
97+
if (!receipt.success) {
98+
console.error('[comments] create failed', receipt);
99+
return;
100+
}
101+
closeComposer();
102+
}
103+
104+
// Render the sidebar from the comments slice. One subscription, the
105+
// whole list re-renders when the snapshot changes. For a real product
106+
// you'd diff DOM; this example optimises for clarity.
107+
scope.add(
108+
ui.comments.observe((snapshot) => renderComments(snapshot)),
109+
);
110+
111+
function renderComments(snapshot: CommentsSlice): void {
112+
list.innerHTML = '';
113+
if (snapshot.items.length === 0) {
114+
const empty = document.createElement('li');
115+
empty.className = 'empty';
116+
empty.textContent = 'No comments yet. Select text and click Add comment.';
117+
list.appendChild(empty);
118+
return;
119+
}
120+
for (const c of snapshot.items) {
121+
if (c.parentCommentId) continue; // replies render under their root, below
122+
const li = document.createElement('li');
123+
li.className = `card${c.status === 'resolved' ? ' resolved' : ''}`;
124+
li.innerHTML = `
125+
<div class="author">${escape(c.creatorName ?? c.creatorEmail ?? 'Unknown')}</div>
126+
${c.anchoredText ? `<div class="quote">"${escape(c.anchoredText)}"</div>` : ''}
127+
<div class="body">${escape(c.text ?? '')}</div>
128+
<div class="actions">
129+
${c.status === 'resolved'
130+
? `<button data-action="reopen" class="primary">Reopen</button>`
131+
: `<button data-action="resolve">Resolve</button><button data-action="reply">Reply</button>`}
132+
</div>
133+
`;
134+
li.querySelector('[data-action="resolve"]')?.addEventListener('click', () => ui.comments.resolve(c.id));
135+
li.querySelector('[data-action="reopen"]')?.addEventListener('click', () => ui.comments.reopen(c.id));
136+
li.querySelector('[data-action="reply"]')?.addEventListener('click', () => {
137+
const text = window.prompt('Reply:');
138+
if (text?.trim()) ui.comments.reply(c.id, { text: text.trim() });
139+
});
140+
list.appendChild(li);
141+
}
142+
}
143+
144+
function escape(s: string): string {
145+
return s.replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[ch]!);
146+
}
147+
148+
// One teardown for the whole app. ui.destroy() cascades into the scope.
149+
const teardown = () => {
150+
ui.destroy();
151+
superdoc.destroy();
152+
};
153+
window.addEventListener('beforeunload', teardown);
154+
if (import.meta.hot) import.meta.hot.dispose(teardown);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
--resolved: #a1a1aa;
10+
}
11+
12+
* { box-sizing: border-box; }
13+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; color: var(--text); background: var(--bg-muted); }
14+
button, textarea { font: inherit; }
15+
button { cursor: pointer; }
16+
17+
.app { display: grid; grid-template-columns: 1fr 320px; height: 100vh; }
18+
.editor-area { overflow: auto; padding: 8px 12px; }
19+
.sidebar { background: var(--bg); border-left: 1px solid var(--border); overflow-y: auto; padding: 12px 16px; }
20+
.sidebar h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 12px; }
21+
22+
.add-btn {
23+
height: 30px; padding: 0 12px;
24+
background: transparent; border: 1px solid var(--border); border-radius: 4px;
25+
color: var(--text); margin-bottom: 8px;
26+
}
27+
.add-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
28+
.add-btn:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; }
29+
30+
.composer { border: 1px solid var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
31+
.composer .quote { font-size: 12px; color: var(--text-muted); font-style: italic; border-left: 2px solid var(--accent); padding-left: 8px; margin-bottom: 8px; }
32+
.composer textarea { width: 100%; min-height: 60px; resize: vertical; border: 1px solid var(--border); border-radius: 4px; padding: 8px; }
33+
.composer .actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; }
34+
.composer button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
35+
.composer button.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
36+
.composer button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
37+
38+
.comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
39+
.comments .empty { color: var(--text-muted); font-size: 12px; }
40+
.card { border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; background: var(--bg); }
41+
.card.resolved { color: var(--resolved); background: var(--bg-muted); }
42+
.card .author { font-weight: 600; font-size: 12px; }
43+
.card .body { font-size: 13px; line-height: 1.4; margin: 4px 0; }
44+
.card .quote { border-left: 2px solid var(--accent); padding-left: 8px; font-style: italic; color: var(--text-muted); font-size: 12px; margin: 4px 0; }
45+
.card .actions { display: flex; gap: 6px; margin-top: 8px; }
46+
.card .actions button { padding: 4px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
47+
.card .actions button.primary { border-color: var(--accent); color: var(--accent); }
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)