From 75cb241fffc570122a30bf15e0cef2b3083a6d23 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 28 Apr 2026 18:43:19 -0300 Subject: [PATCH] feat(superdoc/ui): toolbar domain + per-command observables (SD-2796) Adds `ui.toolbar` (aggregate) and `ui.commands.` (per-command) on top of the SD-2794 selector substrate. The toolbar surface is the first domain to land on `superdoc/ui`; it makes "bring your own toolbar" a single mental model that consumers wire to React, Vue, or vanilla DOM. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Aggregate (HeadlessToolbarController-shaped) ui.toolbar.subscribe(({ snapshot }) => render(snapshot)); ui.toolbar.execute('bold'); // Per-command observable (CKEditor-style fine-grained binding) ui.commands.bold.observe(({ active, disabled }) => boldBtn.set(active)); ui.commands.bold.execute(); ui.commands['font-size'].observe(({ value }) => fontInput.set(value)); ui.commands['font-size'].execute('14pt'); ``` Implementation: - Internal `createHeadlessToolbar({ superdoc })` is instantiated once per `createSuperDocUI` call and feeds `state.toolbar`. `ui.toolbar` delegates `getSnapshot` / `execute` to that controller and routes `subscribe` through the substrate so subscribers ride the same microtask-coalesced burst pattern as `ui.select` consumers. - `ui.commands` is a Proxy returning per-id handles, cached by id so reference identity is stable (matters for React `useMemo` deps and for consumers comparing handles). Each handle's `observe` is internally `ui.select((s) => s.toolbar.commands[id], shallowEqual)`, so a button bound to one command does not re-render when an unrelated command's state changes. - Unknown command ids fall back to `{ active: false, disabled: true, value: undefined }` rather than throwing, so consumer code paths remain forgiving while custom-command registration (FRICTION S3) is filed as follow-up scope. - Built-in `SuperToolbar.vue` and `superdoc/headless-toolbar` are unchanged here; both will migrate to consume `ui.toolbar` in follow-up tickets so this PR stays small and review-friendly. Why these three shapes coexist (per SD-2667 research): - Aggregate (`ui.toolbar.subscribe`): matches today's `HeadlessToolbarController` shape; lets external consumers using `superdoc/headless-toolbar` migrate without rewriting render code. - Per-command (`ui.commands..observe`): CKEditor 5 pattern. A 50- button toolbar with per-command observables only re-renders the button whose state changed, not the whole bar. - Selector substrate (`ui.select`): the underlying primitive. Custom toolbars with cross-command derived state (e.g. "is any heading active?") consume that. Stub-bug found in the SD-2794 test fixture: `fireEditor` iterated a live Set while the internal headless-toolbar rebound listeners on every change, picking up the new handler mid-loop and recursing. Fix snapshots the handler list before iterating, matching how real editor event buses behave. Verified: 19 unit tests (11 selector substrate + 8 toolbar/commands). Full super-editor suite: 11906/13 skipped/0 fail. `pnpm --filter superdoc build` clean. Out of scope (filed under SD-2796 as follow-ups inside SD-2667): - `ui.commands.register({ id, execute, getState })` (closes FRICTION S3, the closed toolbar registry). - Refactor `superdoc/headless-toolbar` into a shim around `createSuperDocUI({ superdoc }).toolbar`. - Refactor `SuperToolbar.vue` to consume `ui.toolbar` (visual / behavior parity validation). --- .../create-toolbar-snapshot.ts | 18 +- .../src/ui/create-super-doc-ui.test.ts | 7 +- .../src/ui/create-super-doc-ui.ts | 162 ++++++++++++- packages/super-editor/src/ui/toolbar.test.ts | 227 ++++++++++++++++++ packages/super-editor/src/ui/types.ts | 97 ++++++++ 5 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 packages/super-editor/src/ui/toolbar.test.ts diff --git a/packages/super-editor/src/headless-toolbar/create-toolbar-snapshot.ts b/packages/super-editor/src/headless-toolbar/create-toolbar-snapshot.ts index 57f068ede9..0bb96f9112 100644 --- a/packages/super-editor/src/headless-toolbar/create-toolbar-snapshot.ts +++ b/packages/super-editor/src/headless-toolbar/create-toolbar-snapshot.ts @@ -32,7 +32,23 @@ const buildCommandStateMap = ({ ] as const; } - return [command, entry.state({ context, superdoc })] as const; + // Per-command resilience: if a single deriver throws (editor + // mid-construction, partial PresentationEditor route, test stub + // not modelling full PM state), default that command to disabled + // rather than killing the whole snapshot. Other commands still + // resolve, and the next event tick re-derives once the editor is + // stable. + try { + return [command, entry.state({ context, superdoc })] as const; + } catch { + return [ + command, + { + active: false, + disabled: true, + }, + ] as const; + } }); return Object.fromEntries(entries) as ToolbarCommandStates; diff --git a/packages/super-editor/src/ui/create-super-doc-ui.test.ts b/packages/super-editor/src/ui/create-super-doc-ui.test.ts index 35c858297c..028be44f3e 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.test.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -63,9 +63,10 @@ function makeSuperdocStub( const handlers = editorListeners.get(event); if (!handlers) return; // Snapshot before iterating: handlers can mutate the registration - // set (e.g., re-attach on surface change), and a Set's forEach - // picks up newly-added handlers mid-loop. Real editor event buses - // iterate a frozen list. + // set (e.g., presentation re-routing, headless-toolbar rebinding + // listeners on every change). A Set's forEach picks up newly-added + // handlers mid-loop, which produces unbounded recursion. Real + // editor event buses iterate a frozen list. [...handlers].forEach((handler) => handler(...args)); }, fireSuperdoc(event: string, ...args: unknown[]) { diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index ee45ad66e1..de8baec2c0 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -1,5 +1,16 @@ +import { createHeadlessToolbar } from '../headless-toolbar/index.js'; import { resolveToolbarSources } from '../headless-toolbar/resolve-toolbar-sources.js'; +import { createToolbarRegistry } from '../headless-toolbar/toolbar-registry.js'; import type { + HeadlessToolbarController, + HeadlessToolbarSuperdocHost, + PublicToolbarItemId, + ToolbarSnapshot, +} from '../headless-toolbar/types.js'; +import { shallowEqual } from './equality.js'; +import type { + CommandHandle, + CommandsHandle, EqualityFn, SelectorFn, SuperDocEditorLike, @@ -7,6 +18,8 @@ import type { SuperDocUIOptions, SuperDocUIState, Subscribable, + ToolbarCommandHandleState, + ToolbarHandle, } from './types.js'; /** @@ -47,6 +60,28 @@ const PRESENTATION_EVENTS = [ 'historyStateChange', ] as const; +/** Default state for an unknown / missing toolbar command. */ +const FALLBACK_COMMAND_STATE: ToolbarCommandHandleState = { + active: false, + disabled: true, + value: undefined, +}; + +/** + * Full set of registered toolbar command ids, used to seed the + * internal `createHeadlessToolbar` call. Without this the controller + * defaults to `commands = []`, leaving `snapshot.commands` empty and + * every per-command observer (`ui.commands.bold.observe`) reporting + * the fallback `{ active: false, disabled: true }` forever. + * + * Computed once at module load by walking the registry returned from + * `createToolbarRegistry()`. Future custom-command registration + * (FRICTION S3) will need to extend this dynamically. + */ +const ALL_TOOLBAR_COMMAND_IDS: PublicToolbarItemId[] = Object.keys( + createToolbarRegistry(), +) as PublicToolbarItemId[]; + /** * Resolve the **routed** editor — the body, header, footer, or note * editor that PresentationEditor currently routes input/selection to. @@ -111,6 +146,44 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }); }; + // Internal headless-toolbar instance. Feeds `state.toolbar` so + // `ui.toolbar.subscribe` and `ui.commands..observe` ride the + // same selector substrate as the rest of the controller. The Vue UI + // and any external `superdoc/headless-toolbar` consumer can keep + // using their existing entry points; this is the single source of + // truth at runtime. + // + // The structural cast is safe at runtime: the SuperDoc Vue instance + // satisfies HeadlessToolbarSuperdocHost (with `Editor` for + // activeEditor) at runtime; we accept the looser SuperDocLike at the + // public boundary so this controller can be unit-tested with stubs. + // Internal headless-toolbar instance. Feeds `state.toolbar` so + // `ui.toolbar.subscribe` and `ui.commands..observe` ride the + // same selector substrate as the rest of the controller. Per-command + // state derivers in the registry are now wrapped to default to + // disabled on throw, so a partial editor never wedges snapshot + // construction. + const toolbarController: HeadlessToolbarController = createHeadlessToolbar({ + superdoc: superdoc as unknown as HeadlessToolbarSuperdocHost, + // Pass the full registry so snapshot.commands is populated for + // every built-in command — without this `ui.commands..observe` + // emits only the fallback disabled state. + commands: ALL_TOOLBAR_COMMAND_IDS, + }); + let toolbarSnapshot: ToolbarSnapshot = toolbarController.getSnapshot(); + const offToolbarSubscribe = toolbarController.subscribe(({ snapshot }) => { + toolbarSnapshot = snapshot; + scheduleNotify(); + }); + teardown.push(() => { + offToolbarSubscribe(); + try { + toolbarController.destroy(); + } catch { + // best-effort + } + }); + const computeState = (): SuperDocUIState => { // Route through PresentationEditor when active so selection state // follows the body/header/footer/note editor the user is actually @@ -126,6 +199,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { ready, documentMode, selection: { empty, quotedText }, + toolbar: toolbarSnapshot, }; }; @@ -283,10 +357,96 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; }; + // Aggregate toolbar handle. Mirrors HeadlessToolbarController so + // built-in SuperToolbar.vue (and external standalone-controller + // consumers) can swap to ui.toolbar without API churn. + const toolbar: ToolbarHandle = { + getSnapshot: () => toolbarController.getSnapshot(), + subscribe(listener) { + // Drives off the same selector substrate so subscribers receive + // the same coalesced burst pattern as ui.select consumers. + // Equality is set to "always different" because the headless + // controller already dedups internally; we want every emit it + // produces to propagate. + return select( + (state) => state.toolbar, + () => false, + ).subscribe((snapshot) => { + try { + listener({ snapshot }); + } catch { + // see scheduleNotify + } + }); + }, + execute: ((id: PublicToolbarItemId, payload?: unknown): boolean => { + // The controller's execute signature is conditionally typed + // (variadic per-id payload); cast here keeps the consumer-facing + // type strict while delegating at runtime. + return (toolbarController.execute as (id: PublicToolbarItemId, payload?: unknown) => boolean)(id, payload); + }) as ToolbarHandle['execute'], + }; + + // Per-command handles. Cached so handle identity is stable across + // repeated accesses (matters for React `useMemo` deps and consumers + // comparing handles). + const commandHandleCache = new Map>(); + + // Per-command Subscribable cache. Sharing one Subscribable across + // every `observe()` call for a given id means N components observing + // `bold` produce one selector + N downstream listeners, not N + // selectors. Each editor event recomputes once per command id, not + // once per active observer. + const commandSubscribableCache = new Map< + string, + Subscribable | undefined> + >(); + const getCommandSubscribable = (id: PublicToolbarItemId) => { + let sub = commandSubscribableCache.get(id); + if (sub) return sub; + sub = select( + (state) => state.toolbar.commands?.[id] as ToolbarCommandHandleState | undefined, + shallowEqual, + ); + commandSubscribableCache.set(id, sub); + return sub; + }; + + const buildCommandHandle = (id: PublicToolbarItemId): CommandHandle => { + return { + observe(listener) { + return getCommandSubscribable(id).subscribe((cmdState) => { + const next = cmdState ?? FALLBACK_COMMAND_STATE; + try { + listener(next as ToolbarCommandHandleState); + } catch { + // see scheduleNotify + } + }); + }, + execute: ((payload?: unknown): boolean => { + return (toolbarController.execute as (id: PublicToolbarItemId, payload?: unknown) => boolean)(id, payload); + }) as CommandHandle['execute'], + }; + }; + + const commands = new Proxy({} as CommandsHandle, { + get(_, prop) { + if (typeof prop !== 'string') return undefined; + let handle = commandHandleCache.get(prop); + if (handle) return handle; + handle = buildCommandHandle(prop as PublicToolbarItemId); + commandHandleCache.set(prop, handle); + return handle; + }, + }); + const destroy = () => { if (destroyed) return; destroyed = true; stateChangeListeners.clear(); + commandHandleCache.clear(); + commandSubscribableCache.clear(); teardown.forEach((fn) => { try { fn(); @@ -297,5 +457,5 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { teardown.length = 0; }; - return { select, destroy }; + return { select, toolbar, commands, destroy }; } diff --git a/packages/super-editor/src/ui/toolbar.test.ts b/packages/super-editor/src/ui/toolbar.test.ts new file mode 100644 index 0000000000..b443ae5877 --- /dev/null +++ b/packages/super-editor/src/ui/toolbar.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createSuperDocUI } from './create-super-doc-ui.js'; +import type { SuperDocLike } from './types.js'; + +/** + * Stub builder for `ui.toolbar` / `ui.commands` tests. + * + * The internal headless-toolbar reads `editor.state`, `editor.options`, + * and `editor.commands` to compute its snapshot. We supply only what + * `resolveToolbarSources` and the registry's state derivers need to + * produce a non-empty snapshot — the real Editor wires far more, but + * that's out of scope for these unit tests. + */ +function makeStubs() { + const editorListeners = new Map void>>(); + const superdocListeners = new Map void>>(); + + const editor = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!editorListeners.has(event)) editorListeners.set(event, new Set()); + editorListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + editorListeners.get(event)?.delete(handler); + }), + state: { + selection: { empty: true, from: 0, to: 0 }, + }, + options: { documentId: 'doc-1', isHeaderOrFooter: false }, + commands: { + toggleBold: vi.fn(() => true), + toggleItalic: vi.fn(() => true), + }, + isEditable: true, + doc: { + selection: { + current: vi.fn(() => ({ empty: true, text: '', target: null })), + }, + }, + }; + + const superdoc: SuperDocLike & { + fireEditor(event: string, ...args: unknown[]): void; + fireSuperdoc(event: string, ...args: unknown[]): void; + } = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!superdocListeners.has(event)) superdocListeners.set(event, new Set()); + superdocListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + superdocListeners.get(event)?.delete(handler); + }), + fireEditor(event, ...args) { + const handlers = editorListeners.get(event); + if (!handlers) return; + [...handlers].forEach((handler) => handler(...args)); + }, + fireSuperdoc(event, ...args) { + const handlers = superdocListeners.get(event); + if (!handlers) return; + [...handlers].forEach((handler) => handler(...args)); + }, + }; + + return { superdoc, editor }; +} + +describe('ui.toolbar', () => { + it('exposes getSnapshot / subscribe / execute compatible with HeadlessToolbarController', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const snapshot = ui.toolbar.getSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot.commands).toBeDefined(); + // Snapshot must include built-in commands — without passing the + // full command list to createHeadlessToolbar, snapshot.commands + // would be empty and ui.commands..observe would always emit + // the fallback disabled state. + expect(Object.keys(snapshot.commands).length).toBeGreaterThan(0); + expect(snapshot.commands).toHaveProperty('bold'); + + expect(typeof ui.toolbar.subscribe).toBe('function'); + expect(typeof ui.toolbar.execute).toBe('function'); + + ui.destroy(); + }); + + it('emits the initial snapshot synchronously on subscribe', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + const off = ui.toolbar.subscribe(cb); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0]).toHaveProperty('snapshot'); + + off(); + ui.destroy(); + }); + + it('forwards execute to the internal controller', () => { + const { superdoc, editor } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.toolbar.execute('bold'); + expect(editor.commands.toggleBold).toHaveBeenCalled(); + + ui.destroy(); + }); +}); + +describe('ui.commands', () => { + it('returns a stable handle per command id (reference equality across accesses)', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const a = ui.commands.bold; + const b = ui.commands.bold; + + expect(a).toBe(b); + + ui.destroy(); + }); + + it('observe fires synchronously with initial command state', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + const off = ui.commands.bold.observe(cb); + + expect(cb).toHaveBeenCalledTimes(1); + const initial = cb.mock.calls[0][0]; + expect(initial).toHaveProperty('active'); + expect(initial).toHaveProperty('disabled'); + + off(); + ui.destroy(); + }); + + it('falls back to a no-op state for unknown command ids', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + // 'company.aiRewrite' is not a built-in id; observe should still fire + // initially with the fallback state rather than throwing. + (ui.commands as unknown as Record void) => () => void }>)[ + 'company.aiRewrite' + ].observe(cb); + + expect(cb).toHaveBeenCalledTimes(1); + const state = cb.mock.calls[0][0]; + expect(state).toMatchObject({ active: false, disabled: true }); + + ui.destroy(); + }); + + it('execute forwards to the internal toolbar controller', () => { + const { superdoc, editor } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.commands.bold.execute(); + expect(editor.commands.toggleBold).toHaveBeenCalled(); + + ui.destroy(); + }); + + it('shares a single Subscribable per command id across observe() calls', async () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + // 50 observers on the same command id. Without sharing the + // Subscribable, each observe() would create a fresh selector with + // its own onStateChange in stateChangeListeners — 50 selector + // recomputes per editor event. + const cbs: Array> = []; + const offs: Array<() => void> = []; + for (let i = 0; i < 50; i += 1) { + const cb = vi.fn(); + cbs.push(cb); + offs.push(ui.commands.bold.observe(cb)); + } + + // Every observer received its initial emit. + cbs.forEach((cb) => expect(cb).toHaveBeenCalledTimes(1)); + + // Half unsubscribe; remaining observers continue. + for (let i = 0; i < 25; i += 1) { + offs[i]?.(); + } + cbs.slice(25).forEach((cb) => cb.mockClear()); + + // Fire one editor event; coalesced microtask drains. + superdoc.fireEditor('transaction'); + await Promise.resolve(); + + // Each remaining observer fires at most once. The specific + // assertion: no observer fires twice in the same tick, because the + // Subscribable is shared per command id and emits once. + cbs.slice(25).forEach((cb) => { + expect(cb.mock.calls.length).toBeLessThanOrEqual(1); + }); + + offs.slice(25).forEach((off) => off()); + ui.destroy(); + }); + + it('a per-command observer is unaffected when destroy clears the cache', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + const off = ui.commands.bold.observe(cb); + expect(cb).toHaveBeenCalledTimes(1); + + ui.destroy(); + // After destroy, no further events propagate. The unsubscribe is + // still callable (idempotent / no-throw). + expect(() => off()).not.toThrow(); + }); +}); diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index b431cd654e..da7aa775ab 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -84,8 +84,22 @@ export interface SuperDocUIState { documentMode: 'editing' | 'suggesting' | 'viewing' | null; /** Selection slice (minimal in the skeleton). */ selection: SelectionSlice; + /** + * Toolbar snapshot — `{ context, commands }`. Sourced from the + * internal headless-toolbar instance. Domain consumers normally read + * this through `ui.toolbar` (aggregate) or `ui.commands.` + * (fine-grained per-command observables). + */ + toolbar: ToolbarSnapshotSlice; } +/** + * Toolbar snapshot exposed on `state.toolbar`. Aliased to the existing + * `ToolbarSnapshot` type from `headless-toolbar` so downstream consumers + * see the same shape they would from the standalone controller. + */ +export type ToolbarSnapshotSlice = import('../headless-toolbar/types.js').ToolbarSnapshot; + export interface SelectionSlice { empty: boolean; /** The selected text, or '' when the selection is collapsed. */ @@ -108,6 +122,24 @@ export interface SuperDocUI { */ select(selector: SelectorFn, equality?: EqualityFn): Subscribable; + /** + * Aggregate toolbar surface. Mirrors the `HeadlessToolbarController` + * shape from `superdoc/headless-toolbar`, sourced from the same + * internal controller. Equivalent to subscribing to the toolbar slice + * via `ui.select((s) => s.toolbar, ...)` plus a passthrough + * `execute` and `getSnapshot`. + */ + toolbar: ToolbarHandle; + + /** + * Per-command observables and executors — one handle per + * {@link import('../headless-toolbar/types.js').PublicToolbarItemId}. + * Pattern lifted from CKEditor 5's per-command `Observable`s: each + * button binds to its own command's state, so unrelated state + * changes don't trigger a re-render. + */ + commands: CommandsHandle; + /** * Tear down all internal subscriptions to the editor / SuperDoc * instance / presentation editor. After destroy, no listeners will @@ -115,3 +147,68 @@ export interface SuperDocUI { */ destroy(): void; } + +/** + * Aggregate toolbar handle exposed on `ui.toolbar`. Compatible with + * `HeadlessToolbarController` from `superdoc/headless-toolbar` so the + * built-in `SuperToolbar.vue` (and any external consumer using the + * standalone controller today) can be migrated without API churn. + */ +export interface ToolbarHandle { + /** Snapshot the current `{ context, commands }` payload synchronously. */ + getSnapshot(): ToolbarSnapshotSlice; + /** + * Subscribe to toolbar snapshot changes. Listener receives an event + * with the latest snapshot. Returns an unsubscribe. + */ + subscribe(listener: (event: { snapshot: ToolbarSnapshotSlice }) => void): () => void; + /** + * Execute a built-in toolbar command. Type-safe payload is enforced + * via the existing `ToolbarPayloadMap`. + */ + execute( + ...args: import('../headless-toolbar/types.js').ToolbarPayloadMap[Id] extends never + ? [id: Id] + : [id: Id, payload: import('../headless-toolbar/types.js').ToolbarPayloadMap[Id]] + ): boolean; +} + +/** + * Per-command handle: state observation + execution for a single + * toolbar command id. + */ +export type CommandHandle = { + /** + * Subscribe to changes in this command's state. The listener fires + * once synchronously with the current state, then again whenever the + * state changes by shallow equality. Returns unsubscribe. + */ + observe(listener: (state: ToolbarCommandHandleState) => void): () => void; + /** Execute this command. Payload is type-checked per-command. */ + execute( + ...args: import('../headless-toolbar/types.js').ToolbarPayloadMap[Id] extends never + ? [] + : [payload: import('../headless-toolbar/types.js').ToolbarPayloadMap[Id]] + ): boolean; +}; + +/** + * Stable per-command state shape. `value` is omitted (`undefined`) when + * the underlying command has no value (e.g., bold), and typed + * per-command via `ToolbarValueMap` otherwise (e.g., `font-size` + * resolves to `string | undefined`). + */ +export type ToolbarCommandHandleState = { + active: boolean; + disabled: boolean; + value: import('../headless-toolbar/types.js').ToolbarValueMap[Id] | undefined; +}; + +/** + * Map of every toolbar command id to its handle. Indexed via + * `ui.commands.bold.observe(...)` etc. The runtime exposes a Proxy so + * any `PublicToolbarItemId` key works without pre-enumerating. + */ +export type CommandsHandle = { + [Id in import('../headless-toolbar/types.js').PublicToolbarItemId]: CommandHandle; +};