Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/workflows/ci-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,35 @@ jobs:
working-directory: examples/__tests__
run: EXAMPLE=advanced/extensions/${{ matrix.example }} npx playwright test

custom-ui:
needs: build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
surface: [toolbar]
framework: [vanilla]
steps:
- name: Restore workspace
uses: actions/cache/restore@v4
with:
path: |
.
~/.cache/ms-playwright
key: examples-workspace-${{ github.sha }}

# The vanilla custom-ui examples have a `predev` hook that
# runs `pnpm --filter superdoc build`, and the playwright
# config falls back to `pnpm --dir <example> run dev` when an
# example has no local node_modules (the pnpm-hoisted layout).
# Both require pnpm on PATH; the cache restore alone does not
# provide it.
- uses: pnpm/action-setup@v4

- name: Run smoke test
working-directory: examples/__tests__
run: EXAMPLE=editor/custom-ui/${{ matrix.surface }}/${{ matrix.framework }} npx playwright test

headless:
needs: build
runs-on: ubuntu-latest
Expand All @@ -213,7 +242,7 @@ jobs:
validate:
name: CI Examples / validate
if: always()
needs: [getting-started, collaboration, built-in-ui, ai, advanced-headless-toolbar, advanced-extensions, headless]
needs: [getting-started, collaboration, built-in-ui, ai, advanced-headless-toolbar, advanced-extensions, custom-ui, headless]
runs-on: ubuntu-latest
steps:
- name: Check results
Expand Down
8 changes: 8 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ Patterns for the browser editor surface.
| [track-changes](./editor/built-in-ui/track-changes) | [docs](https://docs.superdoc.dev/editor/built-in-ui/track-changes) |
| [toolbar](./editor/built-in-ui/toolbar) | [docs](https://docs.superdoc.dev/editor/built-in-ui/toolbar) |

### Custom UI

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.

| Example | Docs |
|---------|------|
| [toolbar/vanilla](./editor/custom-ui/toolbar/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/overview) |

### Theming

| Example | Docs |
Expand Down
26 changes: 26 additions & 0 deletions examples/editor/custom-ui/toolbar/vanilla/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Custom UI: vanilla toolbar

A custom SuperDoc toolbar in plain TypeScript. Single file, no framework, copy-paste into your own app.

## What this teaches

- `createSuperDocUI({ superdoc })` for the controller surface.
- `ui.createScope()` for lifecycle, with auto-cascade on `ui.destroy()`.
- Per-command `observe(state => ...)` so each button only re-renders when its own command changes.
- `ui.commands.has(id)` and `ui.commands.require(id)` to validate a config-driven button list.
- One custom command registered via `scope.register(...)`, auto-unregistered 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 overview](https://docs.superdoc.dev/editor/custom-ui/overview)
- React equivalent: [`demos/custom-ui`](../../../../demos/custom-ui) (composed end-to-end app)
- Headless Toolbar (lower-level alternative): [`examples/advanced/headless-toolbar`](../../../advanced/headless-toolbar)
13 changes: 13 additions & 0 deletions examples/editor/custom-ui/toolbar/vanilla/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SuperDoc Custom UI: vanilla toolbar</title>
</head>
<body>
<div id="toolbar"></div>
<div id="editor"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions examples/editor/custom-ui/toolbar/vanilla/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@superdoc-examples/custom-ui-toolbar-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:"
}
}
Binary file not shown.
116 changes: 116 additions & 0 deletions examples/editor/custom-ui/toolbar/vanilla/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Custom toolbar (vanilla TypeScript), single file.
*
* Wires SuperDoc's UI controller to a hand-rolled toolbar. Three
* patterns to notice:
*
* 1. createSuperDocUI({ superdoc }) accepts the SuperDoc instance
* directly. No cast.
* 2. 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.
* 3. BUILT_IN_COMMAND_IDS + ui.commands.has(id) validate a
* config-driven button list at startup so a typo cannot ship
* silently. ui.commands.require(id) throws on unknown ids at
* trusted dispatch sites.
*/

import { SuperDoc } from 'superdoc';
import {
BUILT_IN_COMMAND_IDS,
createSuperDocUI,
type PublicToolbarItemId,
} from 'superdoc/ui';
import 'superdoc/style.css';
import './style.css';

// Compile-time-typed config. TypeScript verifies every id is a real
// built-in. The runtime check below catches dynamic / config-driven
// arrays the type system cannot see (feature flags, user settings).
const BUTTONS: readonly PublicToolbarItemId[] = ['bold', 'italic', 'underline', 'undo', 'redo'];

const LABELS: Partial<Record<PublicToolbarItemId, string>> = {
bold: 'B',
italic: 'I',
underline: 'U',
undo: '↶',
redo: '↷',
};

const superdoc = new SuperDoc({
selector: '#editor',
document: '/test_file.docx',
documentMode: 'editing',
});

const ui = createSuperDocUI({ superdoc });
const scope = ui.createScope();

