Added mode state machine with visual feedback: Mode State: - CursorMode type: 'normal' | 'edit' - Tracked in useCursor with useState - Starts in 'normal' mode Key Bindings: - Press 'i' in normal mode → Enter edit mode - Press 'Escape' in edit mode → Return to normal mode - All vim motions (hjkl, anchors, etc.) only work in normal mode - Edit mode blocks all movement keys Visual Feedback: - Normal mode: Blue outline (rgb(59, 130, 246)) - Edit mode: Green pulsing outline (rgb(34, 197, 94)) - Smooth pulse animation (1.5s ease-in-out infinite) - cursor-edit CSS class applied in edit mode Flow: 1. Game starts in normal mode (blue cursor) 2. Press 'i' → Green pulsing cursor (edit mode) 3. Press 'Escape' → Blue cursor (normal mode) No digit entry yet - just state machine and visual indicators. Phase 40.3.1 complete.
166 lines
3.9 KiB
TypeScript
166 lines
3.9 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { Coord, CursorManager, CursorMode } from './types';
|
|
import type { GameStatusManager } from '../useGameStatus/types';
|
|
import { useVimMotions } from './useVimMotions';
|
|
|
|
export function useCursor(gameStatusManager: GameStatusManager, containerRef: React.RefObject<HTMLDivElement | null>) {
|
|
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
|
|
const [mode, setMode] = useState<CursorMode>('normal');
|
|
const { gameStatus } = gameStatusManager;
|
|
const vimMotions = useVimMotions();
|
|
|
|
function flashInvalidMove(): void {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const cells = container.querySelectorAll('.sudoku-cell');
|
|
const index = position.row * 9 + position.col;
|
|
const targetCell = cells[index];
|
|
|
|
if (targetCell) {
|
|
targetCell.classList.add('invalid-move');
|
|
setTimeout(() => {
|
|
targetCell.classList.remove('invalid-move');
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
function move(dRows: number, dCols: number, count: number = 1): void {
|
|
setPosition(currentPos => {
|
|
const newRow = currentPos.row + (dRows * count);
|
|
const newCol = currentPos.col + (dCols * count);
|
|
|
|
if (newRow < 0 || newRow > 8 || newCol < 0 || newCol > 8) {
|
|
flashInvalidMove();
|
|
return currentPos;
|
|
}
|
|
|
|
return { row: newRow, col: newCol };
|
|
});
|
|
}
|
|
|
|
function moveLeft(count = 1): void {
|
|
move(0, -1, count);
|
|
}
|
|
|
|
function moveRight(count = 1): void {
|
|
move(0, 1, count);
|
|
}
|
|
|
|
function moveUp(count = 1): void {
|
|
move(-1, 0, count);
|
|
}
|
|
|
|
function moveDown(count = 1): void {
|
|
move(1, 0, count);
|
|
}
|
|
|
|
function moveToStart(): void {
|
|
setPosition(prev => ({ ...prev, col: 0 }));
|
|
}
|
|
|
|
function moveToEnd(): void {
|
|
setPosition(prev => ({ ...prev, col: 8 }));
|
|
}
|
|
|
|
function moveToTop(): void {
|
|
setPosition(prev => ({ row: 0, col: prev.col }));
|
|
}
|
|
|
|
function moveToBottom(): void {
|
|
setPosition(prev => ({ row: 8, col: prev.col }));
|
|
}
|
|
|
|
function repeatLastMotion(): void {
|
|
const motion = vimMotions.repeatLastMotion();
|
|
if (!motion) return;
|
|
|
|
setPosition(currentPos => {
|
|
const newRow = currentPos.row + (motion.dr * motion.steps);
|
|
const newCol = currentPos.col + (motion.dc * motion.steps);
|
|
|
|
if (newRow < 0 || newRow > 8 || newCol < 0 || newCol > 8) {
|
|
flashInvalidMove();
|
|
return currentPos;
|
|
}
|
|
|
|
return { row: newRow, col: newCol };
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (gameStatus !== 'started') return;
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
const key = e.key;
|
|
|
|
if (mode === 'edit') {
|
|
if (key === 'Escape') {
|
|
setMode('normal');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key === 'i') {
|
|
setMode('edit');
|
|
return;
|
|
}
|
|
|
|
const result = vimMotions.processKey(key);
|
|
|
|
if (!result) return;
|
|
|
|
switch (result.type) {
|
|
case 'move':
|
|
move(result.data.dr, result.data.dc, result.data.steps);
|
|
break;
|
|
|
|
case 'anchor':
|
|
switch (result.data) {
|
|
case 'start':
|
|
moveToStart();
|
|
break;
|
|
case 'end':
|
|
moveToEnd();
|
|
break;
|
|
case 'top':
|
|
moveToTop();
|
|
break;
|
|
case 'bottom':
|
|
moveToBottom();
|
|
break;
|
|
}
|
|
break;
|
|
|
|
case 'repeat':
|
|
if (result.data) {
|
|
move(result.data.dr, result.data.dc, result.data.steps);
|
|
}
|
|
break;
|
|
|
|
case 'count':
|
|
break;
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [gameStatus, mode]);
|
|
|
|
const cursor: CursorManager = {
|
|
position: () => position,
|
|
mode: () => mode,
|
|
moveLeft,
|
|
moveRight,
|
|
moveUp,
|
|
moveDown,
|
|
moveToStart,
|
|
moveToEnd,
|
|
moveToTop,
|
|
moveToBottom,
|
|
repeatLastMotion,
|
|
};
|
|
|
|
return cursor;
|
|
}
|