Compare commits

...

1 Commits

Author SHA1 Message Date
Laura Damian
5a77e7d1de add key fix 2026-01-09 13:37:02 +01:00
3 changed files with 130 additions and 18 deletions

View File

@ -46,6 +46,7 @@ const App: React.FC = () => {
const [isTutorOpen, setIsTutorOpen] = useState(false); const [isTutorOpen, setIsTutorOpen] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [feedback, setFeedback] = useState<{ type: 'correct' | 'wrong', id: number } | null>(null); const [feedback, setFeedback] = useState<{ type: 'correct' | 'wrong', id: number } | null>(null);
const feedbackIdRef = useRef(0);
// Responsive check for "Short Landscape" (Mobile Landscape) // Responsive check for "Short Landscape" (Mobile Landscape)
// We want side-by-side layout here because vertical space is scarce. // We want side-by-side layout here because vertical space is scarce.
@ -183,7 +184,8 @@ const App: React.FC = () => {
if (answer === currentProblem.answer) { if (answer === currentProblem.answer) {
// Correct! // Correct!
audioService.playCorrect(); audioService.playCorrect();
setFeedback({ type: 'correct', id: Date.now() }); feedbackIdRef.current += 1;
setFeedback({ type: 'correct', id: feedbackIdRef.current });
setGameState(prev => { setGameState(prev => {
const newScore = prev.score + 10; const newScore = prev.score + 10;
@ -222,7 +224,8 @@ const App: React.FC = () => {
} else { } else {
// Incorrect // Incorrect
audioService.playIncorrect(); audioService.playIncorrect();
setFeedback({ type: 'wrong', id: Date.now() }); feedbackIdRef.current += 1;
setFeedback({ type: 'wrong', id: feedbackIdRef.current });
setGameState(prev => ({ ...prev, streak: 0 })); setGameState(prev => ({ ...prev, streak: 0 }));
// Visual feedback logic could go here (e.g. screen shake), handled by UI via streak reset // Visual feedback logic could go here (e.g. screen shake), handled by UI via streak reset
} }

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Problem, GameStatus } from '../types'; import { Problem, GameStatus } from '../types';
import { Delete, ArrowRight } from 'lucide-react'; import { Delete, ArrowRight } from 'lucide-react';
@ -11,27 +11,130 @@ interface ControlPanelProps {
export const ControlPanel: React.FC<ControlPanelProps> = ({ problem, status, onAnswer, forceVertical = false }) => { export const ControlPanel: React.FC<ControlPanelProps> = ({ problem, status, onAnswer, forceVertical = false }) => {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const lastKeyPressRef = useRef<{ key: string; time: number } | null>(null);
const processingRef = useRef(false);
const onAnswerRef = useRef(onAnswer);
const listenerIdRef = useRef(Math.random());
const lastClickRef = useRef<{ button: number; time: number } | null>(null);
// Keep onAnswer ref up to date
useEffect(() => {
onAnswerRef.current = onAnswer;
}, [onAnswer]);
// Clear input when problem changes // Clear input when problem changes
useEffect(() => { useEffect(() => {
setInput(''); setInput('');
}, [problem?.id]); }, [problem?.id]);
const handleNumClick = (num: number) => { const handleNumClick = useCallback((num: number) => {
if (input.length < 3) { // Max 3 digits (answer < 200 usually) const clickTime = Date.now();
setInput(prev => prev + num.toString()); const lastClick = lastClickRef.current;
// Debounce: prevent clicks within 150ms
if (lastClick && lastClick.button === num && clickTime - lastClick.time < 150) {
console.log(`⛔ BLOCKED - too fast (${clickTime - lastClick.time}ms)`);
return;
} }
};
lastClickRef.current = { button: num, time: clickTime };
setInput(prev => {
if (prev.length < 3) {
return prev + num.toString();
}
return prev;
});
}, []);
const handleDelete = () => { const handleDelete = useCallback(() => {
setInput(prev => prev.slice(0, -1)); setInput(prev => prev.slice(0, -1));
}; }, []);
const handleSubmit = (e?: React.FormEvent) => { const handleSubmit = useCallback((e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
if (input === '') return; setInput(prev => {
onAnswer(parseInt(input, 10)); if (prev !== '') {
}; onAnswerRef.current(parseInt(prev, 10));
}
return prev;
});
}, []);
// Keyboard event handler
useEffect(() => {
if (status !== GameStatus.PLAYING || !problem) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle if not repeating (key held down)
if (e.repeat) {
return;
}
// Prevent double processing
if (processingRef.current) {
e.preventDefault();
return;
}
// Strict debounce check
const now = Date.now();
const lastPress = lastKeyPressRef.current;
if (lastPress && lastPress.key === e.key && now - lastPress.time < 200) {
e.preventDefault();
return;
}
// Number keys (0-9)
if (e.key >= '0' && e.key <= '9') {
e.preventDefault();
processingRef.current = true;
lastKeyPressRef.current = { key: e.key, time: now };
const num = parseInt(e.key, 10);
setInput(prev => {
if (prev.length < 3) {
return prev + num.toString();
}
return prev;
});
setTimeout(() => {
processingRef.current = false;
}, 50);
}
// Backspace/Delete
else if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
processingRef.current = true;
lastKeyPressRef.current = { key: e.key, time: now };
setInput(prev => prev.slice(0, -1));
setTimeout(() => { processingRef.current = false; }, 50);
}
// Enter
else if (e.key === 'Enter') {
e.preventDefault();
processingRef.current = true;
lastKeyPressRef.current = { key: e.key, time: now };
setInput(prev => {
if (prev !== '') {
onAnswerRef.current(parseInt(prev, 10));
}
return prev;
});
setTimeout(() => { processingRef.current = false; }, 50);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [status, problem]);
if (status !== GameStatus.PLAYING || !problem) { if (status !== GameStatus.PLAYING || !problem) {
return <div className="h-full bg-slate-100 rounded-t-xl animate-pulse"></div>; return <div className="h-full bg-slate-100 rounded-t-xl animate-pulse"></div>;
@ -61,21 +164,21 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ problem, status, onA
<form onSubmit={handleSubmit} className="grid grid-cols-3 gap-2 h-full"> <form onSubmit={handleSubmit} className="grid grid-cols-3 gap-2 h-full">
{/* Row 1: 1, 2, 3 */} {/* Row 1: 1, 2, 3 */}
{[1, 2, 3].map((num) => ( {[1, 2, 3].map((num) => (
<button key={num} type="button" onClick={() => handleNumClick(num)} className={btnClass}> <button key={num} type="button" onPointerDown={(e) => { e.preventDefault(); handleNumClick(num); }} className={btnClass}>
{num} {num}
</button> </button>
))} ))}
{/* Row 2: 4, 5, 6 */} {/* Row 2: 4, 5, 6 */}
{[4, 5, 6].map((num) => ( {[4, 5, 6].map((num) => (
<button key={num} type="button" onClick={() => handleNumClick(num)} className={btnClass}> <button key={num} type="button" onPointerDown={(e) => { e.preventDefault(); handleNumClick(num); }} className={btnClass}>
{num} {num}
</button> </button>
))} ))}
{/* Row 3: 7, 8, 9 */} {/* Row 3: 7, 8, 9 */}
{[7, 8, 9].map((num) => ( {[7, 8, 9].map((num) => (
<button key={num} type="button" onClick={() => handleNumClick(num)} className={btnClass}> <button key={num} type="button" onPointerDown={(e) => { e.preventDefault(); handleNumClick(num); }} className={btnClass}>
{num} {num}
</button> </button>
))} ))}
@ -83,13 +186,13 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ problem, status, onA
{/* Row 4: Del, 0, Enter */} {/* Row 4: Del, 0, Enter */}
<button <button
type="button" type="button"
onClick={handleDelete} onPointerDown={(e) => { e.preventDefault(); handleDelete(); }}
className="bg-red-100 hover:bg-red-200 text-red-600 font-bold rounded-lg shadow-sm border-b-4 border-red-200 active:border-b-0 active:translate-y-1 transition-all h-full flex items-center justify-center touch-manipulation select-none" className="bg-red-100 hover:bg-red-200 text-red-600 font-bold rounded-lg shadow-sm border-b-4 border-red-200 active:border-b-0 active:translate-y-1 transition-all h-full flex items-center justify-center touch-manipulation select-none"
> >
<Delete /> <Delete />
</button> </button>
<button type="button" onClick={() => handleNumClick(0)} className={btnClass}> <button type="button" onPointerDown={(e) => { e.preventDefault(); handleNumClick(0); }} className={btnClass}>
0 0
</button> </button>

View File

@ -51,6 +51,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@ -1215,6 +1216,7 @@
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -1347,6 +1349,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -1991,6 +1994,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2032,6 +2036,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -2365,6 +2370,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",