// Custom command. scope.register(...) is a passthrough to
// ui.commands.register(...) that auto-unregisters when the scope (or
// the controller) is destroyed.
scope.register({
id: 'company.insertClause',
getState: ({ state }) => ({ disabled: state.selection.selectionTarget == null }),
execute: ({ editor }) => {
const target = ui.selection.getSnapshot().selectionTarget;
if (!target || !editor?.doc?.insert) return false;
return editor.doc.insert({ target, value: ' [Standard MSA boilerplate] ', type: 'text' }).success;
},
});

const toolbarEl = document.querySelector<HTMLElement>('#toolbar')!;

// Built-in buttons. Each button binds to its OWN command's state via
// observe(state => ...), so unrelated state changes never re-render
// the button. Equivalent to React's useSuperDocCommand(id).
for (const id of BUTTONS) {
if (!ui.commands.has(id)) {
console.warn(`[toolbar] unknown command id: ${id}`);
continue;
}
const handle = ui.commands.require(id);
const btn = document.createElement('button');
btn.className = 'tb-btn';
btn.textContent = LABELS[id] ?? id;
btn.addEventListener('click', () => handle.execute());
scope.add(
handle.observe((state) => {
btn.classList.toggle('active', !!state.active);
btn.disabled = !!state.disabled;
}),
);
toolbarEl.appendChild(btn);
}

// Custom command button. Same observe / execute shape as built-ins;
// `ui.commands.require(id)` returns a typed handle for either.
const customHandle = ui.commands.require('company.insertClause');
const insertBtn = document.createElement('button');
insertBtn.className = 'tb-btn tb-btn-pill';
insertBtn.textContent = 'Insert clause';
insertBtn.addEventListener('click', () => {
void customHandle.execute();
});
scope.add(
customHandle.observe((state) => {
insertBtn.disabled = !!state.disabled;
}),
);
toolbarEl.appendChild(insertBtn);

// Quick reference for consumers reading this file: BUILT_IN_COMMAND_IDS
// is the readonly list of every valid built-in. Useful for validating
// configs loaded from outside the type system (feature flags, user
// settings, plugin manifests).
void BUILT_IN_COMMAND_IDS;

// One teardown for the whole app. ui.destroy() cascades into every
// scope created from this controller, so consumers do not need a
// separate scope.destroy() call.
const teardown = () => {
ui.destroy();
superdoc.destroy();
};
window.addEventListener('beforeunload', teardown);
if (import.meta.hot) import.meta.hot.dispose(teardown);
39 changes: 39 additions & 0 deletions examples/editor/custom-ui/toolbar/vanilla/src/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
:root {
--bg: #fff;
--bg-muted: #f7f7f8;
--border: #e4e4e7;
--text: #18181b;
--text-muted: #71717a;
--accent: #2563eb;
--accent-soft: #eff6ff;
}

* { 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 { font: inherit; cursor: pointer; }

#toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: var(--bg);
border-bottom: 1px solid var(--border);
}

.tb-btn {
height: 30px;
min-width: 30px;
padding: 0 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--text);
}
.tb-btn:hover:not(:disabled) { background: var(--bg-muted); border-color: var(--border); }
.tb-btn.active { background: var(--accent-soft); color: var(--accent); }
.tb-btn:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; }
.tb-btn.tb-btn-pill { padding: 0 12px; font-size: 13px; border-color: var(--border); margin-left: auto; }
16 changes: 16 additions & 0 deletions examples/editor/custom-ui/toolbar/vanilla/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
3 changes: 3 additions & 0 deletions examples/editor/custom-ui/toolbar/vanilla/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'vite';

export default defineConfig({});
10 changes: 10 additions & 0 deletions examples/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@
"docs": "https://docs.superdoc.dev/editor/theming/overview",
"ci": false
},
{
"id": "editor-custom-ui-toolbar-vanilla",
"title": "Custom toolbar (vanilla)",
"category": "Editor",
"surface": "Custom UI",
"sourceRepo": "superdoc-dev/superdoc",
"sourcePath": "examples/editor/custom-ui/toolbar/vanilla",
"docs": "https://docs.superdoc.dev/editor/custom-ui/overview",
"ci": true
},
{
"id": "editor-spell-check-typo-js",
"title": "Spell check with Typo.js",
Expand Down
1 change: 1 addition & 0 deletions packages/super-editor/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
export { createSuperDocUI } from './create-super-doc-ui.js';
export { shallowEqual } from './equality.js';
export { BUILT_IN_COMMAND_IDS } from '../headless-toolbar/types.js';
export type { PublicToolbarItemId } from '../headless-toolbar/types.js';

// Re-export the document-side shapes the controller surfaces so
// consumers can type their components without reaching into the
Expand Down
1 change: 1 addition & 0 deletions packages/superdoc/src/ui.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
BUILT_IN_COMMAND_IDS,
createSuperDocUI,
shallowEqual,
type PublicToolbarItemId,
type CommandHandle,
type CommandsHandle,
type CommentAddress,
Expand Down
Loading
Loading