keyboard input handling

This commit is contained in:
2025-12-24 23:20:15 +02:00
parent 55dfa9c353
commit 9a58f5895b
+222
View File
@@ -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])
}