import { useCallback, useState } from 'react'; import type { RefObject } from 'react'; import { useViewerKeyboardShortcuts } from '../../hooks/useViewerKeyboardShortcuts'; import { DEFAULT_VIEWER_SHORTCUTS, formatShortcutChord, } from '../../utils/viewerKeyboardShortcuts'; import type { ViewerCommandId, ViewerShortcutChord, ViewerShortcutDefinition, } from '../../utils/viewerKeyboardShortcuts'; import { ViewerKeyboardHelpDialog } from './ViewerKeyboardHelpDialog'; export const VIEWER_COMMAND_EVENT = 'mechanica:viewer-command'; export interface ViewerCommandEventDetail { readonly command: ViewerCommandId; readonly label: string; readonly shortcut: string; readonly source: 'keyboard'; readonly originalEvent: KeyboardEvent; } export interface ViewerKeyboardControllerProps { readonly enabled?: boolean; readonly scopeRef?: RefObject; readonly requireFocusWithin?: boolean; readonly shortcuts?: readonly ViewerShortcutDefinition[]; readonly ignoreEditableTargets?: boolean; readonly ignoreInteractiveTargets?: boolean; readonly dispatchDomEvent?: boolean; readonly announceCommands?: boolean; readonly helpOpen?: boolean; readonly onHelpOpenChange?: (open: boolean) => void; readonly onCommand?: ( command: ViewerCommandId, event: KeyboardEvent, definition: ViewerShortcutDefinition, chord: ViewerShortcutChord, ) => void | false; } export function ViewerKeyboardController({ enabled = true, scopeRef, requireFocusWithin = Boolean(scopeRef), shortcuts = DEFAULT_VIEWER_SHORTCUTS, ignoreEditableTargets = true, ignoreInteractiveTargets = true, dispatchDomEvent = true, announceCommands = true, helpOpen, onHelpOpenChange, onCommand, }: ViewerKeyboardControllerProps) { const [internalHelpOpen, setInternalHelpOpen] = useState(false); const [announcement, setAnnouncement] = useState(''); const resolvedHelpOpen = helpOpen ?? internalHelpOpen; const isHelpControlled = helpOpen !== undefined; const setHelpOpen = useCallback( (nextOpen: boolean) => { if (!isHelpControlled) { setInternalHelpOpen(nextOpen); } onHelpOpenChange?.(nextOpen); }, [isHelpControlled, onHelpOpenChange], ); const handleCommand = useCallback( ( command: ViewerCommandId, event: KeyboardEvent, definition: ViewerShortcutDefinition, chord: ViewerShortcutChord, ) => { if (command === 'showKeyboardHelp') { setHelpOpen(true); } const callbackResult = onCommand?.(command, event, definition, chord); if (callbackResult !== false && dispatchDomEvent) { emitViewerCommandEvent(scopeRef?.current ?? getDispatchTarget(event), { command, label: definition.label, shortcut: formatShortcutChord(chord), source: 'keyboard', originalEvent: event, }); } if (callbackResult !== false && announceCommands) { setAnnouncement( command === 'showKeyboardHelp' ? 'Keyboard shortcut help opened.' : `${definition.label}.`, ); } }, [announceCommands, dispatchDomEvent, onCommand, scopeRef, setHelpOpen], ); useViewerKeyboardShortcuts({ enabled: enabled && !resolvedHelpOpen, shortcuts, scopeRef, requireFocusWithin, ignoreEditableTargets, ignoreInteractiveTargets, onCommand: handleCommand, }); return ( <> {announcement} setHelpOpen(false)} open={resolvedHelpOpen} shortcuts={shortcuts} /> ); } export function emitViewerCommandEvent( target: EventTarget | null | undefined, detail: ViewerCommandEventDetail, ): boolean { if (typeof CustomEvent === 'undefined') { return false; } const event = new CustomEvent(VIEWER_COMMAND_EVENT, { bubbles: true, cancelable: true, composed: true, detail, }); if (target) { return target.dispatchEvent(event); } if (typeof window !== 'undefined') { return window.dispatchEvent(event); } return false; } function getDispatchTarget(event: KeyboardEvent): EventTarget | null { if (typeof EventTarget !== 'undefined' && event.target instanceof EventTarget) { return event.target; } if (typeof window !== 'undefined') { return window; } return null; }