duqing2026's picture
Initial commit: Code Typing Practice
1bd2bb8
raw
history blame
6.87 kB
const codeDisplay = document.getElementById('code-display');
const hiddenInput = document.getElementById('hidden-input');
const wpmDisplay = document.getElementById('wpm');
const accuracyDisplay = document.getElementById('accuracy');
const progressDisplay = document.getElementById('progress');
const langDisplay = document.getElementById('lang-display');
const resultOverlay = document.getElementById('result-overlay');
const finalWpmDisplay = document.getElementById('final-wpm');
const finalAccuracyDisplay = document.getElementById('final-accuracy');
const restartBtn = document.getElementById('restart-btn');
let currentCode = "";
let currentIndex = 0;
let startTime = null;
let timer = null;
let mistakes = 0;
let totalTyped = 0;
let isFinished = false;
// Initialize
function init() {
fetchSnippet();
hiddenInput.focus();
// Keep focus on hidden input
document.addEventListener('click', () => {
if (!isFinished) hiddenInput.focus();
});
}
async function fetchSnippet() {
try {
const response = await fetch('/api/snippet');
const data = await response.json();
setupGame(data.code, data.language);
} catch (e) {
console.error("Failed to fetch snippet", e);
setupGame("print('Hello World')", "Python");
}
}
function setupGame(code, language) {
// Reset state
currentCode = code.replace(/\t/g, " "); // Replace tabs with spaces
currentIndex = 0;
startTime = null;
mistakes = 0;
totalTyped = 0;
isFinished = false;
if (timer) clearInterval(timer);
// Update UI
langDisplay.textContent = language;
wpmDisplay.textContent = '0';
accuracyDisplay.textContent = '100%';
progressDisplay.textContent = '0%';
resultOverlay.classList.add('hidden');
// Render Code
renderCode();
hiddenInput.value = '';
hiddenInput.focus();
}
function renderCode() {
codeDisplay.innerHTML = '';
currentCode.split('').forEach((char, index) => {
const span = document.createElement('span');
span.innerText = char;
if (index === 0) span.classList.add('cursor');
// Visual tweak for spaces/newlines
if (char === '\n') {
span.innerHTML = '↵\n';
span.classList.add('text-slate-700');
}
codeDisplay.appendChild(span);
});
}
function startTimer() {
if (!startTime) {
startTime = new Date();
timer = setInterval(updateStats, 1000);
}
}
function updateStats() {
if (!startTime) return;
const now = new Date();
const timeDiff = (now - startTime) / 1000 / 60; // in minutes
if (timeDiff > 0) {
// WPM = (Characters / 5) / Minutes
const wpm = Math.round((currentIndex / 5) / timeDiff);
wpmDisplay.textContent = wpm;
}
const accuracy = totalTyped === 0 ? 100 : Math.round(((totalTyped - mistakes) / totalTyped) * 100);
accuracyDisplay.textContent = accuracy + '%';
const progress = Math.round((currentIndex / currentCode.length) * 100);
progressDisplay.textContent = progress + '%';
}
function finishGame() {
isFinished = true;
clearInterval(timer);
updateStats();
finalWpmDisplay.textContent = wpmDisplay.textContent;
finalAccuracyDisplay.textContent = accuracyDisplay.textContent;
resultOverlay.classList.remove('hidden');
restartBtn.focus();
}
// Input Handling
hiddenInput.addEventListener('input', (e) => {
if (isFinished) return;
// We don't actually use the input value, we just catch the event
// But to support backspace on mobile properly, we might need more complex logic.
// For this desktop-first version, we'll listen to 'keydown' for control and just check logic here if needed.
// Actually, 'input' event is safer for mobile software keyboards.
const inputChar = e.data;
// Reset input to empty to avoid scrolling or overflow
// BUT, we need to handle the character.
// This approach is tricky with 'input' event because 'data' can be null on some actions.
// Let's rely on keydown for desktop/precision, and input for backup?
// Let's stick to keydown for a "Pro" tool.
});
window.addEventListener('keydown', (e) => {
if (isFinished) {
if (e.key === 'Enter') {
init();
}
return;
}
// Prevent default scrolling for Space
if (e.key === ' ' && e.target === document.body) {
e.preventDefault();
}
if (e.key === 'Tab') {
e.preventDefault();
init(); // Quick restart
return;
}
// Ignore non-character keys (Shift, Ctrl, etc.)
if (e.key.length > 1 && e.key !== 'Enter' && e.key !== 'Backspace') return;
hiddenInput.focus();
startTimer();
const charToType = currentCode[currentIndex];
const spans = codeDisplay.querySelectorAll('span');
if (e.key === 'Backspace') {
// Optional: Allow going back?
// For strict practice, maybe not. Or yes.
// Let's allow simple backspace.
if (currentIndex > 0) {
currentIndex--;
const span = spans[currentIndex];
span.className = ''; // Reset classes
span.classList.add('cursor'); // Move cursor back
// Remove cursor from next char
if (currentIndex + 1 < spans.length) {
spans[currentIndex + 1].classList.remove('cursor');
}
}
return;
}
let typedChar = e.key;
if (typedChar === 'Enter') typedChar = '\n';
totalTyped++;
const currentSpan = spans[currentIndex];
if (typedChar === charToType) {
currentSpan.classList.add('text-emerald-400'); // Correct
currentSpan.classList.remove('text-red-500', 'bg-red-900/50', 'text-slate-500');
currentSpan.classList.remove('cursor');
currentIndex++;
if (currentIndex >= currentCode.length) {
finishGame();
} else {
spans[currentIndex].classList.add('cursor');
// Auto-scroll if cursor is near bottom
const cursorRect = spans[currentIndex].getBoundingClientRect();
const containerRect = codeDisplay.parentElement.getBoundingClientRect();
if (cursorRect.bottom > containerRect.bottom - 40) {
codeDisplay.parentElement.scrollTop += 30; // Scroll down
}
}
} else {
mistakes++;
currentSpan.classList.add('text-red-500', 'bg-red-900/50'); // Incorrect
// We don't advance on mistake, user must type correct key
// Or should we?
// "Stop on error" is better for learning code.
}
});
restartBtn.addEventListener('click', init);
// Start
init();