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; +};