import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useEditor } from '@craftjs/core'; import type { DeviceMode } from '../types'; import { useDevice } from '../state/DeviceContext'; import { usePages } from '../state/PageContext'; import { useSitesmithModal } from '../state/SitesmithContext'; import { useNotices } from '../state/NoticeContext'; import { useMobileChrome } from '../state/MobileChromeContext'; import { isTypingTarget, matchShortcut } from '../utils/shortcuts'; /** * Command registry: one place where features register what they can do, shared * by the keyboard layer, the Cmd+K palette, the top-bar "More" menu and the * breadcrumb. Module-level (like hooks/clipboard.ts); features register from an * effect via `useRegisterCommands` and never edit TopBar / useKeyboardShortcuts. */ export type CommandGroup = 'edit' | 'select' | 'view' | 'insert' | 'page' | 'site' | 'help'; export interface CommandContext { query: any; actions: any; selectedIds: string[]; device: DeviceMode; setDevice(d: DeviceMode): void; pages: ReturnType; notify: ReturnType['notify']; openSitesmith(target?: any): void; openTemplates(): void; openHeadCode(): void; } export interface Command { id: string; title: string; group: CommandGroup; keywords?: string[]; /** Shortcut spec string, see utils/shortcuts.ts ('mod+d', 'alt+arrowup'). */ shortcut?: string; /** Extra shortcut specs that trigger the same command ('mod+y' for redo). */ shortcuts?: string[]; when?(ctx: CommandContext): boolean; run(ctx: CommandContext): void | Promise; /** Not shown in the palette / menu (still runnable by shortcut and id). */ hidden?: boolean; /** Fire the shortcut even while an input / contentEditable is focused. */ allowWhileTyping?: boolean; } const registry = new Map(); const listeners = new Set<() => void>(); let snapshot: Command[] | null = null; function changed() { snapshot = null; listeners.forEach((cb) => cb()); } export function registerCommand(cmd: Command): () => void { registry.set(cmd.id, cmd); changed(); return () => { // Only remove our own registration; a later re-register of the same id wins. if (registry.get(cmd.id) === cmd) { registry.delete(cmd.id); changed(); } }; } export function registerCommands(cmds: Command[]): () => void { const offs = cmds.map(registerCommand); return () => offs.forEach((off) => off()); } /** Stable registration order; the same array instance until the registry changes. */ export function getCommands(): Command[] { if (!snapshot) snapshot = Array.from(registry.values()); return snapshot; } export function getCommand(id: string): Command | undefined { return registry.get(id); } export function subscribeCommands(cb: () => void): () => void { listeners.add(cb); return () => { listeners.delete(cb); }; } export function __resetCommandsForTests(): void { registry.clear(); listeners.clear(); snapshot = null; } export function useCommandContext(): CommandContext { // Collect a string key (not an array) so the hook only re-renders when the // selection actually changes. const { actions, query, selKey } = useEditor((state) => ({ selKey: Array.from(state.events?.selected || []).join('\n'), })); const { device, setDevice } = useDevice(); const pages = usePages(); const { notify } = useNotices(); const { open: openSitesmith } = useSitesmithModal(); const { setTemplateModalOpen, setHeadCodeModalOpen } = useMobileChrome(); return useMemo( () => ({ query, actions, selectedIds: selKey ? selKey.split('\n') : [], device, setDevice, pages, notify, openSitesmith, openTemplates: () => setTemplateModalOpen(true), openHeadCode: () => setHeadCodeModalOpen(true), }), [query, actions, selKey, device, setDevice, pages, notify, openSitesmith, setTemplateModalOpen, setHeadCodeModalOpen], ); } /** Live selection read; collectors are snapshots and can lag a keydown. */ function liveContext(ctx: CommandContext): CommandContext { try { return { ...ctx, selectedIds: ctx.query.getEvent('selected').all() }; } catch { return ctx; } } function isEnabled(cmd: Command, ctx: CommandContext): boolean { try { return cmd.when ? !!cmd.when(ctx) : true; } catch { return false; } } export function useRunCommand(): (id: string) => Promise { const ctx = useCommandContext(); const ref = useRef(ctx); ref.current = ctx; return useCallback(async (id: string) => { const cmd = getCommand(id); if (!cmd) return false; const c = liveContext(ref.current); if (!isEnabled(cmd, c)) return false; try { await cmd.run(c); return true; } catch (err) { console.error(`Command ${id} failed:`, err); return false; } }, []); } /** Keys that are also how focus / dialogs are driven: Tab walks focus and Esc * dismisses. They only belong to the canvas when focus is on it (or the body), * never while a button, link or dialog holds focus. */ function focusOutsideCanvas(el: Element | null): boolean { // An open modal owns Esc / Tab even when focus has not moved into it. if (document.querySelector('[role="dialog"][aria-modal="true"]')) return true; if (!el || el === document.body) return false; if (el.closest('[role="dialog"], [aria-modal="true"], .modal-backdrop')) return true; return !!el.closest('button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"]'); } function shortcutsOf(cmd: Command): string[] { return cmd.shortcut ? [cmd.shortcut, ...(cmd.shortcuts ?? [])] : []; } /** * Document-level keydown dispatcher: first registered command whose shortcut * matches and whose `when()` passes runs, with preventDefault. Mounted once, by * useKeyboardShortcuts (#39), so two listeners never double-fire. */ export function useCommandShortcuts(): void { const ctx = useCommandContext(); const ref = useRef(ctx); ref.current = ctx; useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { const typing = isTypingTarget(document.activeElement); const c = liveContext(ref.current); for (const cmd of getCommands()) { const hit = shortcutsOf(cmd).find((spec) => matchShortcut(e, spec)); if (!hit) continue; if ((hit === 'tab' || hit === 'shift+tab' || hit === 'escape') && focusOutsideCanvas(document.activeElement)) continue; if (typing && !cmd.allowWhileTyping) continue; if (!isEnabled(cmd, c)) continue; e.preventDefault(); try { Promise.resolve(cmd.run(c)).catch((err) => console.error(`Command ${cmd.id} failed:`, err)); } catch (err) { console.error(`Command ${cmd.id} failed:`, err); } return; } }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, []); } /** Register commands for the lifetime of a component; re-registers when `deps` change. */ export function useRegisterCommands(cmds: Command[], deps: unknown[]): void { useEffect(() => registerCommands(cmds), deps); // eslint-disable-line react-hooks/exhaustive-deps }