SolverBoard.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import { useState, useCallback, useEffect, useRef } from 'react';
  2. import { getMessages } from '../messages/index.js';
  3. import { Keyboard } from './Keyboard.js';
  4. const COLOR_MAP: Record<string, string> = {
  5. g: '#6aaa64',
  6. y: '#c9b458',
  7. x: '#787c7e',
  8. };
  9. const CURSOR_COLOR = '#6aaa64';
  10. const COLOR_CYCLE: Array<'x' | 'y' | 'g'> = ['x', 'y', 'g'];
  11. type CellColor = 'x' | 'y' | 'g';
  12. interface CellState {
  13. letter: string;
  14. color: CellColor;
  15. }
  16. interface SolverBoardProps {
  17. onUpdate: (rows: Array<{ guess: string; result: string }>) => void;
  18. loading?: boolean;
  19. }
  20. function emptyBoard(): CellState[][] {
  21. return Array.from({ length: 6 }, () =>
  22. Array.from({ length: 5 }, () => ({ letter: '', color: 'x' as CellColor })),
  23. );
  24. }
  25. function buildRows(board: CellState[][]): Array<{ guess: string; result: string }> {
  26. const rows: Array<{ guess: string; result: string }> = [];
  27. for (const row of board) {
  28. if (row.every((c) => c.letter !== '')) {
  29. rows.push({
  30. guess: row.map((c) => c.letter).join(''),
  31. result: row.map((c) => c.color).join(''),
  32. });
  33. }
  34. }
  35. return rows;
  36. }
  37. function hasPartialRow(board: CellState[][]): boolean {
  38. for (const row of board) {
  39. const filled = row.filter((c) => c.letter !== '').length;
  40. if (filled > 0 && filled < 5) return true;
  41. }
  42. return false;
  43. }
  44. export function SolverBoard({ onUpdate, loading }: SolverBoardProps) {
  45. const [board, setBoard] = useState<CellState[][]>(emptyBoard);
  46. const [cursor, setCursor] = useState<{ row: number; col: number }>({ row: 0, col: 0 });
  47. // Refs for latest values used in keyboard handler (stable closure)
  48. const cursorRef = useRef(cursor);
  49. cursorRef.current = cursor;
  50. const boardRef = useRef(board);
  51. boardRef.current = board;
  52. const loadingRef = useRef(loading);
  53. loadingRef.current = loading;
  54. const partial = hasPartialRow(board);
  55. const updateDisabled = loading || partial;
  56. const triggerUpdate = useCallback(() => {
  57. const b = boardRef.current;
  58. const r = buildRows(b);
  59. if (hasPartialRow(b) || loadingRef.current) return;
  60. onUpdate(r);
  61. }, [onUpdate]);
  62. // ── Cell mutation helpers ──
  63. const setCell = useCallback((row: number, col: number, update: Partial<CellState>) => {
  64. setBoard((prev) => {
  65. const next = prev.map((r) => r.map((c) => ({ ...c })));
  66. if (update.letter !== undefined) next[row][col].letter = update.letter;
  67. if (update.color !== undefined) next[row][col].color = update.color;
  68. return next;
  69. });
  70. }, []);
  71. const cycleColor = useCallback((row: number, col: number) => {
  72. setBoard((prev) => {
  73. const next = prev.map((r) => r.map((c) => ({ ...c })));
  74. const current = next[row][col].color;
  75. const idx = COLOR_CYCLE.indexOf(current);
  76. next[row][col].color = COLOR_CYCLE[(idx + 1) % COLOR_CYCLE.length];
  77. return next;
  78. });
  79. }, []);
  80. const moveCursor = useCallback((row: number, col: number) => {
  81. setCursor({ row: Math.max(0, Math.min(5, row)), col: Math.max(0, Math.min(4, col)) });
  82. }, []);
  83. // ── Shared key handler (on-screen keyboard + physical keyboard) ──
  84. const handleKey = useCallback(
  85. (key: string) => {
  86. const { row, col } = cursorRef.current;
  87. if (key === 'Enter') {
  88. triggerUpdate();
  89. return;
  90. }
  91. if (key === 'Backspace') {
  92. setCell(row, col, { letter: '', color: 'x' });
  93. if (col > 0) {
  94. moveCursor(row, col - 1);
  95. } else if (row > 0) {
  96. moveCursor(row - 1, 4);
  97. }
  98. return;
  99. }
  100. if (key === 'ArrowLeft') {
  101. moveCursor(row, col - 1);
  102. return;
  103. }
  104. if (key === 'ArrowRight') {
  105. moveCursor(row, col + 1);
  106. return;
  107. }
  108. if (key === 'ArrowUp') {
  109. moveCursor(row - 1, col);
  110. return;
  111. }
  112. if (key === 'ArrowDown') {
  113. moveCursor(row + 1, col);
  114. return;
  115. }
  116. // Letter key (EN + RU, no ё)
  117. if (key.length === 1 && /^[a-zA-Zа-яА-Я]$/.test(key) && key.toLowerCase() !== 'ё') {
  118. const letter = key.toLowerCase();
  119. setCell(row, col, { letter, color: 'x' });
  120. if (col < 4) {
  121. moveCursor(row, col + 1);
  122. }
  123. }
  124. },
  125. [triggerUpdate, setCell, moveCursor],
  126. );
  127. // ── Physical keyboard ──
  128. useEffect(() => {
  129. const handleKeyDown = (e: KeyboardEvent) => {
  130. // Ignore if user is typing in an input field
  131. if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
  132. const key = e.key;
  133. // Only handle single letters and special keys
  134. if (
  135. key === 'Enter' ||
  136. key === 'Backspace' ||
  137. key.startsWith('Arrow') ||
  138. (key.length === 1 && /^[a-zA-Zа-яА-Я]$/.test(key) && key.toLowerCase() !== 'ё')
  139. ) {
  140. e.preventDefault();
  141. handleKey(key);
  142. }
  143. };
  144. document.addEventListener('keydown', handleKeyDown);
  145. return () => document.removeEventListener('keydown', handleKeyDown);
  146. }, [handleKey]);
  147. // ── Render ──
  148. const m = getMessages();
  149. return (
  150. <div>
  151. <style>{`
  152. @keyframes wl-solver-blink {
  153. 0%, 100% { opacity: 1; }
  154. 50% { opacity: 0; }
  155. }
  156. `}</style>
  157. {/* Grid */}
  158. <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'center' }}>
  159. {board.map((row, rowIdx) => (
  160. <div key={rowIdx} style={{ display: 'flex', gap: '4px' }}>
  161. {row.map((cell, colIdx) => {
  162. const hasLetter = cell.letter !== '';
  163. const bg = hasLetter ? COLOR_MAP[cell.color] : 'transparent';
  164. const isFocused = cursor.row === rowIdx && cursor.col === colIdx;
  165. return (
  166. <div
  167. key={colIdx}
  168. onClick={() => {
  169. if (isFocused && hasLetter) {
  170. cycleColor(rowIdx, colIdx);
  171. } else {
  172. moveCursor(rowIdx, colIdx);
  173. }
  174. }}
  175. style={{
  176. width: '52px',
  177. height: '52px',
  178. border: `2px solid ${hasLetter ? 'transparent' : '#d3d6da'}`,
  179. backgroundColor: bg,
  180. display: 'flex',
  181. alignItems: 'center',
  182. justifyContent: 'center',
  183. fontSize: '1.5rem',
  184. fontWeight: 'bold',
  185. color: hasLetter ? '#fff' : '#000',
  186. textTransform: 'uppercase',
  187. fontFamily: 'monospace',
  188. position: 'relative',
  189. cursor: 'pointer',
  190. userSelect: 'none',
  191. }}
  192. >
  193. {cell.letter}
  194. {isFocused && (
  195. <div
  196. style={{
  197. position: 'absolute',
  198. bottom: '4px',
  199. left: '8px',
  200. right: '8px',
  201. height: '3px',
  202. backgroundColor: CURSOR_COLOR,
  203. borderRadius: '1px',
  204. animation: 'wl-solver-blink 1s step-end infinite',
  205. }}
  206. />
  207. )}
  208. </div>
  209. );
  210. })}
  211. </div>
  212. ))}
  213. </div>
  214. {/* Hint */}
  215. <div
  216. style={{
  217. textAlign: 'center',
  218. marginTop: '10px',
  219. fontSize: '0.75rem',
  220. color: '#787c7e',
  221. fontFamily: 'sans-serif',
  222. }}
  223. >
  224. {m.solver.tapHint}
  225. </div>
  226. {/* On-screen keyboard */}
  227. <Keyboard
  228. letterColors={{}}
  229. onKeyPress={handleKey}
  230. enterDisabled={updateDisabled}
  231. />
  232. </div>
  233. );
  234. }