initial commit (frontend)
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user