'use client'; import { useCallback, useSyncExternalStore } from 'react'; /** * Minimal imperative handle every text-editor surface implements so * an external tool (voice dictation, command palette, AI suggestion) * can read/write its text content without traversing React. * * Methods are optional so a host can register a partial handle * (e.g. only `getValue` + `setValue`), and the caller checks before use. */ export interface ComposerHandle { /** Move keyboard focus into the composer's editable surface. */ focus: () => void; /** Move the caret to the very end of the input. */ moveCursorToEnd?: () => void; /** Read the current draft text. Voice dictation anchors partial * transcripts onto the user's already-typed prefix via this. */ getValue?: () => string; /** Replace the current draft text. Voice dictation pushes interim * and final transcripts through this without owning a controlled * binding. */ setValue?: (value: string) => void; } /** * `@djangocfg/ui-tools/composer-registry` * * Cross-tool bridge: the currently-active text composer's handle. * * Producer side (`@djangocfg/ui-tools/chat` and TipTap hosts): * register their composer's imperative handle via `attachComposer`. * * Consumer side (`@djangocfg/ui-tools/speech-recognition`): * reads the active handle via `useActiveComposer`/`getActiveComposer` * and pipes voice transcripts into it. * * Why this lives in its own subpath (not inside `chat`) * ---------------------------------------------------- * `chat` and `speech-recognition` are sibling subpath exports. If the * registry lived inside `chat`, then `speech-recognition` would have * to reach into it via a cross-tool relative import — and under Vite * dev's dependency optimizer that file ends up loaded TWICE (once via * the `./chat` URL, once via the `./speech-recognition` relative-up * URL), giving the producer and the consumer two separate `let active` * slots. The active handle registered by chat would be invisible to * speech-recognition (and vice versa). * * Putting the registry in its own dedicated subpath (a single tool * that NEITHER chat nor speech-recognition cross-import — they both * import this one as their dependency) means Vite resolves it from a * single URL across the whole graph. One module instance, one shared * `active` slot. * * Semantics: one active composer per realm. The most recent * `registerComposer(handle)` wins; `registerComposer(null)` clears it. */ type Listener = (handle: ComposerHandle | null) => void; let active: ComposerHandle | null = null; const listeners = new Set(); /** Set or replace the active composer handle. Pass `null` to clear. */ export function registerComposer(handle: ComposerHandle | null): void { active = handle; for (const fn of listeners) fn(active); } /** * Convenience for components: register on mount, unregister on * unmount. Returns a cleanup function suitable for `useEffect`. */ export function attachComposer(handle: ComposerHandle): () => void { registerComposer(handle); return () => { if (active === handle) registerComposer(null); }; } /** Read the current active handle (no subscription). */ export function getActiveComposer(): ComposerHandle | null { return active; } /** Subscribe to handle changes; returns an unsubscribe fn. */ export function subscribeComposer(listener: Listener): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } /** * React hook: re-renders the caller whenever the active composer * changes. Built on `useSyncExternalStore` so concurrent rendering, * SSR, and dev-mode strict-effects all behave correctly. */ export function useActiveComposer(): ComposerHandle | null { const subscribe = useCallback((onChange: () => void) => { return subscribeComposer(onChange); }, []); return useSyncExternalStore(subscribe, getActiveComposer, () => null); }