Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions dictation.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,36 @@ 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;
display: flex;
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 {
Expand Down Expand Up @@ -255,3 +274,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;
}
9 changes: 3 additions & 6 deletions dictation.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<header class="top-bar">
<h1>Dictation Practice</h1>
<div class="top-bar-controls">
<button id="menu-toggle-btn" class="menu-toggle-btn">☰</button>
</div>
</header>

Expand All @@ -22,17 +23,11 @@ <h1>Dictation Practice</h1>
<div id="text-display" class="text-display-styling">
<!-- Spans will be generated here -->
</div>
<div id="diff-display" class="text-display-styling hidden">
<!-- Diff will be generated here -->
</div>
<!-- Speaker icon for hidden mode -->
<div id="speaker-icon" class="hidden">🔊</div>
</div>
<textarea id="writing-input" class="text-display-styling" autocomplete="off" placeholder="Start typing here..."></textarea>
<div id="hotkey-bar">
<button id="toggle-hidden-btn">Toggle Hidden Text (Esc)</button>
<button id="repeat-word-btn">Repeat Word (Tab)</button>
<button id="reveal-text-btn" class="hidden">Reveal Text (`)</button>
</div>
</main>
</div>
Expand Down Expand Up @@ -76,6 +71,8 @@ <h3>Practice</h3>
<input type="checkbox" id="ignore-punctuation-checkbox" checked>
<label for="ignore-case-checkbox">Ignore case:</label>
<input type="checkbox" id="ignore-case-checkbox" checked>
<label for="wait-for-space-checkbox">Wait for space before correction:</label>
<input type="checkbox" id="wait-for-space-checkbox" checked>
</div>
</div>
<div class="config-section">
Expand Down
144 changes: 77 additions & 67 deletions dictation.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ document.addEventListener('DOMContentLoaded', () => {
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');
Expand All @@ -25,14 +23,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 = {};
Expand Down Expand Up @@ -118,6 +118,11 @@ document.addEventListener('DOMContentLoaded', () => {
return fragment;
};

const obscureWord = (word) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better code organization, this new obscureWord helper function should be moved to the // ** Level 1: No Dependencies ** section (around line 47). Since it's a pure function with no external dependencies from this file, placing it with other similar functions will improve code readability and maintainability.

// Replaces letters and numbers with black boxes, preserves punctuation.
return word.replace(/[\p{L}\p{N}]/gu, '■');
};

// ** Level 2: Dependencies on Level 1 **
let isSpeaking = false;

Expand Down Expand Up @@ -190,66 +195,72 @@ document.addEventListener('DOMContentLoaded', () => {
speakImmediately(fullText, parseFloat(speedSlider.value));
};

const flashIncorrect = () => {
writingInput.classList.add('input-incorrect-flash');
setTimeout(() => {
writingInput.classList.remove('input-incorrect-flash');
}, 300);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The timeout duration 300 is a magic number. It's better to define it as a named constant at the top of the file (e.g., INCORRECT_FLASH_DURATION = 300). This improves readability and makes it easier to manage timing-related values, especially since the corresponding CSS transition has a different 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;
Expand All @@ -276,19 +287,26 @@ document.addEventListener('DOMContentLoaded', () => {
};

// ** Level 4: Dependencies on Level 3 **
const renderText = () => {
if (!sourceWords.length) {
textDisplay.innerHTML = '';
return;
}
const isHidden = hideTextCheckbox.checked;
// Render words, obscuring if in hidden mode.
const wordsToRender = isHidden ? sourceWords.map(obscureWord) : sourceWords;
textDisplay.innerHTML = wordsToRender.map(word => `<span class="word-span">${word}</span>`).join(' ');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This function uses innerHTML to render sourceWords, which can come from user input. This creates a potential Cross-Site Scripting (XSS) vulnerability if a user saves text containing malicious HTML.

To fix this, we should build the DOM nodes programmatically using document.createElement and set their content using textContent, which automatically escapes any HTML.

    textDisplay.innerHTML = ''; // Clear previous content
    if (!sourceWords.length) {
        return;
    }

    const isHidden = hideTextCheckbox.checked;
    // Render words, obscuring if in hidden mode.
    const wordsToRender = isHidden ? sourceWords.map(obscureWord) : sourceWords;

    const fragment = document.createDocumentFragment();
    wordsToRender.forEach((word, index) => {
        const span = document.createElement('span');
        span.className = 'word-span';
        span.textContent = word; // Use textContent to prevent XSS
        fragment.appendChild(span);

        // Add a space after each word except the last one
        if (index < wordsToRender.length - 1) {
            fragment.appendChild(document.createTextNode(' '));
        }
    });
    textDisplay.appendChild(fragment);

};

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 => `<span>${word}</span>`).join(' ');
renderText(); // Use the helper to render the initial text display

const lang = await detectLanguage(texts[title]);
textDisplay.lang = lang;
Expand Down Expand Up @@ -327,19 +345,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();
}
};
Expand Down Expand Up @@ -380,6 +390,7 @@ document.addEventListener('DOMContentLoaded', () => {
ignoreAccents: ignoreAccentsCheckbox.checked,
ignorePunctuation: ignorePunctuationCheckbox.checked,
ignoreCase: ignoreCaseCheckbox.checked,
waitForSpace: waitForSpaceCheckbox.checked,
};
await idb.set(DB_CONFIG_KEY, config);
};
Expand All @@ -395,6 +406,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();
Expand Down Expand Up @@ -471,6 +483,7 @@ document.addEventListener('DOMContentLoaded', () => {
ignoreAccentsCheckbox.addEventListener('change', saveConfig);
ignorePunctuationCheckbox.addEventListener('change', saveConfig);
ignoreCaseCheckbox.addEventListener('change', saveConfig);
waitForSpaceCheckbox.addEventListener('change', saveConfig);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When the 'Wait for space' checkbox is toggled, the change should be reflected immediately in the UI. Currently, it only saves the configuration, and the user has to type something for the change to take effect.

By also calling handleContinuousInput(), we can re-evaluate the current input and apply the new display logic instantly, which provides a better user experience.

    waitForSpaceCheckbox.addEventListener('change', () => { 
        saveConfig(); 
        handleContinuousInput(); 
    });


writingInput.addEventListener('keydown', (event) => {
if (event.key === 'Tab') {
Expand All @@ -479,14 +492,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});

const revealHiddenText = () => {
if (hideTextCheckbox.checked) {
hideTextCheckbox.checked = false;
toggleHideText();
saveConfig();
}
};

const toggleHiddenTextMode = () => {
hideTextCheckbox.checked = !hideTextCheckbox.checked;
toggleHideText();
Expand All @@ -497,17 +502,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();
Expand Down