zoom strip
This commit is contained in:
@@ -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<HTMLCanvasElement>(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<Stroke[]>([])
|
||||
const currentStroke = useRef<StrokePoint[]>([])
|
||||
const debounceTimer = useRef<number | null>(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 (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 2, padding: '1rem 0 0.5rem', position: 'relative',
|
||||
minHeight: BIG + 16,
|
||||
}}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{slots.map((slot) => {
|
||||
if (!slot.cell) {
|
||||
return (
|
||||
<div key={`empty-${slot.role}`} style={{ width: SMALL, height: SMALL, flexShrink: 0 }} />
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
key={cellKey}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={springTransition}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: 2,
|
||||
backgroundColor: 'var(--cell-white)',
|
||||
border: '1px solid var(--cell-border)',
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
position: 'relative', overflow: 'hidden',
|
||||
zIndex: isActive ? 3 : 1,
|
||||
WebkitMaskImage: maskDir ? `linear-gradient(${maskDir}, transparent 0%, black 60%)` : undefined,
|
||||
maskImage: maskDir ? `linear-gradient(${maskDir}, transparent 0%, black 60%)` : undefined,
|
||||
}}
|
||||
>
|
||||
{num && (
|
||||
<span className="zoom-num-sm" style={{ position: 'absolute', top: 3, left: 4, fontSize: '0.5rem', zIndex: 5 }}>{num}</span>
|
||||
)}
|
||||
{!isActive && letter && (
|
||||
<span className="zoom-letter" style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.5rem' }}>{letter}</span>
|
||||
)}
|
||||
{isActive && showRec && (
|
||||
<motion.div initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.2 }}
|
||||
style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 3 }}>
|
||||
<span className="zoom-letter" style={{ fontSize: '2.5rem' }}>{recLetter}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{isActive && retryMsg && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.15 }}
|
||||
style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 4, background: 'var(--cell-white)' }}>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontSize: '0.75rem', fontStyle: 'italic', color: 'var(--text-tertiary)' }}>{t('common.tryAgain')}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* canvas rendered OUTSIDE AnimatePresence so motion never touches it */}
|
||||
{activeCell && (
|
||||
<canvas
|
||||
ref={canvasRefCallback}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: BIG, height: BIG,
|
||||
touchAction: 'none', cursor: 'crosshair', display: 'block',
|
||||
opacity: showRec || retryMsg ? 0 : 1,
|
||||
transition: 'opacity 200ms ease',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user