import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog' import { DEFAULT_KEY_BINDINGS, getConflictingShortcutActionIds, isEditableEventTarget, keyBindingFromKeyboardEvent, resolveShortcutScope, serialiseKeyBinding, SHORTCUT_DEFINITIONS, SHORTCUT_SCOPE_LABELS, } from '@/lib/keyboardShortcuts' import { getClientKeyboardSettings, getEffectiveKeyBindings, setClientKeyboardSettings, } from '@/lib/settings' import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard' import type { KeyBinding, ShortcutActionId, ShortcutScope, } from '@/lib/keyboardShortcuts' import type { ClientKeyboardSettings } from '@/lib/settings' import type { PropsWithChildren } from 'react' type ShortcutHandler = () => void type ShortcutHandlers = Partial> type KeyboardShortcutsContextValue = { activeScope: ShortcutScope keyboardSettings: ClientKeyboardSettings effectiveBindings: Record conflictActionIds: Set saveKeyboardSettings: (settings: ClientKeyboardSettings) => void registerHandlers: (handlers: ShortcutHandlers) => () => void } const KeyboardShortcutsContext = createContext (null) const isFocusableShortcutTarget = (element: HTMLElement): boolean => { if (element.closest ('[hidden], [aria-hidden="true"]') != null) return false if ( (element instanceof HTMLInputElement || element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && element.disabled ) return false const style = window.getComputedStyle (element) if ( style.display === 'none' || style.visibility === 'hidden' || style.pointerEvents === 'none' ) return false return element.getClientRects ().length > 0 } const focusSearchTarget = (): void => { const targets = [ ...document.querySelectorAll ( '[data-shortcut-focus-search="true"]', ), ] const target = targets.find (isFocusableShortcutTarget) if (target == null) return target.focus () if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) target.select () } export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { const location = useLocation () const navigate = useNavigate () const { confirmDiscardNavigation } = useUnsavedChangesGuard () const [keyboardSettings, setKeyboardSettingsState] = useState (() => getClientKeyboardSettings ()) const [registeredHandlers, setRegisteredHandlers] = useState> ({ }) const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false) const registrationIdRef = useRef (0) const activeScope = useMemo ( () => resolveShortcutScope (location.pathname), [location.pathname], ) const effectiveBindings = useMemo ( () => getEffectiveKeyBindings (keyboardSettings.bindings), [keyboardSettings.bindings], ) const conflictActionIds = useMemo ( () => getConflictingShortcutActionIds (effectiveBindings), [effectiveBindings], ) const mergedRegisteredHandlers = useMemo ( () => Object.values (registeredHandlers).reduce ( (handlers, currentHandlers) => ({ ...handlers, ...currentHandlers }), { }, ), [registeredHandlers], ) const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => { const next = setClientKeyboardSettings (settings) setKeyboardSettingsState (next) }, []) const registerHandlers = useCallback ((handlers: ShortcutHandlers) => { const registrationId = ++registrationIdRef.current setRegisteredHandlers (current => ({ ...current, [registrationId]: handlers, })) return () => { setRegisteredHandlers (current => { const next = { ...current } delete next[registrationId] return next }) } }, []) const guardedNavigate = useCallback ((path: string) => { confirmDiscardNavigation ().then (confirmed => { if (confirmed) navigate (path) }) }, [confirmDiscardNavigation, navigate]) const builtinHandlers = useMemo ( () => ({ 'global.openShortcutHelp': () => { setShortcutHelpOpen (true) }, 'global.focusSearch': () => { focusSearchTarget () }, 'navigation.posts': () => guardedNavigate ('/posts'), 'navigation.tags': () => guardedNavigate ('/tags'), 'navigation.materials': () => guardedNavigate ('/materials'), 'navigation.wiki': () => guardedNavigate ('/wiki'), 'settings.account': () => guardedNavigate ('/users/settings?tab=account'), 'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'), 'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'), 'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'), }), [guardedNavigate], ) const availableActionIds = useMemo> ( () => new Set ( SHORTCUT_DEFINITIONS .filter (definition => definition.scope === 'global' || definition.scope === activeScope) .filter (definition => builtinHandlers[definition.id] != null || mergedRegisteredHandlers[definition.id] != null) .map (definition => definition.id), ), [activeScope, builtinHandlers, mergedRegisteredHandlers], ) useEffect (() => { const onKeyDown = (event: KeyboardEvent) => { if ( keyboardSettings.enabled === false || event.defaultPrevented || event.isComposing || event.key === 'Process' || event.repeat ) return if (isEditableEventTarget (event.target)) return if (shortcutHelpOpen) return const binding = keyBindingFromKeyboardEvent (event) if (binding == null) return const serialisedBinding = serialiseKeyBinding (binding) if (serialisedBinding == null) return const candidates = SHORTCUT_DEFINITIONS.filter (definition => ( definition.scope === 'global' || definition.scope === activeScope )).filter (definition => { const currentBinding = effectiveBindings[definition.id] return ( currentBinding != null && serialiseKeyBinding (currentBinding) === serialisedBinding ) }).filter (definition => !(conflictActionIds.has (definition.id))) if (candidates.length !== 1) return const actionId = candidates[0].id const handler = mergedRegisteredHandlers[actionId] ?? builtinHandlers[actionId] if (handler == null) return event.preventDefault () handler () } document.addEventListener ('keydown', onKeyDown) return () => document.removeEventListener ('keydown', onKeyDown) }, [ activeScope, conflictActionIds, effectiveBindings, builtinHandlers, keyboardSettings.enabled, mergedRegisteredHandlers, shortcutHelpOpen, ]) const value = useMemo (() => ({ activeScope, keyboardSettings, effectiveBindings, conflictActionIds, saveKeyboardSettings, registerHandlers, }), [ activeScope, conflictActionIds, effectiveBindings, keyboardSettings, registerHandlers, saveKeyboardSettings, ]) return ( {children} ) } export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => { const context = useContext (KeyboardShortcutsContext) if (context == null) throw new Error ('KeyboardShortcutsProvider が必要です.') return context } export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => { const { registerHandlers } = useKeyboardShortcutSettings () const handlersRef = useRef (handlers) const actionIdsKey = Object.keys (handlers).sort ().join ('|') useEffect (() => { handlersRef.current = handlers }, [handlers]) const stableHandlers = useMemo (() => { return actionIdsKey.split ('|').filter (actionId => actionId !== '') .reduce ((nextHandlers, actionId) => { const key = actionId as ShortcutActionId nextHandlers[key] = () => { handlersRef.current[key]?.() } return nextHandlers }, { }) }, [actionIdsKey]) useEffect ( () => registerHandlers (stableHandlers), [registerHandlers, stableHandlers], ) } export { DEFAULT_KEY_BINDINGS, SHORTCUT_DEFINITIONS, SHORTCUT_SCOPE_LABELS, }