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