Skip to content

Commit db1ad57

Browse files
committed
feat(examples): add configurable-toolbar Custom UI example (SD-2929)
The smallest example that proves how to build your own toolbar with superdoc/ui. Three built-in commands (bold, italic, underline) wired per-id via ui.commands.<id>.observe, plus one custom command via ui.commands.register on the same surface. Body-only fixture, no framework. Linked from the Custom UI > Toolbar and commands docs page. Single concept per the examples rules: one custom toolbar, one registered custom command, no theming, no comments, no track changes.
1 parent 0b7ec67 commit db1ad57

12 files changed

Lines changed: 268 additions & 1 deletion

File tree

.github/workflows/ci-examples.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ jobs:
140140
strategy:
141141
fail-fast: false
142142
matrix:
143-
example: [selection-capture]
143+
example: [selection-capture, configurable-toolbar]
144144
steps:
145145
- name: Restore workspace
146146
uses: actions/cache/restore@v4

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Patterns for the browser editor surface.
3838
| Example | Docs |
3939
|---------|------|
4040
| [selection-capture](./editor/custom-ui/selection-capture) | [docs](https://docs.superdoc.dev/editor/custom-ui/selection-and-viewport) |
41+
| [configurable-toolbar](./editor/custom-ui/configurable-toolbar) | [docs](https://docs.superdoc.dev/editor/custom-ui/toolbar-and-commands) |
4142

4243
### Theming
4344

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Custom UI: configurable toolbar
2+
3+
The smallest example that proves how to build your own toolbar with `superdoc/ui`. Single file, no framework.
4+
5+
## What this teaches
6+
7+
A custom toolbar binds buttons to commands. The same surface holds built-ins (`bold`, `italic`, `underline`, ...) and your own (`example.insertClause`). Each button subscribes per-id via `ui.commands.<id>.observe(...)`, so changes to one command don't re-render the rest of the row. Click handlers run `ui.commands.get(id).execute()`.
8+
9+
`ui.commands.register({ id, execute, getState })` puts a custom command on the same surface as built-ins. The example registers one and binds a button to it the same way it binds the bold button.
10+
11+
This example shows that flow and nothing else. No threading, no resolve / reopen, no comments, no mode toggle. For the full Custom UI sidebar pattern, see [`demos/custom-ui`](../../../../../demos/custom-ui).
12+
13+
## Run
14+
15+
```bash
16+
pnpm install
17+
pnpm dev
18+
```
19+
20+
Click the buttons. Bold, Italic, Underline toggle on the current selection. Insert clause inserts a fixed snippet at the cursor.
21+
22+
## See also
23+
24+
- [Custom UI > Toolbar and commands](https://docs.superdoc.dev/editor/custom-ui/toolbar-and-commands)
25+
- [Custom UI > Custom commands](https://docs.superdoc.dev/editor/custom-ui/custom-commands)
26+
- [Custom UI > Controller setup](https://docs.superdoc.dev/editor/custom-ui/controller-setup)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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: configurable toolbar</title>
7+
</head>
8+
<body>
9+
<div class="app">
10+
<div role="toolbar" class="toolbar" id="toolbar"></div>
11+
<div id="editor"></div>
12+
</div>
13+
<script type="module" src="/src/main.ts"></script>
14+
</body>
15+
</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-configurable-toolbar",
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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* The smallest example that proves how to build your own toolbar with
3+
* `superdoc/ui`. Three built-in commands and one custom command, all
4+
* on the same surface, no framework.
5+
*
6+
* Each button subscribes per-id via `ui.commands.<id>.observe(...)`,
7+
* which only fires when that command's `active` / `disabled` /
8+
* `value` flips. Click handlers run `ui.commands.get(id).execute()`.
9+
*
10+
* `ui.commands.register({ id, execute, getState })` puts a custom
11+
* command on the same surface as built-ins. Bind to it the same way.
12+
*
13+
* No threading, no resolve / reopen, no comments, no mode toggle.
14+
* For the full Custom UI surface, see `demos/custom-ui` (React).
15+
*/
16+
17+
import { SuperDoc } from 'superdoc';
18+
import { createSuperDocUI } from 'superdoc/ui';
19+
import 'superdoc/style.css';
20+
import './style.css';
21+
22+
const superdoc = new SuperDoc({
23+
selector: '#editor',
24+
document: '/test_file.docx',
25+
documentMode: 'editing',
26+
user: { name: 'Alex', email: 'alex@example.com' },
27+
// No `modules.toolbar` — the built-in toolbar only mounts when its
28+
// selector is set, so we get a no-op default and render our own.
29+
});
30+
31+
const ui = createSuperDocUI({ superdoc });
32+
const scope = ui.createScope();
33+
34+
const toolbar = document.querySelector<HTMLElement>('#toolbar')!;
35+
36+
// Built-in command buttons. Same shape, different ids. Each one
37+
// subscribes per-id so unrelated state changes don't re-render the
38+
// row.
39+
type ButtonConfig = { id: 'bold' | 'italic' | 'underline'; label: string; title: string; className?: string };
40+
const BUILT_IN_BUTTONS: ButtonConfig[] = [
41+
{ id: 'bold', label: 'B', title: 'Bold (\u2318B)' },
42+
{ id: 'italic', label: 'I', title: 'Italic (\u2318I)', className: 'italic' },
43+
{ id: 'underline', label: 'U', title: 'Underline (\u2318U)', className: 'underline' },
44+
];
45+
46+
for (const config of BUILT_IN_BUTTONS) {
47+
const btn = document.createElement('button');
48+
btn.textContent = config.label;
49+
btn.title = config.title;
50+
if (config.className) btn.classList.add(config.className);
51+
btn.addEventListener('click', () => {
52+
ui.commands.get(config.id)?.execute();
53+
});
54+
toolbar.appendChild(btn);
55+
56+
// `ui.commands.<id>.observe` fires once with the initial state and
57+
// again when `active` / `disabled` flip. The fallback during
58+
// editor-init is `{ disabled: true, active: false }`, so the button
59+
// renders disabled with no flicker.
60+
scope.add(
61+
ui.commands[config.id].observe((state) => {
62+
btn.disabled = state.disabled;
63+
btn.classList.toggle('active', state.active === true);
64+
}),
65+
);
66+
}
67+
68+
const sep = document.createElement('span');
69+
sep.className = 'sep';
70+
toolbar.appendChild(sep);
71+
72+
// Custom command. Same surface as built-ins. The id is namespaced so
73+
// it won't collide with future built-ins.
74+
const insertClause = scope.register({
75+
id: 'example.insertClause',
76+
execute: ({ superdoc: sd }) => {
77+
const editor = sd?.activeEditor;
78+
const target = ui.selection.getSnapshot().selectionTarget;
79+
if (!editor?.doc?.insert || !target) return false;
80+
const receipt = editor.doc.insert({ target, value: 'This is a confidentiality clause.', type: 'text' });
81+
return receipt.success === true;
82+
},
83+
getState: ({ state }) => ({
84+
// Disable until the editor is ready and the user has a positional
85+
// selection (insert needs a target). The bold / italic buttons
86+
// already disable themselves when the selection collapses, so the
87+
// toolbar reads consistently across built-ins and customs.
88+
disabled: !state.document.ready || state.selection.selectionTarget == null,
89+
}),
90+
});
91+
92+
const insertBtn = document.createElement('button');
93+
insertBtn.textContent = 'Insert clause';
94+
insertBtn.className = 'custom';
95+
insertBtn.title = 'Insert a fixed snippet (custom command)';
96+
insertBtn.addEventListener('click', () => {
97+
ui.commands.get('example.insertClause')?.execute();
98+
});
99+
toolbar.appendChild(insertBtn);
100+
101+
scope.add(
102+
insertClause.handle.observe((state) => {
103+
insertBtn.disabled = state.disabled === true;
104+
}),
105+
);
106+
107+
const teardown = () => {
108+
ui.destroy();
109+
superdoc.destroy();
110+
};
111+
window.addEventListener('beforeunload', teardown);
112+
if (import.meta.hot) import.meta.hot.dispose(teardown);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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 { font: inherit; cursor: pointer; }
14+
15+
.app { display: flex; flex-direction: column; height: 100vh; }
16+
.toolbar {
17+
display: flex;
18+
gap: 4px;
19+
padding: 8px 12px;
20+
background: var(--bg);
21+
border-bottom: 1px solid var(--border);
22+
align-items: center;
23+
}
24+
.toolbar .sep { width: 1px; height: 18px; background: var(--border); margin: 0 4px; }
25+
.toolbar button {
26+
min-width: 32px;
27+
height: 30px;
28+
padding: 0 10px;
29+
background: transparent;
30+
border: 1px solid transparent;
31+
border-radius: 4px;
32+
color: var(--text);
33+
font-weight: 600;
34+
}
35+
.toolbar button:hover:not(:disabled) { background: var(--bg-muted); }
36+
.toolbar button.active {
37+
background: var(--accent-soft);
38+
border-color: var(--accent);
39+
color: var(--accent);
40+
}
41+
.toolbar button:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; }
42+
43+
.toolbar button.italic { font-style: italic; }
44+
.toolbar button.underline { text-decoration: underline; }
45+
46+
.toolbar button.custom {
47+
font-weight: 500;
48+
padding: 0 12px;
49+
border-color: var(--border);
50+
}
51+
.toolbar button.custom:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
52+
53+
#editor { flex: 1; overflow: auto; padding: 12px; background: var(--bg-muted); }
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)