Skip to content

Commit 9364e23

Browse files
committed
feat(examples): add selection-capture vanilla example (SD-2929)
Smallest example that proves why ui.selection.capture() exists. Single file, no framework. Shows the capture-at-composer-open then createFromCapture-at-submit flow that survives the textarea stealing focus from the editor. Linked from the Custom UI > Comments and Selection-and-viewport docs pages.
1 parent 519da2a commit 9364e23

12 files changed

Lines changed: 335 additions & 254 deletions

File tree

.github/workflows/ci-examples.yml

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,34 @@ jobs:
134134
working-directory: examples/__tests__
135135
run: EXAMPLE=editor/built-in-ui/${{ matrix.example }} npx playwright test
136136

137+
custom-ui:
138+
needs: build
139+
runs-on: ubuntu-latest
140+
strategy:
141+
fail-fast: false
142+
matrix:
143+
example: [selection-capture/vanilla]
144+
steps:
145+
- name: Restore workspace
146+
uses: actions/cache/restore@v4
147+
with:
148+
path: |
149+
.
150+
~/.cache/ms-playwright
151+
key: examples-workspace-${{ github.sha }}
152+
153+
- uses: pnpm/action-setup@v4
154+
155+
- name: Setup Node.js
156+
uses: actions/setup-node@v6
157+
with:
158+
node-version: '20'
159+
cache: 'pnpm'
160+
161+
- name: Run smoke test
162+
working-directory: examples/__tests__
163+
run: EXAMPLE=editor/custom-ui/${{ matrix.example }} npx playwright test
164+
137165
ai:
138166
needs: build
139167
runs-on: ubuntu-latest
@@ -213,7 +241,7 @@ jobs:
213241
validate:
214242
name: CI Examples / validate
215243
if: always()
216-
needs: [getting-started, collaboration, built-in-ui, ai, advanced-headless-toolbar, advanced-extensions, headless]
244+
needs: [getting-started, collaboration, built-in-ui, custom-ui, ai, advanced-headless-toolbar, advanced-extensions, headless]
217245
runs-on: ubuntu-latest
218246
steps:
219247
- name: Check results

