diff --git a/dictation.css b/dictation.css index c08bc9a..f1dc47e 100644 --- a/dictation.css +++ b/dictation.css @@ -119,10 +119,22 @@ body { min-width: 0; /* Prevent flexbox from overflowing */ } +.menu-toggle-btn { + font-size: 1.5em; + background: none; + border: none; + cursor: pointer; + position: relative; + z-index: 101; +} + /* Config Panel */ #config-panel { width: 350px; height: 100%; + position: fixed; + top: 0; + right: 0; background-color: var(--background-color); border-left: 1px solid var(--medium-gray); padding: 20px; @@ -130,6 +142,13 @@ body { flex-direction: column; gap: 20px; overflow-y: auto; + z-index: 100; + transform: translateX(100%); + transition: transform 0.3s ease-in-out; +} + +#config-panel.config-panel-visible { + transform: translateX(0); } .config-section { @@ -243,6 +262,10 @@ body { text-decoration: line-through; } +.diff-removed { + text-decoration: line-through; +} + /* Font size classes */ .font-size-20 { font-size: 20px; } .font-size-24 { font-size: 24px; } @@ -255,3 +278,9 @@ body { .font-family-verdana { font-family: Verdana, sans-serif; } .font-family-times-new-roman { font-family: 'Times New Roman', Times, serif; } .font-family-courier-new { font-family: 'Courier New', Courier, monospace; } + +.input-incorrect-flash { + border-color: var(--incorrect-color); + box-shadow: 0 0 3px 1px var(--incorrect-color); + transition: border-color 0.1s ease-in-out, box-shadow 0.1s ease-in-out; +} diff --git a/dictation.html b/dictation.html index bd06ee9..8ea655b 100644 --- a/dictation.html +++ b/dictation.html @@ -12,6 +12,7 @@

Dictation Practice

+
@@ -22,17 +23,11 @@

Dictation Practice

- - -
-
@@ -76,6 +71,8 @@

Practice

