123 lines
3.8 KiB
TypeScript
123 lines
3.8 KiB
TypeScript
import { useRef, useState, useCallback, useEffect } from "react";
|
|
import type { UseRenderer } from "./useSudokuRenderer";
|
|
|
|
export function useCursor(rendererManager: UseRenderer) {
|
|
const { containerRef } = rendererManager;
|
|
const [cursor, setCursor] = useState<{ r: number; c: number }>({ r: 0, c: 0 });
|
|
|
|
const animatingRef = useRef(false);
|
|
|
|
const getCellEl = useCallback((r: number, c: number): HTMLElement | null => {
|
|
const container = containerRef.current;
|
|
if (!container) return null;
|
|
const sudokuDiv = container.querySelector("#sudoku");
|
|
if (!sudokuDiv) return null;
|
|
const selector = `.sudoku-cell[data-r="${r}"][data-c="${c}"]`;
|
|
return sudokuDiv.querySelector(selector) as HTMLElement | null;
|
|
}, [containerRef]);
|
|
|
|
const flashInvalid = useCallback(() => {
|
|
const el = getCellEl(cursor.r, cursor.c);
|
|
if (!el) return;
|
|
el.classList.add("invalid-move");
|
|
setTimeout(() => el.classList.remove("invalid-move"), 180);
|
|
}, [cursor, getCellEl]);
|
|
|
|
const flashLocked = useCallback(() => {
|
|
const el = getCellEl(cursor.r, cursor.c);
|
|
if (!el) return;
|
|
el.classList.add("locked-cell");
|
|
setTimeout(() => el.classList.remove("locked-cell"), 300);
|
|
}, [cursor, getCellEl]);
|
|
|
|
const flashValid = useCallback(() => {
|
|
const el = getCellEl(cursor.r, cursor.c);
|
|
if (!el) return;
|
|
el.classList.add("valid-move");
|
|
setTimeout(() => el.classList.remove("valid-move"), 300);
|
|
}, [cursor, getCellEl]);
|
|
|
|
const highlightConflicts = useCallback((conflictIndices: number[]) => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
const sudokuDiv = container.querySelector("#sudoku");
|
|
if (!sudokuDiv) return;
|
|
|
|
conflictIndices.forEach((idx) => {
|
|
const r = Math.floor(idx / 9);
|
|
const c = idx % 9;
|
|
const el = getCellEl(r, c);
|
|
if (el) {
|
|
el.classList.add("conflict");
|
|
}
|
|
});
|
|
|
|
setTimeout(() => {
|
|
conflictIndices.forEach((idx) => {
|
|
const r = Math.floor(idx / 9);
|
|
const c = idx % 9;
|
|
const el = getCellEl(r, c);
|
|
if (el) {
|
|
el.classList.remove("conflict");
|
|
}
|
|
});
|
|
}, 2000);
|
|
}, [containerRef, getCellEl]);
|
|
|
|
const moveCursor = useCallback(
|
|
(dr: number, dc: number, steps = 1) => {
|
|
if (animatingRef.current) return;
|
|
|
|
setCursor((prev) => {
|
|
const targetR = prev.r + (dr * steps);
|
|
const targetC = prev.c + (dc * steps);
|
|
|
|
// Check if the full counted move would go out of bounds
|
|
if (targetR < 0 || targetR > 8 || targetC < 0 || targetC > 8) {
|
|
// Flash invalid and don't move
|
|
setTimeout(() => flashInvalid(), 0);
|
|
return prev;
|
|
}
|
|
|
|
// Valid move - apply it
|
|
return { r: targetR, c: targetC };
|
|
});
|
|
},
|
|
[flashInvalid]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const onLockedCell = () => flashLocked();
|
|
const onValidMove = () => flashValid();
|
|
const onInvalidMove = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail;
|
|
if (detail?.conflicts) {
|
|
highlightConflicts(detail.conflicts);
|
|
}
|
|
flashInvalid();
|
|
};
|
|
|
|
window.addEventListener("sudoku-locked-cell", onLockedCell);
|
|
window.addEventListener("sudoku-valid-move", onValidMove);
|
|
window.addEventListener("sudoku-invalid-move", onInvalidMove as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener("sudoku-locked-cell", onLockedCell);
|
|
window.removeEventListener("sudoku-valid-move", onValidMove);
|
|
window.removeEventListener("sudoku-invalid-move", onInvalidMove as EventListener);
|
|
};
|
|
}, [flashLocked, flashValid, flashInvalid, highlightConflicts]);
|
|
|
|
return {
|
|
cursor,
|
|
setCursor,
|
|
moveCursor,
|
|
getCellEl,
|
|
flashInvalid,
|
|
flashValid,
|
|
highlightConflicts,
|
|
};
|
|
}
|
|
|
|
export type UseCursorType = ReturnType<typeof useCursor>;
|