the puzzle grid
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { useStore } from '../store'
|
||||
import type { CellStatus } from '../types'
|
||||
import InkOverlay from './InkOverlay'
|
||||
import CellFlip from './CellFlip'
|
||||
|
||||
function getCellSize(gridSize: number): number {
|
||||
if (gridSize <= 5) return 64
|
||||
if (gridSize <= 7) return 54
|
||||
if (gridSize <= 9) return 46
|
||||
if (gridSize <= 13) return 40
|
||||
return 36
|
||||
}
|
||||
|
||||
export default function Grid() {
|
||||
const { t } = useTranslation()
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const puzzle = useStore((s) => s.puzzle)
|
||||
const entries = useStore((s) => s.entries)
|
||||
const cellStatus = useStore((s) => s.cellStatus)
|
||||
const selectedCell = useStore((s) => s.selectedCell)
|
||||
const direction = useStore((s) => s.direction)
|
||||
const activeClue = useStore((s) => s.activeClue)
|
||||
const wordSpans = useStore((s) => s.wordSpans)
|
||||
const selectCell = useStore((s) => s.selectCell)
|
||||
const pencilEntries = useStore((s) => s.pencilEntries)
|
||||
const mpPlayers = useStore((s) => s.mpPlayers)
|
||||
const inputMode = useStore((s) => s.inputMode)
|
||||
|
||||
if (!puzzle) return null
|
||||
|
||||
const { width, height } = puzzle
|
||||
const CELL = getCellSize(Math.max(width, height))
|
||||
const NUM_SIZE = Math.round(CELL * 0.26)
|
||||
const LETTER_SIZE = Math.round(CELL * 0.52)
|
||||
|
||||
const activeSpan = wordSpans.find(
|
||||
(s) => s.number === activeClue && s.direction === direction,
|
||||
)
|
||||
const activeCells = new Set(
|
||||
activeSpan?.cells.map(([r, c]) => `${r},${c}`) ?? [],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid-wrapper${inputMode === 'stylus' ? ' stylus-active' : ''}`}
|
||||
style={{ maxWidth: width * CELL, width: '100%' }}
|
||||
onContextMenu={inputMode === 'stylus' ? (e => e.preventDefault()) : undefined}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${width * CELL} ${height * CELL}`}
|
||||
style={{ width: '100%', maxWidth: width * CELL, display: 'block' }}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={t('grid.ariaLabel', { width, height })}
|
||||
aria-activedescendant={selectedCell ? `cell-${selectedCell.row}-${selectedCell.col}` : undefined}
|
||||
>
|
||||
<defs>
|
||||
<style>{`
|
||||
.cell-num { font-family: 'DM Sans', system-ui, sans-serif; }
|
||||
.cell-letter { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||
`}</style>
|
||||
</defs>
|
||||
{Array.from({ length: height }, (_, r) =>
|
||||
Array.from({ length: width }, (_, c) => {
|
||||
const isBlack = puzzle.cells[r][c] === 'black'
|
||||
const isSelected = selectedCell?.row === r && selectedCell?.col === c
|
||||
const isActive = activeCells.has(`${r},${c}`)
|
||||
const num = puzzle.cell_numbers[`${r},${c}`]
|
||||
const letter = entries[r]?.[c] || ''
|
||||
const pencilLetter = !letter && pencilEntries[r]?.[c] ? pencilEntries[r][c] : ''
|
||||
const status: CellStatus = cellStatus[r]?.[c] || 'default'
|
||||
|
||||
const x = c * CELL
|
||||
const y = r * CELL
|
||||
|
||||
let fill = 'var(--cell-white)'
|
||||
if (isBlack) fill = 'var(--cell-black)'
|
||||
else if (isSelected) fill = 'var(--cell-selected)'
|
||||
else if (isActive) fill = 'var(--cell-active)'
|
||||
|
||||
let textColor = 'var(--text)'
|
||||
if (status === 'correct') textColor = 'var(--correct)'
|
||||
if (status === 'incorrect') textColor = 'var(--incorrect)'
|
||||
if (status === 'revealed') textColor = 'var(--revealed)'
|
||||
|
||||
const ariaLabel = isBlack ? t('grid.blackCell') :
|
||||
t('grid.cellLabel', { row: r + 1, col: c + 1 }) + (num ? t('grid.cellNumber', { num }) : '') + (letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty'))
|
||||
|
||||
return (
|
||||
<g
|
||||
key={`${r},${c}`}
|
||||
id={`cell-${r}-${c}`}
|
||||
data-cell={`${r},${c}`}
|
||||
onClick={() => !isBlack && selectCell(r, c)}
|
||||
style={{ cursor: isBlack ? 'default' : 'pointer' }}
|
||||
role="gridcell"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<rect
|
||||
x={x} y={y}
|
||||
width={CELL} height={CELL}
|
||||
fill={fill}
|
||||
stroke="var(--cell-border)"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
{!isBlack && num && (
|
||||
<text
|
||||
className="cell-num"
|
||||
x={x + 2.5} y={y + NUM_SIZE}
|
||||
fontSize={NUM_SIZE}
|
||||
fill="var(--text-tertiary)"
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{num}
|
||||
</text>
|
||||
)}
|
||||
{!isBlack && letter && (
|
||||
<text
|
||||
className="cell-letter"
|
||||
x={x + CELL / 2}
|
||||
y={y + CELL / 2 + LETTER_SIZE * 0.35}
|
||||
fontSize={LETTER_SIZE}
|
||||
textAnchor="middle"
|
||||
fill={textColor}
|
||||
fontWeight={500}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{letter}
|
||||
</text>
|
||||
)}
|
||||
{!isBlack && pencilLetter && (
|
||||
<text
|
||||
className="cell-letter cell-pencil"
|
||||
x={x + CELL / 2}
|
||||
y={y + CELL / 2 + LETTER_SIZE * 0.28}
|
||||
fontSize={LETTER_SIZE * 0.72}
|
||||
textAnchor="middle"
|
||||
fill="var(--text-tertiary)"
|
||||
fontWeight={400}
|
||||
fontStyle="italic"
|
||||
opacity={0.7}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{pencilLetter}
|
||||
</text>
|
||||
)}
|
||||
{status === 'correct' && (
|
||||
<text x={x + CELL - NUM_SIZE} y={y + CELL - 3} fontSize={NUM_SIZE * 0.9}
|
||||
fill="var(--correct)" aria-hidden="true" style={{ userSelect: 'none' }}>+</text>
|
||||
)}
|
||||
{status === 'incorrect' && (
|
||||
<text x={x + CELL - NUM_SIZE} y={y + CELL - 3} fontSize={NUM_SIZE * 0.9}
|
||||
fill="var(--incorrect)" aria-hidden="true" style={{ userSelect: 'none' }}>x</text>
|
||||
)}
|
||||
{status === 'revealed' && (
|
||||
<text x={x + CELL - NUM_SIZE} y={y + CELL - 3} fontSize={NUM_SIZE * 0.9}
|
||||
fill="var(--revealed)" aria-hidden="true" style={{ userSelect: 'none' }}>*</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}),
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{mpPlayers.filter(p => p.cursor).map(p => {
|
||||
const [r, c] = p.cursor!
|
||||
return (
|
||||
<motion.rect key={`cursor-${p.id}`}
|
||||
x={c * CELL} y={r * CELL}
|
||||
width={CELL} height={CELL}
|
||||
fill={p.color}
|
||||
aria-hidden="true"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.2 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
<rect
|
||||
x={0} y={0}
|
||||
width={width * CELL} height={height * CELL}
|
||||
fill="none"
|
||||
stroke="var(--cell-black)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
<CellFlip svgRef={svgRef} />
|
||||
<InkOverlay svgRef={svgRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user