+ +
diff --git a/dictation.js b/dictation.js index 286a1cc..2a24db4 100644 --- a/dictation.js +++ b/dictation.js @@ -7,14 +7,13 @@ document.addEventListener('DOMContentLoaded', () => { const DB_PREFIX = 'dictation-'; const DB_CONFIG_KEY = 'dictation-config'; const SESSION_STORAGE_KEY = 'dictation-session'; + const INCORRECT_FLASH_DURATION = 300; // --- DOM Elements --- const textSelect = document.getElementById('text-select'); const newTextBtn = document.getElementById('new-text-btn'); const deleteTextBtn = document.getElementById('delete-text-btn'); const textDisplay = document.getElementById('text-display'); - const speakerIcon = document.getElementById('speaker-icon'); - const diffDisplay = document.getElementById('diff-display'); const writingInput = document.getElementById('writing-input'); const speedSlider = document.getElementById('speed-slider'); const fontSizeSelect = document.getElementById('font-size-select'); @@ -25,14 +24,16 @@ document.addEventListener('DOMContentLoaded', () => { const ignoreAccentsCheckbox = document.getElementById('ignore-accents-checkbox'); const ignorePunctuationCheckbox = document.getElementById('ignore-punctuation-checkbox'); const ignoreCaseCheckbox = document.getElementById('ignore-case-checkbox'); + const waitForSpaceCheckbox = document.getElementById('wait-for-space-checkbox'); const textTitleInput = document.getElementById('text-title-input'); const textContentTextarea = document.getElementById('text-content-textarea'); const saveTextBtn = document.getElementById('save-text-btn'); const repeatWordBtn = document.getElementById('repeat-word-btn'); - const revealTextBtn = document.getElementById('reveal-text-btn'); const toggleHiddenBtn = document.getElementById('toggle-hidden-btn'); const resetSettingsBtn = document.getElementById('reset-settings-btn'); const notificationArea = document.getElementById('notification-area'); + const configPanel = document.getElementById('config-panel'); + const menuToggleBtn = document.getElementById('menu-toggle-btn'); // --- App State --- let texts = {}; @@ -47,6 +48,11 @@ document.addEventListener('DOMContentLoaded', () => { // ** Level 1: No Dependencies ** const stripPunctuation = (str) => str.replace(/[\p{P}]/gu, ''); + const obscureWord = (word) => { + // Replaces letters and numbers with black boxes, preserves punctuation. + return word.replace(/[\p{L}\p{N}]/gu, '■'); + }; + const showNotification = (message, duration = 3000) => { notificationArea.textContent = message; notificationArea.classList.remove('hidden'); @@ -190,66 +196,72 @@ document.addEventListener('DOMContentLoaded', () => { speakImmediately(fullText, parseFloat(speedSlider.value)); }; + const flashIncorrect = () => { + writingInput.classList.add('input-incorrect-flash'); + setTimeout(() => { + writingInput.classList.remove('input-incorrect-flash'); + }, INCORRECT_FLASH_DURATION); + }; + const handleContinuousInput = () => { - const sourceSpans = Array.from(textDisplay.querySelectorAll('span')); + const sourceSpans = Array.from(textDisplay.querySelectorAll('.word-span')); const inputValue = writingInput.value; - const inputWords = inputValue.split(/[\s\n]+/).filter(w => w.length > 0); - const isInputComplete = /[\s\n]$/.test(inputValue) || inputValue.length === 0; + const inputTokens = inputValue.match(/\S+\s*/g) || []; const isHiddenMode = hideTextCheckbox.checked; let lastCorrectIndex = -1; - let firstErrorFound = null; - // Part 1: Determine correctness of each word and find the first error + // Part 1: Determine correctness of each word sourceSpans.forEach(span => span.classList.remove('correct', 'incorrect', 'current')); + sourceSpans.forEach((span, index) => { - if (index < inputWords.length) { - const isLastWord = index === inputWords.length - 1; + if (index < inputTokens.length) { + const token = inputTokens[index]; + const inputWord = token.trim(); + const wordIsFinishedBySpace = token.length > inputWord.length; + const isLastToken = index === inputTokens.length - 1; + const normalizedSource = normalizeWord(sourceWords[index]); - const normalizedInput = normalizeWord(inputWords[index]); + const normalizedInput = normalizeWord(inputWord); let isIncorrect = false; - if (isLastWord && !isInputComplete) { // Typing last word - if (!normalizedSource.startsWith(normalizedInput)) { - isIncorrect = true; - } - } else { // Word is complete - if (normalizedInput !== normalizedSource) { - isIncorrect = true; - } + const isWordInteractionComplete = !isLastToken || wordIsFinishedBySpace; + + if (isWordInteractionComplete) { + if (normalizedInput !== normalizedSource) isIncorrect = true; + } else { + if (!normalizedSource.startsWith(normalizedInput)) isIncorrect = true; } if (isIncorrect) { - span.classList.add('incorrect'); - if (!firstErrorFound) { - firstErrorFound = { input: inputWords[index], source: sourceWords[index] }; + if (waitForSpaceCheckbox.checked && !isWordInteractionComplete) { + flashIncorrect(); + } else { + span.classList.add('incorrect'); + if (isHiddenMode) { + const diffHtml = createDiffHtml(inputWord, sourceWords[index]); + span.innerHTML = ''; // Clear the black boxes + span.appendChild(diffHtml); + } } } else { - // Only mark as correct if the word is complete - if (!isLastWord || isInputComplete) { + if (isWordInteractionComplete) { span.classList.add('correct'); + if (isHiddenMode) { + // If it was previously incorrect (showing a diff) or just completed, + // reveal the correct word permanently. + span.textContent = sourceWords[index]; + } lastCorrectIndex = index; } } + } else if (isHiddenMode) { + // For words not yet typed in hidden mode, ensure they are black boxes + span.textContent = obscureWord(sourceWords[index]); } }); - // Part 2: Update the hidden mode display based on whether an error was found - if (isHiddenMode) { - if (firstErrorFound) { - const diffHtml = createDiffHtml(firstErrorFound.input, firstErrorFound.source); - diffDisplay.innerHTML = ''; - diffDisplay.appendChild(diffHtml); - diffDisplay.classList.remove('hidden'); - speakerIcon.classList.add('hidden'); - } else { - // No errors found, show the speaker icon - diffDisplay.classList.add('hidden'); - speakerIcon.classList.remove('hidden'); - } - } - - // Part 3: Update current word index, handle audio, and save session + // Part 2: Update current word index, handle audio, and save session const newWordIndex = lastCorrectIndex + 1; const hasAdvanced = newWordIndex > currentWordIndex; currentWordIndex = newWordIndex; @@ -276,19 +288,38 @@ document.addEventListener('DOMContentLoaded', () => { }; // ** Level 4: Dependencies on Level 3 ** +const renderText = () => { + // Clear previous content safely + textDisplay.innerHTML = ''; + if (!sourceWords.length) { + return; + } + + const isHidden = hideTextCheckbox.checked; + const wordsToRender = isHidden ? sourceWords.map(obscureWord) : sourceWords; + + wordsToRender.forEach((word, index) => { + const span = document.createElement('span'); + span.className = 'word-span'; + span.textContent = word; + textDisplay.appendChild(span); + + // Add a space after each word except the last one + if (index < wordsToRender.length - 1) { + textDisplay.appendChild(document.createTextNode(' ')); + } + }); +}; + const displayText = async (savedInput = '') => { const title = textSelect.value; if (title && texts[title]) { - // Clear any previous diff display when loading new text - diffDisplay.innerHTML = ''; - diffDisplay.classList.add('hidden'); - sourceWords = texts[title].split(' ').filter(w => w.length > 0); currentWordIndex = 0; tabKeyPressCount = 0; writingInput.value = savedInput; - textDisplay.innerHTML = sourceWords.map(word => `${word}`).join(' '); + renderText(); // Use the helper to render the initial text display const lang = await detectLanguage(texts[title]); textDisplay.lang = lang; @@ -327,19 +358,11 @@ document.addEventListener('DOMContentLoaded', () => { }; const toggleHideText = () => { - const isHidden = hideTextCheckbox.checked; - textDisplay.classList.toggle('hidden', isHidden); - revealTextBtn.classList.toggle('hidden', !isHidden); + renderText(); // Re-render the text display based on the new checkbox state + handleContinuousInput(); // Re-apply styles and reveal any existing errors - // When hiding text, show the speaker icon and hide the diff display. - // When showing text, hide both. - speakerIcon.classList.toggle('hidden', !isHidden); - diffDisplay.classList.toggle('hidden', true); // Always hide diff on toggle - - if (isHidden) { + if (hideTextCheckbox.checked) { writingInput.focus(); - // We re-run input handler to check if a diff should be shown immediately - handleContinuousInput(); speakNextWord(); } }; @@ -380,6 +403,7 @@ document.addEventListener('DOMContentLoaded', () => { ignoreAccents: ignoreAccentsCheckbox.checked, ignorePunctuation: ignorePunctuationCheckbox.checked, ignoreCase: ignoreCaseCheckbox.checked, + waitForSpace: waitForSpaceCheckbox.checked, }; await idb.set(DB_CONFIG_KEY, config); }; @@ -395,6 +419,7 @@ document.addEventListener('DOMContentLoaded', () => { ignoreAccentsCheckbox.checked = config.ignoreAccents !== false; ignorePunctuationCheckbox.checked = config.ignorePunctuation !== false; ignoreCaseCheckbox.checked = config.ignoreCase !== false; + waitForSpaceCheckbox.checked = config.waitForSpace !== false; applyConfig({ fontSize: fontSizeSelect.value, fontFamily: fontFamilySelect.value }); toggleHideText(); @@ -471,6 +496,10 @@ document.addEventListener('DOMContentLoaded', () => { ignoreAccentsCheckbox.addEventListener('change', saveConfig); ignorePunctuationCheckbox.addEventListener('change', saveConfig); ignoreCaseCheckbox.addEventListener('change', saveConfig); + waitForSpaceCheckbox.addEventListener('change', () => { + saveConfig(); + handleContinuousInput(); + }); writingInput.addEventListener('keydown', (event) => { if (event.key === 'Tab') { @@ -479,14 +508,6 @@ document.addEventListener('DOMContentLoaded', () => { } }); - const revealHiddenText = () => { - if (hideTextCheckbox.checked) { - hideTextCheckbox.checked = false; - toggleHideText(); - saveConfig(); - } - }; - const toggleHiddenTextMode = () => { hideTextCheckbox.checked = !hideTextCheckbox.checked; toggleHideText(); @@ -497,17 +518,22 @@ document.addEventListener('DOMContentLoaded', () => { if (event.ctrlKey && event.key.toLowerCase() === 's') { event.preventDefault(); speakText(); - } else if (event.key === '`') { - revealHiddenText(); } else if (event.key === 'Escape') { event.preventDefault(); toggleHiddenTextMode(); } }); - revealTextBtn.addEventListener('click', revealHiddenText); toggleHiddenBtn.addEventListener('click', toggleHiddenTextMode); + menuToggleBtn.addEventListener('click', () => { + configPanel.classList.toggle('config-panel-visible'); + }); + + writingInput.addEventListener('focus', () => { + configPanel.classList.remove('config-panel-visible'); + }); + const initializeApp = async () => { await loadConfig(); await loadTexts();