import { DependencyList, useEffect, useState } from "react"; import { GamepadButtonEvent } from "./gamepads"; import { dispatchFocusedEvent, GetFocusedTree } from "./spatialNavigation"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { isTextInputFocused } from "./utils"; const shortcutMap = new Map Shortcut[])[]>(); const conflictSet = new Set(); let hadEnterDown = false; export enum GamePadButtonCode { A = 0, B = 1, X = 2, Y = 3, L1 = 4, R1 = 5, L2 = 6, R2 = 7, Select = 8, Start = 9, LJoy = 10, RJoy = 11, Up = 12, Down = 13, Left = 14, Right = 15, Steam = 16 } export interface Shortcut { label?: string; button: GamePadButtonCode; heldTime?: number; action?: (e: GamepadButtonEvent) => void; side?: "left" | "right"; } let isDirty = false; const shortcutChangeDispatcher = setInterval(() => { if (!isDirty) return; window.dispatchEvent(new Event('shortcutsChanged')); isDirty = false; }, 100); import.meta.hot?.dispose(() => clearInterval(shortcutChangeDispatcher)); function markDirtyThrottled () { isDirty = true; } window.addEventListener('focuschanged', markDirtyThrottled); import.meta.hot?.dispose(() => window.removeEventListener('focuschanged', markDirtyThrottled)); import.meta.hot?.dispose(() => shortcutMap.clear()); export function useShortcutContext () { const [array, setArray] = useState<({ key: string; } & Shortcut)[] | undefined>(); useEffect(() => { const handleShortcutRebuild = () => { conflictSet.clear(); const focusKey = getCurrentFocusKey(); const newArray = GetFocusedTree(focusKey) .filter(f => shortcutMap.has(f)) .flatMap(f => shortcutMap.get(f)!.map(s => ({ key: f, handler: s }))) .flatMap(kvp => kvp.handler().map(s => ({ key: kvp.key, ...s }))) .filter(s => { const empty = !conflictSet.has(s.button); conflictSet.add(s.button); return empty; }); if (!compareShortcutArrays(newArray, array)) { setArray(newArray); } }; const shortcuts = new Map(array?.reverse().map(s => [s.button, s]) ?? []); const holdChecks = new Map(); const handleGamepadButtonDown = (e: Event) => { const event = e as GamepadButtonEvent; if (event.button == GamePadButtonCode.B && document.fullscreenElement) { document.exitFullscreen(); return; } if (shortcuts.has(event.button)) { const shortcut = shortcuts.get(event.button); if (shortcut) { if (shortcut.heldTime && shortcut.heldTime > 0) { holdChecks.set(event.button, setTimeout(() => { shortcut.action?.(event); }, shortcut.heldTime)); } else { shortcut.action?.(event); } } } else if (event.button === GamePadButtonCode.A) { dispatchFocusedEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true })); hadEnterDown = true; } }; const handleKeyPress = (e: KeyboardEvent) => { if (e.key === 'Escape') { shortcuts.get(GamePadButtonCode.B)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.B })); } else { // We use backspace and space in typing if (isTextInputFocused()) { return false; } if (e.key === 'Backspace') { shortcuts.get(GamePadButtonCode.X)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.X })); } else if (e.key === ' ') { shortcuts.get(GamePadButtonCode.Y)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.Y })); } } }; const handleGamepadButtonUp = (e: Event) => { const event = e as GamepadButtonEvent; if (hadEnterDown && event.button === GamePadButtonCode.A) { dispatchFocusedEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true })); } if (shortcuts.has(event.button)) { if (holdChecks.has(event.button)) { clearInterval(holdChecks.get(event.button)); } } }; function compareShortcut (a: Shortcut, b: Shortcut) { return a.action === b.action && a.button === b.button && a.label === b.label; } function compareShortcutArrays (a: Shortcut[] | undefined, b: Shortcut[] | undefined) { if (a === b) return true; if (a === undefined || b === undefined) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (!compareShortcut(a[i], b[i])) { return false; } } return true; } if (!array) { handleShortcutRebuild(); } window.addEventListener('gamepadbuttondown', handleGamepadButtonDown); window.addEventListener('keydown', handleKeyPress); window.addEventListener('gamepadbuttonup', handleGamepadButtonUp); window.addEventListener('shortcutsChanged', handleShortcutRebuild); return () => { window.removeEventListener('gamepadbuttondown', handleGamepadButtonDown); window.removeEventListener('gamepadbuttonup', handleGamepadButtonUp); window.removeEventListener('shortcutsChanged', handleShortcutRebuild); window.removeEventListener('keydown', handleKeyPress); holdChecks.forEach(c => clearInterval(c)); }; }, [array]); return { shortcuts: array }; } export function useShortcuts (focusKey: string, build: () => Shortcut[], deps?: DependencyList) { useEffect(() => { const array = shortcutMap.get(focusKey) ?? []; array.push(build); shortcutMap.set(focusKey, array); markDirtyThrottled(); return () => { const array = shortcutMap.get(focusKey); if (array) { const index = array.indexOf(build); array?.splice(index, 1); } markDirtyThrottled(); }; }, [focusKey, ...deps ?? []]); }