fix & feat: resolve user reports #1 through #8 (TTS voice & speed, modals, scrollbar, CEFR hint, SRS grammar exclusion, global word lookup)

This commit is contained in:
Antigravity committed 2026-10-08 12:37:41 +03:00
1 parent 8cffae46fa
commit 7871191c90
12 files changed
+443 -103

No files matched your search

+56 -10
View File
@@ -4,19 +4,22 @@
// =======================================================================
import { api } from './api.js';
import { showToast, escapeHtml } from './ui.js';
import { showToast, escapeHtml, playTextSpeech } from './ui.js';
let currentLevel = 'A1';
let currentMode = 'all';
let currentQuiz = [];
let currentQuestionIdx = 0;
let isAnswered = false;
let hintUsedThisQuestion = false;
const sessionStats = {
total: 0,
correct: 0,
mistakes: 0,
ingested: 0,
score: 0,
hintsUsed: 0,
};
// -----------------------------------------------------------------------
@@ -391,6 +394,8 @@ export async function startCefrQuiz(level = 'A1', mode = 'all') {
sessionStats.correct = 0;
sessionStats.mistakes = 0;
sessionStats.ingested = 0;
sessionStats.score = 0;
sessionStats.hintsUsed = 0;
showCefrScreen('quiz');
@@ -459,6 +464,8 @@ function renderCurrentQuestion() {
const cardContainer = document.getElementById('cefr-cloze-card');
if (!cardContainer) return;
hintUsedThisQuestion = false;
// Stylize the sentence blank: replace ___ with a visible blank node
const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '<span class="cefr-blank" id="cefr-blank-target">_____</span>');
@@ -473,10 +480,15 @@ function renderCurrentQuestion() {
<p class="cefr-sentence-text">${formattedSentence}</p>
</div>
<div class="cefr-clue-box">
<span class="clue-icon">💡</span>
<span class="clue-label">Значение:</span>
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
<div class="cefr-clue-box" id="cefr-clue-box">
<button type="button" class="btn btn-xs btn-outline cefr-hint-btn" id="cefr-hint-btn" title="Показать подсказку с переводом слова (-0.2 балла)">
💡 Подсказка (-0.2 балла)
</button>
<div id="cefr-hint-content" class="cefr-hint-content hidden">
<span class="clue-icon">💡</span>
<span class="clue-label">Значение:</span>
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
</div>
</div>
<div class="cefr-options-grid" id="cefr-options-grid">
@@ -491,6 +503,17 @@ function renderCurrentQuestion() {
<div id="cefr-feedback-box" class="cefr-feedback-box hidden"></div>
`;
// Hook up hint toggle button
const hintBtn = cardContainer.querySelector('#cefr-hint-btn');
const hintContent = cardContainer.querySelector('#cefr-hint-content');
if (hintBtn && hintContent) {
hintBtn.addEventListener('click', () => {
hintUsedThisQuestion = true;
hintBtn.classList.add('hidden');
hintContent.classList.remove('hidden');
});
}
// Attach click listeners to option buttons
cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => {
btn.addEventListener('click', () => {
@@ -524,6 +547,10 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
if (res.correct) {
sessionStats.correct++;
const pts = hintUsedThisQuestion ? 0.8 : 1.0;
sessionStats.score += pts;
if (hintUsedThisQuestion) sessionStats.hintsUsed++;
clickedBtn.classList.add('opt-correct');
if (blankTarget) {
blankTarget.textContent = res.correct_option;
@@ -533,16 +560,28 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
if (feedbackBox) {
feedbackBox.className = 'cefr-feedback-box feedback-correct';
feedbackBox.innerHTML = `
<div class="fb-title">✓ Верно! Отличный выбор.</div>
<div class="fb-meta">
<div class="fb-title">✓ Верно! ${pts === 0.8 ? 'Получено: <strong>+0.8 балла</strong> (с подсказкой)' : 'Получено: <strong>+1.0 балл</strong>'}</div>
<div class="fb-meta" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
<span class="audio-ctrl-group">
<button type="button" class="icon-btn audio-xs-btn play-cefr-word-audio-btn" title="Озвучить слово (1.0x)">🔊</button>
<button type="button" class="icon-btn audio-xs-btn audio-slow-btn play-cefr-word-slow-btn" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
</span>
</div>
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
${renderOptionsBreakdown(res.options_breakdown)}
`;
feedbackBox.classList.remove('hidden');
feedbackBox.querySelector('.play-cefr-word-audio-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(res.correct_option, 'en', 1.0);
});
feedbackBox.querySelector('.play-cefr-word-slow-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(res.correct_option, 'en', 0.75);
});
attachBreakdownListeners(feedbackBox, sentence);
}
} else {
@@ -550,6 +589,12 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
// Two words were ingested: the correct target word and the clicked distractor
sessionStats.ingested += 2;
// Automatically reveal clue on mistake so user sees the meaning
const hBtn = document.getElementById('cefr-hint-btn');
const hCnt = document.getElementById('cefr-hint-content');
if (hBtn) hBtn.classList.add('hidden');
if (hCnt) hCnt.classList.remove('hidden');
clickedBtn.classList.add('opt-wrong');
// Highlight the true correct option
@@ -623,7 +668,8 @@ function renderQuizSummary() {
const correct = sessionStats.correct;
const mistakes = sessionStats.mistakes;
const ingested = sessionStats.ingested;
const pct = Math.round((correct / total) * 100);
const earnedScore = Math.round(sessionStats.score * 10) / 10;
const pct = Math.round((earnedScore / total) * 100);
const sumScore = document.getElementById('cefr-sum-score');
const sumLead = document.getElementById('cefr-sum-lead');
@@ -633,8 +679,8 @@ function renderQuizSummary() {
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
if (sumScore) sumScore.textContent = `${pct}%`;
if (sumCorrect) sumCorrect.textContent = `${correct} / ${total}`;
if (sumMistakes) sumMistakes.textContent = mistakes;
if (sumCorrect) sumCorrect.textContent = `${earnedScore} / ${total} баллов (${correct} верных)`;
if (sumMistakes) sumMistakes.innerHTML = `${mistakes} ${sessionStats.hintsUsed > 0 ? `<span style="font-size:12px;opacity:0.8;">(подсказок: ${sessionStats.hintsUsed})</span>` : ''}`;
if (sumIngested) sumIngested.textContent = ingested;
if (sumLead) {
+12 -6
View File
@@ -167,8 +167,7 @@ export function initDictionary() {
audioPlayBtn.addEventListener('click', () => {
const term = currentLookupData?.term || currentLookupData?.english_term;
if (!term) return;
const url = currentLookupData.audio_url || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
playAudio(url);
playTextSpeech(term, 'en');
});
}
@@ -402,7 +401,10 @@ export function initDictionary() {
${c.context_sentence ? `
<div class="card-context">
“${escapeHtml(c.context_sentence)}”
<button class="icon-btn audio-play-btn play-context-audio-btn" data-id="${c.id}" title="Озвучить пример">🔊</button>
<span class="audio-ctrl-group">
<button class="icon-btn audio-play-btn play-context-audio-btn" data-id="${c.id}" title="Озвучить пример (1.0x)">🔊</button>
<button class="icon-btn audio-slow-btn play-context-slow-btn" data-id="${c.id}" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
</span>
</div>` : ''}
<div class="card-bottom">
<div class="card-tags">
@@ -416,13 +418,17 @@ export function initDictionary() {
// Audio buttons
cardEl.querySelector('.play-term-audio-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
if (c.audio_url) playAudio(c.audio_url);
else playTextSpeech(c.term, 'en');
playTextSpeech(c.term, 'en', 1.0);
});
cardEl.querySelector('.play-context-audio-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(c.context_sentence, 'en');
playTextSpeech(c.context_sentence, 'en', 1.0);
});
cardEl.querySelector('.play-context-slow-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(c.context_sentence, 'en', 0.75);
});
// Edit button
+13 -9
View File
@@ -3,7 +3,7 @@
// =======================================================================
import { api } from './api.js';
import { escapeHtml, escapeRegex, slugify, showToast, playAudio } from './ui.js';
import { escapeHtml, escapeRegex, slugify, showToast, playAudio, playTextSpeech } from './ui.js';
import { getAllCards, fetchCards } from './dictionary.js';
// Reader State & Appearance Settings
@@ -2251,6 +2251,7 @@ export function initReader() {
const popupAudioBtn = document.getElementById('popup-audio-btn');
const popupAudioSentenceBtn = document.getElementById('popup-audio-sentence-btn');
const popupAudioSentenceSlowBtn = document.getElementById('popup-audio-sentence-slow-btn');
const popupAudioRuBtn = document.getElementById('popup-audio-ru-btn');
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
@@ -2265,9 +2266,7 @@ export function initReader() {
popupAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.term) return;
const term = popupCurrentData.term;
const url = popupCurrentData.audioUrl || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
playAudio(url);
playTextSpeech(popupCurrentData.term, 'en');
});
}
@@ -2275,9 +2274,15 @@ export function initReader() {
popupAudioSentenceBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
const sentence = popupCurrentData.contextSentence;
const url = `/api/tts?text=${encodeURIComponent(sentence)}&lang=en`;
playAudio(url);
playTextSpeech(popupCurrentData.contextSentence, 'en', 1.0);
});
}
if (popupAudioSentenceSlowBtn) {
popupAudioSentenceSlowBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
playTextSpeech(popupCurrentData.contextSentence, 'en', 0.75);
});
}
@@ -2286,8 +2291,7 @@ export function initReader() {
e.stopPropagation();
const ruText = (popupSentenceRu && popupSentenceRu.textContent || '').trim();
if (!ruText || ruText.startsWith('(') || ruText.startsWith('Ошибка')) return;
const url = `/api/tts?text=${encodeURIComponent(ruText)}&lang=ru`;
playAudio(url);
playTextSpeech(ruText, 'ru', 1.0);
});
}
+22 -13
View File
@@ -3,7 +3,7 @@
// =======================================================================
import { api } from './api.js';
import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio, cleanPromptTranslation } from './ui.js';
import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio, playTextSpeech, cleanPromptTranslation } from './ui.js';
import { getAllCards } from './dictionary.js';
let srsQueue = [];
@@ -53,6 +53,7 @@ export function initSrs() {
const fcBackContextBox = document.getElementById('fc-back-context-box');
const fcBackContext = document.getElementById('fc-back-context');
const fcBackContextAudioBtn = document.getElementById('fc-back-context-audio-btn');
const fcBackContextAudioSlowBtn = document.getElementById('fc-back-context-audio-slow-btn');
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
const fcBackNotes = document.getElementById('fc-back-notes');
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
@@ -352,6 +353,9 @@ export function initSrs() {
if (fcBackContextAudioBtn) {
fcBackContextAudioBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
}
if (fcBackContextAudioSlowBtn) {
fcBackContextAudioSlowBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
}
}
if (fcFrontAudioBtn) {
@@ -359,8 +363,7 @@ export function initSrs() {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
playTextSpeech(card.term, 'en', 1.0);
});
}
@@ -369,8 +372,7 @@ export function initSrs() {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
playTextSpeech(card.term, 'en', 1.0);
});
}
@@ -379,8 +381,16 @@ export function initSrs() {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card || !card.context_sentence) return;
const url = card.context_audio_url || `/api/tts?text=${encodeURIComponent(card.context_sentence)}&lang=en`;
playAudio(url);
playTextSpeech(card.context_sentence, 'en', 1.0);
});
}
if (fcBackContextAudioSlowBtn) {
fcBackContextAudioSlowBtn.addEventListener('click', (e) => {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card || !card.context_sentence) return;
playTextSpeech(card.context_sentence, 'en', 0.75);
});
}
@@ -491,7 +501,7 @@ export function initSrs() {
}
});
if (card.audio_url) playAudio(card.audio_url);
playTextSpeech(card.term, 'en');
if (isCorrect) {
quizFeedbackBox.className = 'trainer-feedback-box feedback-correct';
@@ -523,8 +533,7 @@ export function initSrs() {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
playTextSpeech(card.term, 'en', 1.0);
});
}
@@ -565,7 +574,7 @@ export function initSrs() {
const target = card.term.trim().toLowerCase();
const isMatch = userInput === target;
if (card.audio_url) playAudio(card.audio_url);
playTextSpeech(card.term, 'en');
typingInput.disabled = true;
if (isMatch) {
@@ -611,7 +620,7 @@ export function initSrs() {
const target = card.term.trim().toLowerCase();
const isMatch = userInput === target;
if (card.audio_url) playAudio(card.audio_url);
playTextSpeech(card.term, 'en');
clozeInput.disabled = true;
if (isMatch) {
@@ -640,7 +649,7 @@ export function initSrs() {
dictationInput.value = '';
dictationInput.disabled = false;
const play = () => { if (card.audio_url) playAudio(card.audio_url); };
const play = () => { playTextSpeech(card.term, 'en'); };
dictationBigPlayBtn.onclick = play;
dictationReplayBtn.onclick = play;
play();