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 @@
@@ -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();