diff --git a/frontend/src/components/CellFlip.tsx b/frontend/src/components/CellFlip.tsx new file mode 100644 index 0000000..c0d0892 --- /dev/null +++ b/frontend/src/components/CellFlip.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState } from 'react' +import { useStore } from '../store' + +interface FlipItem { + row: number + col: number + oldLetter: string + newLetter: string + clueNumber: number | undefined + id: number +} + +let flipId = 0 + +export default function CellFlip({ svgRef }: { svgRef: React.RefObject }) { + const entries = useStore((s) => s.entries) + const puzzle = useStore((s) => s.puzzle) + const [flips, setFlips] = useState([]) + const prevEntries = useRef([]) + + useEffect(() => { + if (!puzzle) return + + const prev = prevEntries.current + const curr = entries + const newFlips: FlipItem[] = [] + + for (let r = 0; r < curr.length; r++) { + for (let c = 0; c < (curr[r]?.length || 0); c++) { + const was = prev[r]?.[c] || '' + const now = curr[r]?.[c] || '' + if (now && now !== was) { + const num = puzzle.cell_numbers[`${r},${c}`] + newFlips.push({ row: r, col: c, oldLetter: was, newLetter: now, clueNumber: num, id: ++flipId }) + } + } + } + + prevEntries.current = curr.map(r => [...r]) + + // skip animation when bulk-loading (e.g. reopening a solved puzzle) + const totalWhiteCells = puzzle.cells.flat().filter(c => c !== 'black').length + if (newFlips.length > 0 && newFlips.length < totalWhiteCells * 0.5) { + // hide the SVG letter text for the flipping cells + if (svgRef.current) { + for (const flip of newFlips) { + const texts = svgRef.current.querySelectorAll(`g[data-cell="${flip.row},${flip.col}"] .cell-letter`) + texts.forEach(t => { t.style.opacity = '0' }) + } + } + + setFlips(f => [...f, ...newFlips]) + setTimeout(() => { + // restore SVG letter visibility + if (svgRef.current) { + for (const flip of newFlips) { + const texts = svgRef.current.querySelectorAll(`g[data-cell="${flip.row},${flip.col}"] .cell-letter`) + texts.forEach(t => { t.style.opacity = '1' }) + } + } + setFlips(f => f.filter(fl => !newFlips.some(n => n.id === fl.id))) + }, 600) + } + }, [entries, puzzle, svgRef]) + + if (!puzzle || !svgRef.current || flips.length === 0) return null + + const svg = svgRef.current + const rect = svg.getBoundingClientRect() + const cellW = rect.width / puzzle.width + const cellH = rect.height / puzzle.height + const fontSize = Math.round(cellW * 0.52) + const numSize = Math.round(cellW * 0.26) + + return ( + <> + {flips.map(flip => ( +
+
+
+
+ {flip.clueNumber && ( + {flip.clueNumber} + )} + {flip.oldLetter && {flip.oldLetter}} +
+
+ {flip.clueNumber && ( + {flip.clueNumber} + )} + {flip.newLetter} +
+
+
+ ))} + + ) +}