Skip to content

Commit 67f9d5a

Browse files
committed
feat(examples): vanilla custom UI toolbar example (SD-2929)
First focused minimal example under examples/editor/custom-ui/. Single file (~99 lines) demonstrating the cleaned-up controller surface from SD-2917 / SD-2918 / SD-2919 / SD-2920: - createSuperDocUI({ superdoc }) accepts the SuperDoc instance directly. No cast. - ui.createScope() collects every subscription, custom command registration, and DOM listener; ui.destroy() cascades into every scope so consumers tear everything down with one call. - Per-command observe(state => ...) so each button only re-renders when its own command flips, matching the React useSuperDocCommand pattern. - BUILT_IN_COMMAND_IDS, ui.commands.has(id), and ui.commands.require(id) validate a config-driven button list at startup so a typo cannot ship silently. - One custom command via scope.register(...), auto-unregistered on scope teardown. Also surfaced and fixed: PublicToolbarItemId was added to the runtime exports of superdoc/ui (SD-2920) but never re-exported as a type from the public sub-entry. This commit threads it through super-editor/src/ui/index.ts and superdoc/src/ui.d.ts so consumers can type their config arrays without dipping into the headless-toolbar entry. Wired into examples/manifest.json and a new custom-ui CI smoke job. Stacked on caio/sd-2928-examples-demos-reorg until #3127 merges.
1 parent e627b28 commit 67f9d5a

13 files changed

Lines changed: 319 additions & 254 deletions

File tree

.github/workflows/ci-examples.yml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,27 @@ jobs:
194194
working-directory: examples/__tests__
195195
run: EXAMPLE=advanced/extensions/${{ matrix.example }} npx playwright test
196196

197+
custom-ui:
198+
needs: build
199+
runs-on: ubuntu-latest
200+
strategy:
201+
fail-fast: false
202+
matrix:
203+
surface: [toolbar]
204+
framework: [vanilla]
205+
steps:
206+
- name: Restore workspace
207+
uses: actions/cache/restore@v4
208+
with:
209+
path: |
210+
.
211+
~/.cache/ms-playwright
212+
key: examples-workspace-${{ github.sha }}
213+
214+
- name: Run smoke test
215+
working-directory: examples/__tests__
216+
run: EXAMPLE=editor/custom-ui/${{ matrix.surface }}/${{ matrix.framework }} npx playwright test
217+
197218
headless:
198219
needs: build
199220
runs-on: ubuntu-latest
@@ -213,7 +234,7 @@ jobs:
213234
validate:
214235
name: CI Examples / validate
215236
if: always()
216-
needs: [getting-started, collaboration, built-in-ui, ai, advanced-headless-toolbar, advanced-extensions, headless]
237+
needs: [getting-started, collaboration, built-in-ui, ai, advanced-headless-toolbar, advanced-extensions, custom-ui, headless]
217238
runs-on: ubuntu-latest
218239
steps:
219240
- name: Check results

examples/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ 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+
Build your own toolbar, comments sidebar, and review panel against the `superdoc/ui` controller. Each example teaches one surface in the smallest amount of code.
39+
40+
| Example | Docs |
41+
|---------|------|
42+
| [toolbar/vanilla](./editor/custom-ui/toolbar/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/overview) |
43+
3644
### Theming
3745