examples/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ Patterns for the browser editor surface.
3333
| [track-changes](./editor/built-in-ui/track-changes) | [docs](https://docs.superdoc.dev/editor/built-in-ui/track-changes) |
3434
| [toolbar](./editor/built-in-ui/toolbar) | [docs](https://docs.superdoc.dev/editor/built-in-ui/toolbar) |
3535

36+
### Custom UI
37+
38+
| Example | Docs |
39+
|---------|------|
40+
| [selection-capture/vanilla](./editor/custom-ui/selection-capture/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/selection-and-viewport) |
41+
3642
### Theming
3743

3844
| Example | Docs |
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Custom UI: selection capture (vanilla)
2+
3+
The smallest example that proves why `ui.selection.capture()` exists. Single file, no framework.
4+
5+
## What this teaches
6+
7+
A comment composer cannot read the live selection at submit time. The textarea takes focus when the composer opens, the editor's live selection visually clears, and a `ui.selection.getSnapshot()` call at submit returns null. The fix is `ui.selection.capture()` at composer-open: a frozen snapshot of the selection that survives focus changes. Pass it to `ui.comments.createFromCapture(capture, { text })` at submit, and the new comment anchors against the original selection.
8+
9+
This example shows that flow and nothing else. No threading, no resolve / reopen / reply, no toolbar, no mode toggle. For the full Custom UI sidebar pattern, see [`demos/custom-ui`](../../../../../demos/custom-ui).
10+
11+
## Run
12+
13+
```bash
14+
pnpm install
15+
pnpm dev
16+
```
17+
18+
Select text in the document. Click **Add comment on selection**. Type, then post. The new comment appears in the right-hand list anchored to the original selection, even though the selection visually cleared the moment the textarea took focus.
19+
20+
## See also
21+
22+
- [Custom UI > Comments](https://docs.superdoc.dev/editor/custom-ui/comments)
23+
- [Custom UI > Selection and viewport](https://docs.superdoc.dev/editor/custom-ui/selection-and-viewport)
24+
- [Custom UI > Controller setup](https://docs.superdoc.dev/editor/custom-ui/controller-setup)
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: selection.capture()</title>
7+
</head>
8+
<body>
9+
<div class="app">
10+
<section class="editor-area">
11+
<button id="add-comment" class="btn" disabled>Add comment on selection</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"></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-selection-capture-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: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* The smallest example that proves why `ui.selection.capture()` exists.
3+
*
4+
* The problem: a comment composer cannot read the live selection at
5+
* submit time. The textarea takes focus the moment the composer
6+
* opens; the editor's live selection visually clears. By the time
7+
* the user types and clicks Post, `ui.selection.getSnapshot()`
8+
* returns null.
9+
*
10+
* The fix: `ui.selection.capture()` returns a frozen snapshot of the
11+
* selection at composer-open time. Hold it across the user's typing.
12+
* Pass it to `ui.comments.createFromCapture(capture, { text })` at
13+
* submit. The new comment anchors against the original selection
14+
* regardless of where focus has moved.
15+
*
16+
* This example shows that flow and nothing else. No threading, no
17+
* resolve / reopen / reply, no toolbar, no mode toggle. For the
18+
* full Custom UI surface, see `demos/custom-ui` (React).
19+
*/
20+
21+
import { SuperDoc } from 'superdoc';
22+
import { createSuperDocUI } from 'superdoc/ui';
23+
import 'superdoc/style.css';
24+
import './style.css';
25+
26+
const superdoc = new SuperDoc({
27+
selector: '#editor',
28+
document: '/test_file.docx',
29+
documentMode: 'editing',
30+
user: { name: 'Alex', email: 'alex@example.com' },
31+
modules: { comments: false }, // disable built-in comments UI; we render our own
32+
});
33+
34+
const ui = createSuperDocUI({ superdoc });
35+
const scope = ui.createScope();
36+
37+
const addBtn = document.querySelector<HTMLButtonElement>('#add-comment')!;
38+
const composerMount = document.querySelector<HTMLElement>('#composer-mount')!;
39+
const list = document.querySelector<HTMLUListElement>('#comments')!;
40+
41+
// Enable the Add button only when the editor has a positional
42+
// selection. `selection.observe` fires once with the initial state
43+
// and then on every selection change.
44+
scope.add(
45+
ui.selection.observe((sel) => {
46+
addBtn.disabled = sel.empty || sel.selectionTarget == null;
47+
}),
48+
);
49+
50+
addBtn.addEventListener('click', () => {
51+
// Capture NOW, before the textarea steals focus.
52+
const capture = ui.selection.capture();
53+
if (!capture) return;
54+
55+
composerMount.innerHTML = `
56+
<div class="composer">
57+
<div class="quote">${capture.quotedText ? `"${esc(capture.quotedText)}"` : '<em>No text selected</em>'}</div>
58+
<textarea autofocus placeholder="Comment on the selection…"></textarea>
59+
<div class="actions">
60+
<button data-action="cancel">Cancel</button>
61+
<button data-action="post" class="primary" disabled>Post</button>
62+
</div>
63+
</div>
64+
`;
65+
66+
const ta = composerMount.querySelector<HTMLTextAreaElement>('textarea')!;
67+
const post = composerMount.querySelector<HTMLButtonElement>('button[data-action="post"]')!;
68+
const cancel = composerMount.querySelector<HTMLButtonElement>('button[data-action="cancel"]')!;
69+
70+
ta.addEventListener('input', () => {
71+
post.disabled = ta.value.trim().length === 0;
72+
});
73+
ta.focus();
74+
75+
cancel.addEventListener('click', close);
76+
post.addEventListener('click', () => {
77+
const text = ta.value.trim();
78+
if (!text) return;
79+
// The captured snapshot still has the original anchor; the live
80+
// selection has been gone since the textarea took focus.
81+
const receipt = ui.comments.createFromCapture(capture, { text });
82+
if (!receipt.success) console.error('[selection-capture] create failed', receipt);
83+
close();
84+
});
85+
});
86+
87+
function close(): void {
88+
composerMount.innerHTML = '';
89+
}
90+
91+
// Render the comments list. One subscription, plain DOM.
92+
scope.add(
93+
ui.comments.observe((snapshot) => {
94+
list.innerHTML = '';
95+
if (snapshot.items.length === 0) {
96+
const empty = document.createElement('li');
97+
empty.className = 'empty';
98+
empty.textContent = 'No comments yet. Select text and click Add.';
99+
list.appendChild(empty);
100+
return;
101+
}
102+
for (const c of snapshot.items) {
103+
const li = document.createElement('li');
104+
li.className = 'card';
105+
li.innerHTML = `
106+
${c.anchoredText ? `<div class="quote">"${esc(c.anchoredText)}"</div>` : ''}
107+
<div class="body">${esc(c.text ?? '')}</div>
108+
`;
109+
list.appendChild(li);
110+
}
111+
}),
112+
);
113+
114+
function esc(s: string): string {
115+
return s.replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[ch]!);
116+
}
117+
118+
const teardown = () => {
119+
ui.destroy();
120+
superdoc.destroy();
121+
};
122+
window.addEventListener('beforeunload', teardown);
123+
if (import.meta.hot) import.meta.hot.dispose(teardown);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
}
10+
11+
* { box-sizing: border-box; }
12+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 14px; color: var(--text); background: var(--bg-muted); }
13+
button, textarea { font: inherit; }
14+
button { cursor: pointer; }
15+
16+
.app { display: grid; grid-template-columns: 1fr 320px; height: 100vh; }
17+
.editor-area { overflow: auto; padding: 12px; }
18+
.sidebar { background: var(--bg); border-left: 1px solid var(--border); overflow-y: auto; padding: 12px 16px; }
19+
.sidebar h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 12px; }
20+
21+
.btn { height: 30px; padding: 0 12px; background: transparent; border: 1px solid var(--border); border-radius: 4px; color: var(--text); margin-bottom: 8px; }
22+
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
23+
.btn:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; }
24+
25+
.composer { border: 1px solid var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
26+
.composer .quote { font-size: 12px; color: var(--text-muted); font-style: italic; border-left: 2px solid var(--accent); padding-left: 8px; margin-bottom: 8px; }
27+
.composer textarea { width: 100%; min-height: 60px; resize: vertical; border: 1px solid var(--border); border-radius: 4px; padding: 8px; }
28+
.composer .actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; }
29+
.composer .actions button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
30+
.composer .actions button.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
31+
.composer .actions button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
32+
33+
#comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
34+
#comments .empty { color: var(--text-muted); font-size: 12px; }
35+
.card { border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; background: var(--bg); }
36+
.card .quote { border-left: 2px solid var(--accent); padding-left: 8px; font-style: italic; color: var(--text-muted); font-size: 12px; margin-bottom: 4px; }
37+
.card .body { font-size: 13px; line-height: 1.4; }
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)