112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
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<SVGSVGElement | null> }) {
|
|
const entries = useStore((s) => s.entries)
|
|
const puzzle = useStore((s) => s.puzzle)
|
|
const [flips, setFlips] = useState<FlipItem[]>([])
|
|
const prevEntries = useRef<string[][]>([])
|
|
const timeouts = useRef<ReturnType<typeof setTimeout>[]>([])
|
|
|
|
useEffect(() => () => { timeouts.current.forEach(id => clearTimeout(id)) }, [])
|
|
|
|
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<SVGElement>(`g[data-cell="${flip.row},${flip.col}"] .cell-letter`)
|
|
texts.forEach(t => { t.style.opacity = '0' })
|
|
}
|
|
}
|
|
|
|
setFlips(f => [...f, ...newFlips])
|
|
timeouts.current.push(setTimeout(() => {
|
|
// restore SVG letter visibility
|
|
if (svgRef.current) {
|
|
for (const flip of newFlips) {
|
|
const texts = svgRef.current.querySelectorAll<SVGElement>(`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 => (
|
|
<div
|
|
key={flip.id}
|
|
className="flip-scene"
|
|
style={{
|
|
position: 'absolute',
|
|
left: flip.col * cellW,
|
|
top: flip.row * cellH,
|
|
width: cellW,
|
|
height: cellH,
|
|
}}
|
|
>
|
|
<div className="flip-backing" />
|
|
<div className="flip-card flip-card-animate">
|
|
<div className="flip-face flip-front">
|
|
{flip.clueNumber && (
|
|
<span className="flip-num" style={{ fontSize: numSize }}>{flip.clueNumber}</span>
|
|
)}
|
|
{flip.oldLetter && <span className="flip-letter" style={{ fontSize }} aria-hidden="true">{flip.oldLetter}</span>}
|
|
</div>
|
|
<div className="flip-face flip-back">
|
|
{flip.clueNumber && (
|
|
<span className="flip-num" style={{ fontSize: numSize }}>{flip.clueNumber}</span>
|
|
)}
|
|
<span className="flip-letter" style={{ fontSize }} aria-hidden="true">{flip.newLetter}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
)
|
|
}
|