Commit b1bb6e7
authored
feat(superdoc/ui): custom toolbar command registration (SD-2802) (#3004)
* feat(superdoc/ui): custom toolbar command registration (SD-2802)
ui.commands was a closed registry — 38 hardcoded ids, no extension
hook. Every TipTap / CKEditor / TinyMCE consumer with custom toolbar
buttons (AI rewrite, insert mention, internal workflow actions, slash
commands) had to fork the registry or work around it. The drop-in
replacement story isn't real until consumers can wire their own
buttons through the same surface as built-ins.
Add ui.commands.register(...) returning a typed registration object:
const ai = ui.commands.register<{ prompt: string }>({
id: 'company.aiRewrite',
execute: async ({ payload, superdoc }) => { ... return true; },
getState: ({ state }) => ({ active: false, disabled: !ready }),
});
ai.handle.execute({ prompt: 'fix tone' }); // typed
ai.invalidate(); // re-run getState
ai.unregister(); // idempotent
Custom commands appear in ui.toolbar.snapshot.commands alongside
built-ins. Every entry now carries source: 'built-in' | 'custom' so
consumers can render one uniform toolbar without branching on the id.
Built-in collisions are refused by default with a console warning;
override: true on the registration replaces the built-in deliberately.
Custom-vs-custom replacement warns and replaces. getState errors fall
back to a static disabled-false state and log once per unique error
message. Async execute is supported and normalized to boolean.
invalidate() exists because custom command state often depends on
external app state (permissions, AI quota, upload progress) that
SuperDoc has no way to observe via editor events. Consumers wire it
to whatever signal their app uses; the controller microtask-coalesces
the resulting snapshot rebuild.
The captured registration handle is the realistic typed path —
indexing ui.commands['company.aiRewrite'] degrades to unknown without
module augmentation. Don't promise type safety we can't deliver.
Backed by 14 new unit tests in custom-commands.test.ts. Existing 81
ui tests continue to pass. tsc -b clean.
* fix(superdoc/ui): four correctness fixes from PR #3004 review (SD-2802)
All four were verified by failing tests/typecheck before fixing:
1. Default TPayload to `void` instead of `unknown`. Without this,
`register({ id, execute: () => true })` returned a handle whose
zero-arg `handle.execute()` was a type error — consumers had to
write `register<void>({...})` for every payload-less button.
2. Type `snapshot.commands` as `{ [id: string]: ... | undefined }`.
The prior `Record<string, ...>` claimed every string lookup
returned a state, but at runtime unregistered ids return undefined.
Consumers writing `snapshot.commands[id].disabled` would crash.
3. Preserve `null` returned from `getState`. The old
`derived?.value ?? STATIC_CUSTOM_STATE.value` collapsed null to
undefined, so a custom command using null to mean "no current
value" (matching built-ins like link / text-color) couldn't.
4. Stop observers firing after unregister. The Subscribable lives on
the controller's selector substrate and outlives the registration;
without an explicit early-return the next snapshot rebuild emitted
the static fallback `{ disabled: false }` to active observers,
leaving stale buttons enabled. The observe wrapper now detaches
its inner subscription on the first post-unregister emit.
Adds four regression tests; total 18 in custom-commands.test.ts (up
from 14). All 99 ui tests pass, tsc -b clean.
* fix(superdoc/ui): identity-guard register lifecycle + active observer disposal (SD-2802)
PR #3004 second review pass.
Bot P1: A's stale `unregister()` would delete B's replacement.
// before: identity-blind
unregister() { entries.delete(id); ... }
// after: identity-checked
unregister() {
if (entries.get(id) !== ownEntry) return; // stale call from prior owner
entries.delete(id);
...
}
Same guard on `invalidate()`. Verified by failing test before fix.
Bot P2: existing observers attached to a registration are now actively
disposed during `unregister()` and during register-time replacement.
The lazy `!entries.has(id)` short-circuit in the observer wrapper is
kept as a safety net — but no longer the only mechanism. A new
`observerDisposers: Map<string, Set<() => void>>` tracks each active
observer's teardown so the registry can call them all on demand.
Three new regression tests:
- A.unregister after B replaced does not remove B
- A.invalidate after B replaced does not re-emit on B's observer
- Replacement actively disposes prior-registration observers
Total 21 tests in custom-commands.test.ts (up from 18). All 102 ui
tests pass, tsc -b clean.1 parent e3eaed9 commit b1bb6e7
6 files changed
Lines changed: 1171 additions & 7 deletions
File tree
- packages
- super-editor/src/ui
- superdoc/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| 19 | + | |
19 | 20 | | |
20 | 21 | | |
21 | 22 | | |
| |||
34 | 35 | | |
35 | 36 | | |
36 | 37 | | |
| 38 | + | |
| 39 | + | |
37 | 40 | | |
38 | 41 | | |
39 | 42 | | |
| |||
231 | 234 | | |
232 | 235 | | |
233 | 236 | | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
234 | 243 | | |
235 | 244 | | |
236 | 245 | | |
| |||
471 | 480 | | |
472 | 481 | | |
473 | 482 | | |
474 | | - | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
475 | 504 | | |
476 | 505 | | |
477 | 506 | | |
478 | | - | |
| 507 | + | |
479 | 508 | | |
480 | 509 | | |
481 | 510 | | |
| |||
491 | 520 | | |
492 | 521 | | |
493 | 522 | | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
494 | 533 | | |
495 | 534 | | |
496 | 535 | | |
| |||
667 | 706 | | |
668 | 707 | | |
669 | 708 | | |
670 | | - | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
671 | 714 | | |
672 | 715 | | |
673 | 716 | | |
| |||
736 | 779 | | |
737 | 780 | | |
738 | 781 | | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
739 | 796 | | |
740 | 797 | | |
741 | 798 | | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
742 | 812 | | |
743 | 813 | | |
744 | 814 | | |
| |||
0 commit comments