Files
filwordspl-frontend/src/components/LetterGrid.tsx
T
2026-09-20 15:08:32 +03:00

351 lines
10 KiB
TypeScript

import { useState, useRef, useCallback, useEffect, memo, useMemo } from 'react';
import { colors, radius, shadows } from '../styles/theme';
interface CellProps {
letter: string;
size: number;
fontSize: number;
bg: string;
border: string;
textColor: string;
boxShadow: string;
animation: string | undefined;
}
const Cell = memo<CellProps>(({ letter, size, fontSize, bg, border, textColor, boxShadow, animation }) => {
return (
<div
style={{
width: `${size}px`,
height: `${size}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
background: bg,
border: `2px solid ${border}`,
color: textColor,
fontSize: `${fontSize}px`,
fontWeight: bg !== colors.cellDefault ? 700 : 500,
fontFamily: 'Inter, sans-serif',
transition: animation ? 'all 0.3s ease' : 'all 0.15s ease',
pointerEvents: 'none',
boxShadow: boxShadow,
animation: animation || undefined,
willChange: bg !== colors.cellDefault ? 'transform, box-shadow' : 'auto',
}}
>
{letter}
</div>
);
});
Cell.displayName = 'Cell';
interface LetterGridProps {
letters: string[][];
gridSize: number;
onWordSelect: (cells: [number, number][]) => void;
foundWords: string[];
firstLetterCell: [number, number] | null;
wordPaths: Record<string, [number, number][]>;
newFoundWord?: string | null;
}
const LetterGrid: React.FC<LetterGridProps> = ({
letters,
gridSize,
onWordSelect,
foundWords,
firstLetterCell,
wordPaths,
newFoundWord,
}) => {
const [selectedCells, setSelectedCells] = useState<[number, number][]>([]);
const [isSelecting, setIsSelecting] = useState(false);
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const gridRef = useRef<HTMLDivElement>(null);
const processingRef = useRef(false);
const touchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastProcessedCellsRef = useRef<string>('');
// Адаптивный размер клетки
const maxGridWidth = Math.min(windowWidth - 32, 400);
const gapSize = gridSize <= 4 ? 5 : gridSize <= 6 ? 4 : 3;
const totalGap = gapSize * (gridSize - 1);
const cellSize = Math.floor((maxGridWidth - totalGap) / gridSize);
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (touchTimeoutRef.current) {
clearTimeout(touchTimeoutRef.current);
}
};
}, []);
useEffect(() => {
processingRef.current = false;
lastProcessedCellsRef.current = '';
if (touchTimeoutRef.current) {
clearTimeout(touchTimeoutRef.current);
touchTimeoutRef.current = null;
}
}, [letters]);
// useMemo для мгновенного обновления (без задержки useEffect)
const foundCells = useMemo(() => {
const fc = new Set<string>();
foundWords.forEach(word => {
const path = wordPaths[word];
if (path) {
path.forEach(([r, c]) => {
fc.add(`${r},${c}`);
});
}
});
return fc;
}, [foundWords, wordPaths]);
const newFoundCells = useMemo(() => {
const nfc = new Set<string>();
if (newFoundWord) {
const path = wordPaths[newFoundWord];
if (path) {
path.forEach(([r, c]) => {
nfc.add(`${r},${c}`);
});
}
}
return nfc;
}, [newFoundWord, wordPaths]);
const selectedSet = useMemo(() => {
const ss = new Set<string>();
selectedCells.forEach(([r, c]) => ss.add(`${r},${c}`));
return ss;
}, [selectedCells]);
const isAdjacent = (cell1: [number, number], cell2: [number, number]): boolean => {
const [r1, c1] = cell1;
const [r2, c2] = cell2;
return (Math.abs(r1 - r2) === 1 && c1 === c2) || (Math.abs(c1 - c2) === 1 && r1 === r2);
};
const getCellFromTouch = useCallback((clientX: number, clientY: number): [number, number] | null => {
if (!gridRef.current) return null;
const rect = gridRef.current.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const col = Math.floor(x / (cellSize + gapSize));
const row = Math.floor(y / (cellSize + gapSize));
if (row >= 0 && row < gridSize && col >= 0 && col < gridSize) {
return [row, col];
}
return null;
}, [gridSize, cellSize, gapSize]);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
e.preventDefault();
if (processingRef.current) return;
const touch = e.touches[0];
const cell = getCellFromTouch(touch.clientX, touch.clientY);
if (cell) {
const [row, col] = cell;
const key = `${row},${col}`;
if (!foundCells.has(key)) {
setIsSelecting(true);
setSelectedCells([cell]);
if (window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
}
}
}
}, [foundCells, getCellFromTouch]);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
e.preventDefault();
if (!isSelecting || processingRef.current) return;
const touch = e.touches[0];
const cell = getCellFromTouch(touch.clientX, touch.clientY);
if (!cell) return;
const [row, col] = cell;
const key = `${row},${col}`;
if (foundCells.has(key)) return;
setSelectedCells(prev => {
const lastCell = prev[prev.length - 1];
if (!lastCell) return prev;
if (lastCell[0] === row && lastCell[1] === col) return prev;
if (!isAdjacent(lastCell, [row, col])) return prev;
const alreadySelected = prev.some(([r, c]) => r === row && c === col);
if (alreadySelected) {
const prevCell = prev[prev.length - 2];
if (prevCell && prevCell[0] === row && prevCell[1] === col) {
return prev.slice(0, -1);
}
return prev;
}
return [...prev, [row, col]];
});
}, [isSelecting, foundCells, getCellFromTouch]);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
e.preventDefault();
if (!isSelecting || processingRef.current) {
setIsSelecting(false);
setSelectedCells([]);
return;
}
if (selectedCells.length > 1) {
const cellsKey = selectedCells.map(([r, c]) => `${r},${c}`).join('|');
if (cellsKey !== lastProcessedCellsRef.current) {
processingRef.current = true;
lastProcessedCellsRef.current = cellsKey;
onWordSelect(selectedCells);
if (touchTimeoutRef.current) {
clearTimeout(touchTimeoutRef.current);
}
touchTimeoutRef.current = setTimeout(() => {
processingRef.current = false;
touchTimeoutRef.current = null;
}, 300);
}
}
setIsSelecting(false);
setSelectedCells([]);
}, [isSelecting, selectedCells, onWordSelect]);
const flatLetters = Array.isArray(letters) ? letters.flat() : [];
if (flatLetters.length === 0 || flatLetters.length !== gridSize * gridSize) {
return (
<div style={{
padding: '40px',
textAlign: 'center',
color: colors.textSecondary,
fontFamily: 'Inter, sans-serif',
}}>
Loading grid...
</div>
);
}
const fontSize = gridSize <= 4
? Math.max(cellSize * 0.42, 18)
: gridSize <= 6
? Math.max(cellSize * 0.4, 15)
: gridSize <= 8
? Math.max(cellSize * 0.38, 13)
: Math.max(cellSize * 0.35, 11);
return (
<div
ref={gridRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${gridSize}, ${cellSize}px)`,
gap: `${gapSize}px`,
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'none',
justifyContent: 'center',
margin: '0 auto',
padding: '8px',
}}
>
<style>{`
@keyframes cellPopIn {
0% { transform: scale(0.8); opacity: 0.5; }
50% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes cellGlow {
0%, 100% { box-shadow: 0 0 8px rgba(76, 217, 100, 0.3); }
50% { box-shadow: 0 0 16px rgba(76, 217, 100, 0.6); }
}
@keyframes firstLetterPulse {
0%, 100% { box-shadow: 0 0 6px rgba(255, 215, 0, 0.4); }
50% { box-shadow: 0 0 14px rgba(255, 215, 0, 0.8); }
}
`}</style>
{flatLetters.map((letter, index) => {
const row = Math.floor(index / gridSize);
const col = index % gridSize;
const key = `${row},${col}`;
const selected = selectedSet.has(key);
const first = firstLetterCell ? firstLetterCell[0] === row && firstLetterCell[1] === col : false;
const found = foundCells.has(key);
const newFound = newFoundCells.has(key);
let bg = colors.cellDefault;
let border = colors.border;
let textColor = colors.text;
let animation = '';
let boxShadow = shadows.cell;
if (found) {
bg = colors.cellFound;
border = colors.success;
textColor = '#1a7a3a';
if (newFound) {
animation = 'cellPopIn 0.4s ease, cellGlow 1s ease 0.4s';
}
} else if (first) {
bg = colors.cellFirst;
border = colors.gold;
textColor = '#b8860b';
animation = 'firstLetterPulse 1s ease infinite';
boxShadow = '0 0 10px rgba(255, 215, 0, 0.5)';
} else if (selected) {
bg = colors.cellSelected;
border = colors.accent;
textColor = colors.accent;
boxShadow = `0 0 8px ${colors.accent}40`;
}
return (
<Cell
key={`${row}-${col}-${index}`}
letter={letter}
size={cellSize}
fontSize={fontSize}
bg={bg}
border={border}
textColor={textColor}
boxShadow={boxShadow}
animation={animation || undefined}
/>
);
})}
</div>
);
};
export default LetterGrid;