keyboard input handling
This commit is contained in:
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user