Skip to content

Commit b1bb6e7

Browse files
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/create-super-doc-ui.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
} from '@superdoc/document-api';
1717
import { shallowEqual } from './equality.js';
1818
import { scrollRangeIntoView } from './scroll-into-view.js';
19+
import { createCustomCommandsRegistry } from './custom-commands.js';
1920
import type {
2021
CommandHandle,
2122
CommandsHandle,
@@ -34,6 +35,8 @@ import type {
3435
Subscribable,
3536
ToolbarCommandHandleState,
3637
ToolbarHandle,
38+
ToolbarSnapshotSlice,
39+
UIToolbarCommandState,
3740
ViewportGetRectInput,
3841
ViewportHandle,
3942
ViewportRect,
@@ -231,6 +234,12 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
231234
}
232235
});
233236

237+
// Custom-commands registry — built lazily so its hooks (scheduleNotify,
238+
// buildSubscribable, isBuiltIn) can reference the substrate primitives
239+
// declared further down. The actual registry instance is created after
240+
// `select` is in scope.
241+
const BUILT_IN_COMMAND_ID_SET: Set<string> = new Set(ALL_TOOLBAR_COMMAND_IDS);
242+
234243
// Comments slice cache. `editor.doc.comments.list()` is O(N) and
235244
// re-running it on every `computeState()` would tax the hot path —
236245
// instead we cache the list result and refresh on `commentsUpdate` /
@@ -471,11 +480,31 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
471480
selectionMemo = { key: selectionKey, slice: selectionSlice };
472481
}
473482

474-
return {
483+
// Built-in commands are tagged with `source: 'built-in'` so consumers
484+
// can render one uniform toolbar without branching on the id.
485+
// Custom commands (registered via `ui.commands.register`) are merged
486+
// in below, after the rest of the state is built — their `getState`
487+
// callback receives the same `SuperDocUIState` we return here so the
488+
// deriver can read selection, document mode, etc. without dipping
489+
// back into the controller.
490+
const builtInCommands: Record<string, UIToolbarCommandState> = {};
491+
if (toolbarSnapshot.commands) {
492+
for (const [id, cmdState] of Object.entries(toolbarSnapshot.commands)) {
493+
if (!cmdState) continue;
494+
builtInCommands[id] = {
495+
active: cmdState.active,
496+
disabled: cmdState.disabled,
497+
value: cmdState.value,
498+
source: 'built-in',
499+
};
500+
}
501+
}
502+
503+
const partial: SuperDocUIState = {
475504
ready,
476505
documentMode,
477506
selection: selectionSlice,
478-
toolbar: toolbarSnapshot,
507+
toolbar: { context: toolbarSnapshot.context, commands: builtInCommands } as ToolbarSnapshotSlice,
479508
comments: {
480509
total: commentsListCache.total,
481510
items: commentsListCache.items,
@@ -491,6 +520,16 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
491520
},
492521
review: reviewSlice,
493522
};
523+
524+
const customCommandStates = customCommandsRegistry.computeStates(partial);
525+
const mergedCommands: Record<string, UIToolbarCommandState> = customCommandStates
526+
? { ...builtInCommands, ...customCommandStates }
527+
: builtInCommands;
528+
529+
return {
530+
...partial,
531+
toolbar: { context: toolbarSnapshot.context, commands: mergedCommands } as ToolbarSnapshotSlice,
532+
};
494533
};
495534

496535
// Wire SuperDoc-instance events. The wrapper-side bus (editorCreate /
@@ -667,7 +706,11 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
667706
// built-in SuperToolbar.vue (and external standalone-controller
668707
// consumers) can swap to ui.toolbar without API churn.
669708
const toolbar: ToolbarHandle = {
670-
getSnapshot: () => toolbarController.getSnapshot(),
709+
// Pull from `state.toolbar` (post-merge with custom commands and
710+
// tagged with `source`) rather than the bare headless-toolbar
711+
// snapshot — the public `ToolbarSnapshotSlice` shape is the merged
712+
// one, not the underlying built-ins-only shape.
713+
getSnapshot: () => computeState().toolbar,
671714
subscribe(listener) {
672715
// Drives off the same selector substrate so subscribers receive
673716
// the same coalesced burst pattern as ui.select consumers.
@@ -736,9 +779,36 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
736779
};
737780
};
738781

782+
// Custom commands registry. Wires the substrate primitives (selectors
783+
// for state observation, scheduleNotify for re-emit) to the registry
784+
// so registered commands ride the same dedupe/coalesce posture as
785+
// built-ins. Built-in collisions are refused without `override: true`.
786+
const customCommandsRegistry = createCustomCommandsRegistry({
787+
superdoc,
788+
isBuiltIn: (id) => BUILT_IN_COMMAND_ID_SET.has(id),
789+
scheduleNotify,
790+
buildSubscribable: (id) => select((state) => state.toolbar.commands?.[id], shallowEqual),
791+
});
792+
teardown.push(() => {
793+
customCommandsRegistry.destroy();
794+
});
795+
739796
const commands = new Proxy({} as CommandsHandle, {
740797
get(_, prop) {
741798
if (typeof prop !== 'string') return undefined;
799+
// `register` is the one non-id key on the Proxy. Delegates to the
800+
// custom-commands registry; everything else flows through the
801+
// per-id handle cache below.
802+
if (prop === 'register') {
803+
return customCommandsRegistry.register.bind(customCommandsRegistry);
804+
}
805+
// Custom-registered ids surface a typed handle from the registry.
806+
// Built-in ids fall through to the existing per-id cache so they
807+
// keep the same observe/execute shape they had before SD-2802.
808+
if (customCommandsRegistry.has(prop)) {
809+
const customHandle = customCommandsRegistry.getHandle(prop);
810+
if (customHandle) return customHandle;
811+
}
742812
let handle = commandHandleCache.get(prop);
743813
if (handle) return handle;
744814
handle = buildCommandHandle(prop as PublicToolbarItemId);

0 commit comments

Comments
 (0)