web/src/controls.ts80 lines · 3035 bytes
| 1 | // Presenter controls: keyboard shortcuts (and a clicker's Page Up / Page Down) -> page actions. Pure, so the self-test |
| 2 | // checks the whole map. The on-screen help (?) and web/README.md list the same keys. |
| 3 | // |
| 4 | // ← / PageUp previous (older) proposal |
| 5 | // → / PageDown next (newer) proposal |
| 6 | // N / Home / End newest proposal |
| 7 | // Space / Enter replay the selected proposal's verdict moment |
| 8 | // F auto-follow on / off (offline: the replay loop) |
| 9 | // P play / pause the replay loop |
| 10 | // D details drawer |
| 11 | // L language |
| 12 | // ? / H help |
| 13 | // 1 - 7 replay attack card 1-7 of the picker (lab.ts), also with ?clean=1 where the picker is hidden |
| 14 | // O the operator's launch panel (only where operator mode is on; operator-client.ts) |
| 15 | // Esc close the drawer, the help and the operator panel |
| 16 | |
| 17 | export type Action = 'prev' | 'next' | 'newest' | 'replay' | 'follow' | 'loop' | 'details' | 'lang' | 'help' | 'lab' | 'operator' | 'close' |
| 18 | |
| 19 | const KEYS: Record<string, Action> = { |
| 20 | arrowleft: 'prev', |
| 21 | pageup: 'prev', |
| 22 | arrowright: 'next', |
| 23 | pagedown: 'next', |
| 24 | n: 'newest', |
| 25 | home: 'newest', |
| 26 | end: 'newest', |
| 27 | ' ': 'replay', |
| 28 | spacebar: 'replay', |
| 29 | enter: 'replay', |
| 30 | f: 'follow', |
| 31 | p: 'loop', |
| 32 | d: 'details', |
| 33 | l: 'lang', |
| 34 | '?': 'help', |
| 35 | h: 'help', |
| 36 | '1': 'lab', |
| 37 | '2': 'lab', |
| 38 | '3': 'lab', |
| 39 | '4': 'lab', |
| 40 | '5': 'lab', |
| 41 | '6': 'lab', |
| 42 | '7': 'lab', |
| 43 | o: 'operator', |
| 44 | escape: 'close', |
| 45 | } |
| 46 | |
| 47 | /** Keys 1-7: the picker card's index (0-6), else null */ |
| 48 | export function labIndexOfKey(key: string): number | null { |
| 49 | return /^[1-7]$/.test(key) ? Number(key) - 1 : null |
| 50 | } |
| 51 | |
| 52 | /** KeyboardEvent.key -> action; null for keys the page leaves alone (and for any key with Ctrl, Alt or Meta held) */ |
| 53 | export function keyAction(key: string, mods: { ctrl?: boolean; alt?: boolean; meta?: boolean } = {}): Action | null { |
| 54 | if (mods.ctrl || mods.alt || mods.meta) return null |
| 55 | return KEYS[key.toLowerCase()] ?? null |
| 56 | } |
| 57 | |
| 58 | /** The help overlay's rows: the keys shown for each action, in this order */ |
| 59 | export const HELP_ROWS: { keys: string[]; action: Action }[] = [ |
| 60 | { keys: ['←', '→'], action: 'prev' }, |
| 61 | { keys: ['N'], action: 'newest' }, |
| 62 | { keys: ['Space'], action: 'replay' }, |
| 63 | { keys: ['F'], action: 'follow' }, |
| 64 | { keys: ['P'], action: 'loop' }, |
| 65 | { keys: ['D'], action: 'details' }, |
| 66 | { keys: ['L'], action: 'lang' }, |
| 67 | { keys: ['?'], action: 'help' }, |
| 68 | { keys: ['1–7'], action: 'lab' }, |
| 69 | { keys: ['O'], action: 'operator' }, |
| 70 | { keys: ['Esc'], action: 'close' }, |
| 71 | ] |
| 72 | |
| 73 | /** Step through ids (sorted oldest to newest) from the current one; stays at either end */ |
| 74 | export function stepId(ids: string[], cur: string | null, dir: -1 | 1): string | null { |
| 75 | if (!ids.length) return null |
| 76 | const sorted = [...ids].sort((a, b) => Number(a) - Number(b)) |
| 77 | const i = cur === null ? -1 : sorted.indexOf(cur) |
| 78 | if (i < 0) return sorted[sorted.length - 1] |
| 79 | return sorted[Math.max(0, Math.min(sorted.length - 1, i + dir))] |
| 80 | } |