From 1b814d9cd4b9ed6e8237ff81a5e5fc7e64f657c3 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 16 Feb 2026 20:57:59 +0200 Subject: [PATCH] zoom strip --- frontend/src/components/ZoomStrip.tsx | 318 ++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 frontend/src/components/ZoomStrip.tsx diff --git a/frontend/src/components/ZoomStrip.tsx b/frontend/src/components/ZoomStrip.tsx new file mode 100644 index 0000000..07daee0 --- /dev/null +++ b/frontend/src/components/ZoomStrip.tsx @@ -0,0 +1,318 @@ +import { useRef, useEffect, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'motion/react' +import { useStore } from '../store' +import type { Stroke, StrokePoint } from '../recognition/types' +import { recognizeEnsemble, initEngines } from '../recognition/ensemble' + +const BIG = 120 +const SMALL = 60 + +interface Props { + onDone: () => void +} + +export default function ZoomStrip({ onDone }: Props) { + const { t } = useTranslation() + const canvasRef = useRef(null) + const puzzle = useStore((s) => s.puzzle) + const entries = useStore((s) => s.entries) + const selectedCell = useStore((s) => s.selectedCell) + const direction = useStore((s) => s.direction) + const wordSpans = useStore((s) => s.wordSpans) + const activeClue = useStore((s) => s.activeClue) + const settings = useStore((s) => s.stylusSettings) + const acceptCandidate = useStore((s) => s.acceptCandidate) + const setRecognitionResult = useStore((s) => s.setRecognitionResult) + + const strokes = useRef([]) + const currentStroke = useRef([]) + const debounceTimer = useRef(null) + const drawing = useRef(false) + const enginesInitialized = useRef(false) + + const [canvasVersion, setCanvasVersion] = useState(0) + const canvasRefCallback = useCallback((el: HTMLCanvasElement | null) => { + const prev = canvasRef.current + canvasRef.current = el + if (el && el !== prev && prev !== null) { + setCanvasVersion(v => v + 1) + } + if (el && el !== prev) { + el.width = BIG; el.height = BIG + const ctx = el.getContext('2d') + if (ctx) { + ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--cell-white').trim() || '#fff' + ctx.fillRect(0, 0, BIG, BIG) + ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--cell-selected').trim() || '#fce4d6' + ctx.lineWidth = 3 + ctx.strokeRect(0, 0, BIG, BIG) + } + } + }, []) + + // pre-initialize recognition engines when puzzle loads + useEffect(() => { + if (puzzle && !enginesInitialized.current) { + enginesInitialized.current = true + initEngines(settings) + } + }, [puzzle, settings]) + + const [recLetter, setRecLetter] = useState('') + const [showRec, setShowRec] = useState(false) + const [retryMsg, setRetryMsg] = useState(false) + + const wordCells = (() => { + if (!puzzle || !selectedCell) return [] + // try active clue in current direction first + let span = wordSpans.find( + (s) => s.number === activeClue && s.direction === direction, + ) + // fall back to any span containing the selected cell in current direction + if (!span) { + span = wordSpans.find( + (s) => s.direction === direction && s.cells.some(([r, c]) => r === selectedCell!.row && c === selectedCell!.col), + ) + } + // last resort: any span containing the selected cell + if (!span) { + span = wordSpans.find( + (s) => s.cells.some(([r, c]) => r === selectedCell!.row && c === selectedCell!.col), + ) + } + return span?.cells ?? [] + })() + + const activeIndex = wordCells.findIndex( + ([r, c]) => selectedCell && r === selectedCell.row && c === selectedCell.col, + ) + + // always 3 slots: prev, active, next. null for edges. + const prevCell = activeIndex > 0 ? wordCells[activeIndex - 1] : null + const activeCell = activeIndex >= 0 ? wordCells[activeIndex] : null + const nextCell = activeIndex < wordCells.length - 1 ? wordCells[activeIndex + 1] : null + + type Slot = { cell: [number, number] | null; role: 'prev' | 'active' | 'next' } + const slots: Slot[] = [ + { cell: prevCell, role: 'prev' }, + { cell: activeCell, role: 'active' }, + { cell: nextCell, role: 'next' }, + ] + + function getCellLetter(cell: [number, number] | null) { + if (!cell) return '' + return entries[cell[0]]?.[cell[1]] || '' + } + function getCellNum(cell: [number, number] | null) { + if (!cell) return undefined + return puzzle?.cell_numbers[`${cell[0]},${cell[1]}`] + } + + const clearCanvas = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + canvas.width = BIG; canvas.height = BIG + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--cell-white').trim() || '#fff' + ctx.fillRect(0, 0, BIG, BIG) + ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--cell-selected').trim() || '#fce4d6' + ctx.lineWidth = 3 + ctx.strokeRect(0, 0, BIG, BIG) + }, []) + + useEffect(() => { + clearCanvas() + strokes.current = [] + queueMicrotask(() => { setShowRec(false); setRecLetter('') }) + }, [selectedCell, direction, activeClue, clearCanvas]) + + const advanceToNextEmpty = useCallback(() => { + const store = useStore.getState() + const cell = store.selectedCell + if (!puzzle || !cell) { onDone(); return } + const dir = store.direction + const ents = store.entries + const dr = dir === 'down' ? 1 : 0 + const dc = dir === 'across' ? 1 : 0 + let nr = cell.row + dr, nc = cell.col + dc + while (nr < puzzle.height && nc < puzzle.width) { + if (puzzle.cells[nr]?.[nc] === 'black') break + if (!ents[nr]?.[nc]) { + const span = store.wordSpans.find((s) => s.direction === dir && s.cells.some(([r, c]) => r === nr && c === nc)) + useStore.setState({ selectedCell: { row: nr, col: nc }, activeClue: span?.number ?? store.activeClue }) + strokes.current = [] + return + } + nr += dr; nc += dc + } + onDone() + }, [puzzle, onDone]) + + const runRecognition = useCallback(async () => { + const cell = useStore.getState().selectedCell + if (strokes.current.length === 0 || !cell) return + if (!enginesInitialized.current) { + enginesInitialized.current = true + await initEngines(settings) + } + await new Promise(r => setTimeout(r, 50)) + const result = await recognizeEnsemble(strokes.current, settings) + strokes.current = [] + + if (result.candidates.length === 0 || result.decision === 'redraw') { + setRetryMsg(true) + setTimeout(() => { + setRetryMsg(false) + clearCanvas() + }, 1200) + return + } + + if (result.decision === 'auto-fill') { + const letter = result.candidates[0].letter + setRecLetter(letter) + setShowRec(true) + setTimeout(() => { + acceptCandidate(letter) + // typeLetter (called by acceptCandidate) already advances to next empty + strokes.current = [] + }, 350) + } else if (result.decision === 'disambiguate') { + setRecognitionResult({ candidates: result.candidates, cell }) + useStore.setState({ showDisambiguation: true }) + } + }, [settings, acceptCandidate, setRecognitionResult, advanceToNextEmpty, clearCanvas]) + + // pointer events + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + function onDown(e: PointerEvent) { + e.preventDefault(); e.stopPropagation() + canvas!.setPointerCapture(e.pointerId); drawing.current = true + if (debounceTimer.current) { clearTimeout(debounceTimer.current); debounceTimer.current = null } + const rect = canvas!.getBoundingClientRect() + const sx = canvas!.width / rect.width, sy = canvas!.height / rect.height + currentStroke.current = [{ x: (e.clientX - rect.left) * sx, y: (e.clientY - rect.top) * sy, pressure: e.pressure || 0.5, timestamp: Date.now() }] + } + function onMove(e: PointerEvent) { + if (!drawing.current) return; e.preventDefault(); e.stopPropagation() + const rect = canvas!.getBoundingClientRect() + const sx = canvas!.width / rect.width, sy = canvas!.height / rect.height + const pt: StrokePoint = { x: (e.clientX - rect.left) * sx, y: (e.clientY - rect.top) * sy, pressure: e.pressure || 0.5, timestamp: Date.now() } + if (currentStroke.current.length > 0) { + const prev = currentStroke.current[currentStroke.current.length - 1] + const ctx = canvas!.getContext('2d')! + ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--ink-color').trim() || '#3a2820' + ctx.lineWidth = settings.strokeWidth * (1 + pt.pressure); ctx.lineCap = 'round'; ctx.lineJoin = 'round' + ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(pt.x, pt.y); ctx.stroke() + } + currentStroke.current.push(pt) + } + function onUp() { + if (!drawing.current) return; drawing.current = false + if (currentStroke.current.length > 1) strokes.current.push([...currentStroke.current]) + currentStroke.current = [] + if (strokes.current.length > 0) { + if (debounceTimer.current) clearTimeout(debounceTimer.current) + debounceTimer.current = window.setTimeout(() => runRecognition(), settings.debounceMs) + } + } + canvas.addEventListener('pointerdown', onDown); canvas.addEventListener('pointermove', onMove) + canvas.addEventListener('pointerup', onUp); canvas.addEventListener('pointercancel', onUp) + return () => { canvas.removeEventListener('pointerdown', onDown); canvas.removeEventListener('pointermove', onMove) + canvas.removeEventListener('pointerup', onUp); canvas.removeEventListener('pointercancel', onUp) } + }, [canvasRef, settings, runRecognition, canvasVersion]) + + if (!puzzle || !selectedCell || wordCells.length === 0) return null + + const springTransition = { + layout: { type: 'spring' as const, visualDuration: 0.35, bounce: 0.12 }, + opacity: { duration: 0.2 }, + } + + return ( +
+ + {slots.map((slot) => { + if (!slot.cell) { + return ( +
+ ) + } + + const cellKey = `${slot.cell[0]},${slot.cell[1]}` + const isActive = slot.role === 'active' + const isLeft = slot.role === 'prev' + const isRight = slot.role === 'next' + const size = isActive ? BIG : SMALL + const letter = getCellLetter(slot.cell) + const num = getCellNum(slot.cell) + const maskDir = isLeft ? 'to right' : isRight ? 'to left' : undefined + + return ( + + {num && ( + {num} + )} + {!isActive && letter && ( + {letter} + )} + {isActive && showRec && ( + + {recLetter} + + )} + {isActive && retryMsg && ( + + {t('common.tryAgain')} + + )} + + ) + })} + + + {/* canvas rendered OUTSIDE AnimatePresence so motion never touches it */} + {activeCell && ( + + )} +
+ ) +}