3846
| Example | Docs |
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Custom UI: vanilla toolbar
2+
3+
A custom SuperDoc toolbar in plain TypeScript. Single file, no framework, copy-paste into your own app.
4+
5+
## What this teaches
6+
7+
- `createSuperDocUI({ superdoc })` for the controller surface.
8+
- `ui.createScope()` for lifecycle, with auto-cascade on `ui.destroy()`.
9+
- Per-command `observe(state => ...)` so each button only re-renders when its own command changes.
10+
- `ui.commands.has(id)` and `ui.commands.require(id)` to validate a config-driven button list.
11+
- One custom command registered via `scope.register(...)`, auto-unregistered 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 overview](https://docs.superdoc.dev/editor/custom-ui/overview)
25+
- React equivalent: [`demos/custom-ui`](../../../../demos/custom-ui) (composed end-to-end app)
26+
- Headless Toolbar (lower-level alternative): [`examples/advanced/headless-toolbar`](../../../advanced/headless-toolbar)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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 toolbar</title>
7+
</head>
8+
<body>
9+
<div id="toolbar"></div>
10+
<div id="editor"></div>
11+
<script type="module" src="/src/main.ts"></script>
12+
</body>
13+
</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-toolbar-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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Custom toolbar (vanilla TypeScript), single file.
3+
*
4+
* Wires SuperDoc's UI controller to a hand-rolled toolbar. Three
5+
* patterns to notice:
6+
*
7+
* 1. createSuperDocUI({ superdoc }) accepts the SuperDoc instance
8+
* directly. No cast.
9+
* 2. ui.createScope() collects every subscription, custom command
10+
* registration, and DOM listener. ui.destroy() cascades into
11+
* every scope so consumers tear everything down with one call.
12+
* 3. BUILT_IN_COMMAND_IDS + ui.commands.has(id) validate a
13+
* config-driven button list at startup so a typo cannot ship
14+
* silently. ui.commands.require(id) throws on unknown ids at
15+
* trusted dispatch sites.
16+
*/
17+
18+
import { SuperDoc } from 'superdoc';
19+
import {
20+
BUILT_IN_COMMAND_IDS,
21+
createSuperDocUI,
22+
type PublicToolbarItemId,
23+
} from 'superdoc/ui';
24+
import 'superdoc/style.css';
25+
import './style.css';
26+
27+
// Compile-time-typed config. TypeScript verifies every id is a real
28+
// built-in. The runtime check below catches dynamic / config-driven
29+
// arrays the type system cannot see (feature flags, user settings).
30+
const BUTTONS: readonly PublicToolbarItemId[] = ['bold', 'italic', 'underline', 'undo', 'redo'];
31+
32+
const LABELS: Partial<Record<PublicToolbarItemId, string>> = {
33+
bold: 'B',
34+
italic: 'I',
35+
underline: 'U',
36+
undo: '↶',
37+
redo: '↷',
38+
};
39+
40+
const superdoc = new SuperDoc({
41+
selector: '#editor',
42+
document: '/test_file.docx',
43+
documentMode: 'editing',
44+
});
45+
46+
const ui = createSuperDocUI({ superdoc });
47+
const scope = ui.createScope();
48+
49+
// Custom command. scope.register(...) is a passthrough to
50+
// ui.commands.register(...) that auto-unregisters when the scope (or
51+
// the controller) is destroyed.
52+
scope.register({
53+
id: 'company.insertClause',
54+
getState: ({ state }) => ({ disabled: state.selection.selectionTarget == null }),
55+
execute: ({ editor }) => {
56+
const target = ui.selection.getSnapshot().selectionTarget;
57+
if (!target || !editor?.doc?.insert) return false;
58+
return editor.doc.insert({ target, value: ' [Standard MSA boilerplate] ', type: 'text' }).success;
59+
},
60+
});
61+
62+
const toolbarEl = document.querySelector<HTMLElement>('#toolbar')!;
63+
64+
// Built-in buttons. Each button binds to its OWN command's state via
65+
// observe(state => ...), so unrelated state changes never re-render
66+
// the button. Equivalent to React's useSuperDocCommand(id).
67+
for (const id of BUTTONS) {
68+
if (!ui.commands.has(id)) {
69+
console.warn(`[toolbar] unknown command id: ${id}`);
70+
continue;
71+
}
72+
const handle = ui.commands.require(id);
73+
const btn = document.createElement('button');
74+
btn.className = 'tb-btn';
75+
btn.textContent = LABELS[id] ?? id;
76+
btn.addEventListener('click', () => handle.execute());
77+
scope.add(
78+
handle.observe((state) => {
79+
btn.classList.toggle('active', !!state.active);
80+
btn.disabled = !!state.disabled;
81+
}),
82+
);
83+
toolbarEl.appendChild(btn);
84+
}
85+
86+
// Custom command button. Same observe / execute shape as built-ins;
87+
// `ui.commands.require(id)` returns a typed handle for either.
88+
const customHandle = ui.commands.require('company.insertClause');
89+
const insertBtn = document.createElement('button');
90+
insertBtn.className = 'tb-btn tb-btn-pill';
91+
insertBtn.textContent = 'Insert clause';
92+
insertBtn.addEventListener('click', () => {
93+
void customHandle.execute();
94+
});
95+
scope.add(
96+
customHandle.observe((state) => {
97+
insertBtn.disabled = !!state.disabled;
98+
}),
99+
);
100+
toolbarEl.appendChild(insertBtn);
101+
102+
// Quick reference for consumers reading this file: BUILT_IN_COMMAND_IDS
103+
// is the readonly list of every valid built-in. Useful for validating
104+
// configs loaded from outside the type system (feature flags, user
105+
// settings, plugin manifests).
106+
void BUILT_IN_COMMAND_IDS;
107+
108+
// One teardown for the whole app. ui.destroy() cascades into every
109+
// scope created from this controller, so consumers do not need a
110+
// separate scope.destroy() call.
111+
const teardown = () => {
112+
ui.destroy();
113+
superdoc.destroy();
114+
};
115+
window.addEventListener('beforeunload', teardown);
116+
if (import.meta.hot) import.meta.hot.dispose(teardown);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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, 'Segoe UI', sans-serif; font-size: 14px; color: var(--text); background: var(--bg-muted); }
13+
button { font: inherit; cursor: pointer; }
14+
15+
#toolbar {
16+
display: flex;
17+
align-items: center;
18+
gap: 4px;
19+
padding: 8px 12px;
20+
background: var(--bg);
21+
border-bottom: 1px solid var(--border);
22+
}
23+
24+
.tb-btn {
25+
height: 30px;
26+
min-width: 30px;
27+
padding: 0 8px;
28+
display: inline-flex;
29+
align-items: center;
30+
justify-content: center;
31+
background: transparent;
32+
border: 1px solid transparent;
33+
border-radius: 4px;
34+
color: var(--text);
35+
}
36+
.tb-btn:hover:not(:disabled) { background: var(--bg-muted); border-color: var(--border); }
37+
.tb-btn.active { background: var(--accent-soft); color: var(--accent); }
38+
.tb-btn:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; }
39+
.tb-btn.tb-btn-pill { padding: 0 12px; font-size: 13px; border-color: var(--border); margin-left: auto; }
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)