- Archive previous implementation attempt (src -> src_failed) - Create comprehensive IMPLEMENTATION_PLAN.md with phased approach - Create IMPLEMENTATION.md to track implementation progress - Update example app to match vim-maze patterns: - Simplify UI to desktop-only (no mobile support) - Add score metrics display (time, keystrokes, final score) - Update instructions for sudoku-specific keys (hjkl, i for edit) - Remove unnecessary mobile hooks Phase 5 complete. Ready to begin Phase 10 (Setup & Core Types).
275 lines
7.5 KiB
TypeScript
275 lines
7.5 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import type { BoardManager } from '../useBoard/types';
|
|
import type { CursorManager, CursorMode, Coord, VimMotionSystem, GameKeyManager, KeyLogEntry } from './types';
|
|
import { useVimMotions } from './useVimMotions';
|
|
|
|
export function useCursor(board: BoardManager): CursorManager & { keyManager: GameKeyManager } {
|
|
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
|
|
const [mode, setMode] = useState<CursorMode>('normal');
|
|
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
|
|
|
|
const vimMotions = useVimMotions();
|
|
|
|
const addKeyToLog = useCallback((key: string) => {
|
|
setKeyLog(prev => [...prev, { key, timestamp: Date.now() }]);
|
|
}, []);
|
|
|
|
const move = useCallback((dRows: number, dCols: number, count: number) => {
|
|
setPosition(prev => {
|
|
const newRow = prev.row + (dRows * count);
|
|
const newCol = prev.col + (dCols * count);
|
|
|
|
if (newRow < 0 || newRow > 8 || newCol < 0 || newCol > 8) {
|
|
flashInvalid();
|
|
return prev;
|
|
}
|
|
|
|
return { row: newRow, col: newCol };
|
|
});
|
|
}, []);
|
|
|
|
const moveLeft = useCallback((count = 1) => {
|
|
move(0, -1, count);
|
|
}, [move]);
|
|
|
|
const moveRight = useCallback((count = 1) => {
|
|
move(0, 1, count);
|
|
}, [move]);
|
|
|
|
const moveUp = useCallback((count = 1) => {
|
|
move(-1, 0, count);
|
|
}, [move]);
|
|
|
|
const moveDown = useCallback((count = 1) => {
|
|
move(1, 0, count);
|
|
}, [move]);
|
|
|
|
const moveToStart = useCallback(() => {
|
|
setPosition(prev => ({ ...prev, col: 0 }));
|
|
}, []);
|
|
|
|
const moveToEnd = useCallback(() => {
|
|
setPosition(prev => ({ ...prev, col: 8 }));
|
|
}, []);
|
|
|
|
const moveToTop = useCallback(() => {
|
|
setPosition({ row: 0, col: 0 });
|
|
}, []);
|
|
|
|
const moveToBottom = useCallback(() => {
|
|
setPosition({ row: 8, col: 0 });
|
|
}, []);
|
|
|
|
const repeatLastMotion = useCallback(() => {
|
|
const lastMotion = vimMotions.repeatLastMotion();
|
|
if (lastMotion) {
|
|
move(lastMotion.dr, lastMotion.dc, lastMotion.steps);
|
|
}
|
|
}, [vimMotions, move]);
|
|
|
|
const resetCount = useCallback(() => {
|
|
vimMotions.resetCount();
|
|
}, [vimMotions]);
|
|
|
|
const getCount = useCallback(() => {
|
|
return vimMotions.getCount().toString();
|
|
}, [vimMotions]);
|
|
|
|
const hasCount = useCallback(() => {
|
|
return vimMotions.hasCount();
|
|
}, [vimMotions]);
|
|
|
|
const setCount = useCallback((digit: string) => {
|
|
vimMotions.processKey(digit);
|
|
}, [vimMotions]);
|
|
|
|
const setLastKey = useCallback((key: string) => {
|
|
addKeyToLog(key);
|
|
}, [addKeyToLog]);
|
|
|
|
const getLastKey = useCallback(() => {
|
|
return keyLog[keyLog.length - 1]?.key || '';
|
|
}, [keyLog]);
|
|
|
|
const flashInvalid = useCallback(() => {
|
|
const container = board.containerRef.current;
|
|
if (!container) return;
|
|
|
|
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
|
|
if (cellEl) {
|
|
cellEl.classList.add('invalid-move');
|
|
setTimeout(() => cellEl.classList.remove('invalid-move'), 180);
|
|
}
|
|
}, [board, position]);
|
|
|
|
const flashValid = useCallback(() => {
|
|
const container = board.containerRef.current;
|
|
if (!container) return;
|
|
|
|
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
|
|
if (cellEl) {
|
|
cellEl.classList.add('valid-move');
|
|
setTimeout(() => cellEl.classList.remove('valid-move'), 300);
|
|
}
|
|
}, [board, position]);
|
|
|
|
const flashLocked = useCallback(() => {
|
|
const container = board.containerRef.current;
|
|
if (!container) return;
|
|
|
|
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
|
|
if (cellEl) {
|
|
cellEl.classList.add('locked-cell');
|
|
setTimeout(() => cellEl.classList.remove('locked-cell'), 300);
|
|
}
|
|
}, [board, position]);
|
|
|
|
const highlightConflicts = useCallback((conflictIndices: number[]) => {
|
|
const container = board.containerRef.current;
|
|
if (!container) return;
|
|
|
|
conflictIndices.forEach((idx) => {
|
|
const row = Math.floor(idx / 9);
|
|
const col = idx % 9;
|
|
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
|
if (cellEl) {
|
|
cellEl.classList.add('conflict');
|
|
}
|
|
});
|
|
|
|
setTimeout(() => {
|
|
conflictIndices.forEach((idx) => {
|
|
const row = Math.floor(idx / 9);
|
|
const col = idx % 9;
|
|
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
|
if (cellEl) {
|
|
cellEl.classList.remove('conflict');
|
|
}
|
|
});
|
|
}, 2000);
|
|
}, [board]);
|
|
|
|
const clearKeyLog = useCallback(() => {
|
|
setKeyLog([]);
|
|
}, []);
|
|
|
|
const getKeyLog = useCallback(() => {
|
|
return [...keyLog];
|
|
}, [keyLog]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const key = e.key;
|
|
const result = vimMotions.processKey(key);
|
|
|
|
if (!result) return;
|
|
|
|
addKeyToLog(key);
|
|
|
|
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 'mode':
|
|
setMode(result.data);
|
|
break;
|
|
|
|
case 'digit':
|
|
if (mode === 'edit') {
|
|
const success = board.setCell(position.row, position.col, result.data);
|
|
if (success) {
|
|
flashValid();
|
|
} else if (!board.canEditCell(position.row, position.col)) {
|
|
flashLocked();
|
|
} else {
|
|
const conflicts = board.getConflicts(position.row, position.col);
|
|
if (conflicts.length > 0) {
|
|
highlightConflicts(conflicts);
|
|
}
|
|
flashInvalid();
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'erase':
|
|
if (mode === 'edit') {
|
|
const success = board.setCell(position.row, position.col, '.');
|
|
if (success) {
|
|
flashValid();
|
|
} else if (!board.canEditCell(position.row, position.col)) {
|
|
flashLocked();
|
|
} else {
|
|
flashInvalid();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [vimMotions, addKeyToLog, move, moveToStart, moveToEnd, moveToTop, moveToBottom, mode, board, position, flashValid, flashLocked, flashInvalid, highlightConflicts]);
|
|
|
|
const cursorManager: CursorManager = {
|
|
position: () => position,
|
|
mode: () => mode,
|
|
move,
|
|
moveLeft,
|
|
moveRight,
|
|
moveUp,
|
|
moveDown,
|
|
moveToStart,
|
|
moveToEnd,
|
|
moveToTop,
|
|
moveToBottom,
|
|
repeatLastMotion,
|
|
resetCount,
|
|
getCount,
|
|
hasCount,
|
|
setCount,
|
|
setLastKey,
|
|
getLastKey,
|
|
setMode,
|
|
flashInvalid,
|
|
flashValid,
|
|
flashLocked,
|
|
highlightConflicts
|
|
};
|
|
|
|
const keyManager: GameKeyManager = {
|
|
keyLog,
|
|
clearKeyLog,
|
|
getKeyLog,
|
|
editMode: mode,
|
|
setEditMode: setMode
|
|
};
|
|
|
|
return {
|
|
...cursorManager,
|
|
keyManager
|
|
};
|
|
} |