From 9a58f5895b169d6de72c12f97de2a75f9a634d81 Mon Sep 17 00:00:00 2001 From: lashman Date: Wed, 24 Dec 2025 23:20:15 +0200 Subject: [PATCH] keyboard input handling --- frontend/src/hooks/useKeyboard.ts | 222 ++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 frontend/src/hooks/useKeyboard.ts diff --git a/frontend/src/hooks/useKeyboard.ts b/frontend/src/hooks/useKeyboard.ts new file mode 100644 index 0000000..0b5cc90 --- /dev/null +++ b/frontend/src/hooks/useKeyboard.ts @@ -0,0 +1,222 @@ +import { useEffect } from 'react' +import { useStore } from '../store' +import * as narrator from '../narrator' + +function readCurrentCell() { + const { puzzle, selectedCell, entries, direction, wordSpans, activeClue } = useStore.getState() + if (!puzzle || !selectedCell) { + narrator.speak('No cell selected') + return + } + const { row, col } = selectedCell + const letter = entries[row]?.[col] || '' + const num = puzzle.cell_numbers[`${row},${col}`] + const span = wordSpans.find(s => s.number === activeClue && s.direction === direction) + const pos = span ? span.cells.findIndex(([r, c]) => r === row && c === col) + 1 : 0 + const len = span?.cells.length || 0 + + let msg = `Row ${row + 1}, Column ${col + 1}` + if (num) msg += `, number ${num}` + msg += letter ? `, letter ${letter}` : ', empty' + msg += `, ${direction}` + if (pos && len) msg += `, position ${pos} of ${len}` + narrator.speak(msg) +} + +function readCurrentClue() { + const { puzzle, activeClue, direction, wordSpans, entries, narratorSettings } = useStore.getState() + if (!puzzle || activeClue === null) { + narrator.speak('No clue selected') + return + } + const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down + const clue = clues.find(c => c.number === activeClue) + if (!clue) return + + const span = wordSpans.find(s => s.number === activeClue && s.direction === direction) + const len = span?.cells.length || 0 + const filled = span ? span.cells.filter(([r, c]) => entries[r]?.[c]).length : 0 + const v = narratorSettings.verbosity + + if (v === 'brief') { + narrator.speak(`${activeClue} ${direction}: ${clue.text}`) + } else if (v === 'normal') { + narrator.speak(`${activeClue} ${direction}, ${len} letters: ${clue.text}`) + } else { + narrator.speak(`${activeClue} ${direction}, ${len} letters, ${filled} filled: ${clue.text}`) + } +} + +function readPuzzleOverview() { + const { puzzle, entries, wordSpans } = useStore.getState() + if (!puzzle) { + narrator.speak('No puzzle loaded') + return + } + let totalCells = 0 + let filledCells = 0 + for (let r = 0; r < puzzle.height; r++) { + for (let c = 0; c < puzzle.width; c++) { + if (puzzle.cells[r][c] !== 'black') { + totalCells++ + if (entries[r]?.[c]) filledCells++ + } + } + } + const totalClues = puzzle.clues_across.length + puzzle.clues_down.length + const completedWords = wordSpans.filter(s => + s.cells.every(([r, c]) => entries[r]?.[c]) + ).length + const remaining = totalClues - completedWords + + narrator.speak( + `${puzzle.width} by ${puzzle.height} puzzle, ${filledCells} of ${totalCells} cells filled, ${remaining} clues remaining` + ) +} + +function getGridEl(): HTMLElement | null { + return document.querySelector('.grid-wrapper') +} + +// tracks whether focus (or the last pointer interaction) is inside the grid, +// so typing after clicking a header button does not fill grid cells +let gridHasFocus = false + +function updateGridFocus(target: EventTarget | null) { + gridHasFocus = target instanceof Node && !!getGridEl()?.contains(target) +} + +export function useKeyboard() { + const puzzle = useStore((s) => s.puzzle) + const typeLetter = useStore((s) => s.typeLetter) + const deleteLetter = useStore((s) => s.deleteLetter) + const moveSelection = useStore((s) => s.moveSelection) + const toggleDirection = useStore((s) => s.toggleDirection) + const tabClue = useStore((s) => s.tabClue) + const narratorEnabled = useStore((s) => s.narratorEnabled) + const undo = useStore((s) => s.undo) + const redo = useStore((s) => s.redo) + + useEffect(() => { + if (!puzzle) return + + gridHasFocus = !!getGridEl()?.contains(document.activeElement) + + const onPointerDown = (e: PointerEvent) => updateGridFocus(e.target) + const onFocusIn = (e: FocusEvent) => updateGridFocus(e.target) + document.addEventListener('pointerdown', onPointerDown, true) + document.addEventListener('focusin', onFocusIn) + + // give the grid initial keyboard focus when nothing else is focused + if (document.activeElement === document.body || document.activeElement === null) { + const svg = getGridEl()?.querySelector('svg') + if (svg instanceof SVGSVGElement) svg.focus() + } + + function onKeyDown(e: KeyboardEvent) { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement + ) + return + + // only handle letter input when the grid area has focus + const gridFocused = gridHasFocus || !!getGridEl()?.contains(document.activeElement) + + const key = e.key + const narr = useStore.getState().narratorEnabled + + // narrator-only shortcuts - only when grid is focused + if (narr && gridFocused) { + if (key === ' ') { + e.preventDefault() + readCurrentCell() + return + } + if (key === '?' || key === '/') { + e.preventDefault() + readCurrentClue() + return + } + if ((e.ctrlKey && key.toLowerCase() === 'h') || key === 'F1') { + e.preventDefault() + readPuzzleOverview() + return + } + if (key === 'Tab') { + e.preventDefault() + tabClue(!e.shiftKey) + // clue announcement handled by useNarrator hook + return + } + } + + if (gridFocused && e.ctrlKey && key.toLowerCase() === 'z' && !e.shiftKey) { + e.preventDefault() + undo() + return + } + if (gridFocused && ((e.ctrlKey && key.toLowerCase() === 'y') || (e.ctrlKey && e.shiftKey && key.toLowerCase() === 'z'))) { + e.preventDefault() + redo() + return + } + + if (gridFocused && key.length === 1 && key >= 'a' && key <= 'z') { + e.preventDefault() + typeLetter(key) + return + } + if (gridFocused && key.length === 1 && key >= 'A' && key <= 'Z') { + e.preventDefault() + typeLetter(key) + return + } + + if (!gridFocused) return + + switch (key) { + case 'Backspace': + e.preventDefault() + deleteLetter() + break + case 'Escape': + e.preventDefault() + useStore.getState().deselectCell() + break + case 'ArrowUp': + e.preventDefault() + moveSelection(-1, 0) + break + case 'ArrowDown': + e.preventDefault() + moveSelection(1, 0) + break + case 'ArrowLeft': + e.preventDefault() + moveSelection(0, -1) + break + case 'ArrowRight': + e.preventDefault() + moveSelection(0, 1) + break + case ' ': + e.preventDefault() + toggleDirection() + break + case 'Tab': + e.preventDefault() + tabClue(!e.shiftKey) + break + } + } + + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('pointerdown', onPointerDown, true) + document.removeEventListener('focusin', onFocusIn) + } + }, [puzzle, typeLetter, deleteLetter, moveSelection, toggleDirection, tabClue, narratorEnabled, undo, redo]) +}