Class_generator/js/games/story-reader.js
StillHammer e67e40f09b Fix Story Reader word parsing for letters and punctuation
- Separate punctuation from words during sentence parsing
- Add special handling for letter pairs (Aa, Bb, Cc, etc.)
- Add special handling for punctuation marks (., !, ?, :, etc.)
- Preserve punctuation display while enabling proper word-by-word navigation
- Fix alphabet learning display in SBS-1 content

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 11:29:36 +08:00

1362 lines
48 KiB
JavaScript

// === STORY READER GAME ===
// Prototype for reading long stories with sentence chunking and word-by-word translation
class StoryReader {
constructor(options) {
this.container = options.container;
this.content = options.content;
this.onScoreUpdate = options.onScoreUpdate || (() => {});
this.onGameEnd = options.onGameEnd || (() => {});
// Reading state
this.currentChapter = 0;
this.currentSentence = 0;
this.totalSentences = 0;
this.readingSessions = 0;
this.wordsRead = 0;
this.comprehensionScore = 0;
// Story data
this.story = null;
this.availableStories = [];
this.currentStoryIndex = 0;
this.vocabulary = {};
// UI state
this.showTranslations = false;
this.showPronunciations = false;
this.readingMode = 'sentence'; // 'sentence' or 'paragraph'
this.fontSize = 'medium';
// Reading time tracking
this.startTime = Date.now();
this.totalReadingTime = 0;
this.readingTimer = null;
// TTS settings
this.autoPlayTTS = true;
this.ttsEnabled = true;
// Expose content globally for SettingsManager TTS language detection
window.currentGameContent = this.content;
this.init();
}
init() {
logSh(`🔍 Story Reader content received:`, this.content, 'DEBUG');
logSh(`🔍 Story field exists: ${!!this.content.story}`, 'DEBUG');
logSh(`🔍 RawContent exists: ${!!this.content.rawContent}`, 'DEBUG');
// Discover all available stories
this.discoverAvailableStories();
if (this.availableStories.length === 0) {
logSh('No story content found in content or rawContent', 'ERROR');
this.showError('This content does not contain any stories for reading.');
return;
}
// Get URL params to check if specific story is requested
const urlParams = new URLSearchParams(window.location.search);
const requestedStory = urlParams.get('story');
if (requestedStory) {
const storyIndex = this.availableStories.findIndex(story =>
story.id === requestedStory || story.title.toLowerCase().includes(requestedStory.toLowerCase())
);
if (storyIndex !== -1) {
this.currentStoryIndex = storyIndex;
}
}
this.selectStory(this.currentStoryIndex);
this.vocabulary = this.content.rawContent?.vocabulary || this.content.vocabulary || {};
logSh(`📖 Story Reader initialized: "${this.story.title}" (${this.totalSentences} sentences)`, 'INFO');
this.createInterface();
this.loadProgress();
this.renderCurrentSentence();
}
discoverAvailableStories() {
this.availableStories = [];
// Check main story field
const mainStory = this.content.rawContent?.story || this.content.story;
if (mainStory && mainStory.title) {
this.availableStories.push({
id: 'main',
title: mainStory.title,
data: mainStory,
source: 'main'
});
}
// Check additionalStories field (like in WTA1B1)
const additionalStories = this.content.rawContent?.additionalStories || this.content.additionalStories;
if (additionalStories && Array.isArray(additionalStories)) {
additionalStories.forEach((story, index) => {
if (story && story.title) {
this.availableStories.push({
id: `additional_${index}`,
title: story.title,
data: story,
source: 'additional'
});
}
});
}
// NEW: Check for simple texts and convert them to stories
const texts = this.content.rawContent?.texts || this.content.texts;
if (texts && Array.isArray(texts)) {
texts.forEach((text, index) => {
if (text && (text.title || text.original_language)) {
const convertedStory = this.convertTextToStory(text, index);
this.availableStories.push({
id: `text_${index}`,
title: text.title || `Text ${index + 1}`,
data: convertedStory,
source: 'text'
});
}
});
}
// NEW: Check for sentences and create a story from them
const sentences = this.content.rawContent?.sentences || this.content.sentences;
if (sentences && Array.isArray(sentences) && sentences.length > 0 && this.availableStories.length === 0) {
const sentencesStory = this.convertSentencesToStory(sentences);
this.availableStories.push({
id: 'sentences',
title: 'Reading Practice',
data: sentencesStory,
source: 'sentences'
});
}
logSh(`📚 Discovered ${this.availableStories.length} stories:`, this.availableStories.map(s => s.title), 'INFO');
}
selectStory(storyIndex) {
if (storyIndex >= 0 && storyIndex < this.availableStories.length) {
this.currentStoryIndex = storyIndex;
this.story = this.availableStories[storyIndex].data;
this.calculateTotalSentences();
// Reset reading position for new story
this.currentSentence = 0;
this.wordsRead = 0;
// Update URL to include story parameter
this.updateUrlForStory();
logSh(`📖 Selected story: "${this.story.title}" (${this.totalSentences} sentences)`, 'INFO');
}
}
updateUrlForStory() {
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('story', this.availableStories[this.currentStoryIndex].id);
const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
window.history.replaceState({}, '', newUrl);
}
showError(message) {
this.container.innerHTML = `
<div class="story-error">
<h3>❌ Error</h3>
<p>${message}</p>
<button onclick="AppNavigation.navigateTo('games')" class="back-btn">← Back</button>
</div>
`;
}
calculateTotalSentences() {
this.totalSentences = 0;
this.story.chapters.forEach(chapter => {
this.totalSentences += chapter.sentences.length;
});
}
createInterface() {
// Create story selector dropdown if multiple stories available
const storySelector = this.availableStories.length > 1 ? `
<div class="story-selector">
<label for="story-select">📚 Select Story:</label>
<select id="story-select">
${this.availableStories.map((story, index) =>
`<option value="${index}" ${index === this.currentStoryIndex ? 'selected' : ''}>${story.title}</option>`
).join('')}
</select>
</div>
` : '';
this.container.innerHTML = `
<div class="story-reader-wrapper">
${storySelector}
<!-- Header Controls -->
<div class="story-header">
<div class="story-title">
<h2>${this.story.title}</h2>
<div class="reading-progress">
<span id="progress-text">Sentence 1 of ${this.totalSentences}</span>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
</div>
<div class="story-controls">
<button class="control-btn secondary" id="play-sentence-btn">🔊 Play Sentence</button>
<button class="control-btn secondary" id="settings-btn">⚙️ Settings</button>
<button class="control-btn secondary" id="toggle-translation-btn">🌐 Translations</button>
</div>
</div>
<!-- Settings Panel -->
<div class="settings-panel" id="settings-panel" style="display: none;">
<div class="setting-group">
<label>Font Size:</label>
<select id="font-size-select">
<option value="small">Small</option>
<option value="medium" selected>Medium</option>
<option value="large">Large</option>
<option value="extra-large">Extra Large</option>
</select>
</div>
<div class="setting-group">
<label>Reading Mode:</label>
<select id="reading-mode-select">
<option value="sentence" selected>Sentence by Sentence</option>
<option value="paragraph">Full Paragraph</option>
</select>
</div>
<div class="setting-group">
<label>🔊 Auto-play TTS:</label>
<input type="checkbox" id="auto-play-tts" ${this.autoPlayTTS ? 'checked' : ''}>
</div>
<div class="setting-group">
<label>TTS Speed:</label>
<select id="tts-speed-select">
<option value="0.7">Slow (0.7x)</option>
<option value="0.8">Normal (0.8x)</option>
<option value="1.0" selected>Fast (1.0x)</option>
<option value="1.2">Very Fast (1.2x)</option>
</select>
</div>
</div>
<!-- Chapter Info -->
<div class="chapter-info" id="chapter-info">
<span class="chapter-title">Chapter 1: Loading...</span>
</div>
<!-- Reading Area -->
<div class="reading-area" id="reading-area">
<div class="sentence-display" id="sentence-display">
<div class="original-text" id="original-text">Loading story...</div>
<div class="translation-text" id="translation-text" style="display: none;">
Translation will appear here...
</div>
</div>
<!-- Word-by-word translation popup -->
<div class="word-popup" id="word-popup" style="display: none;">
<div class="word-original" id="popup-word"></div>
<div class="word-translation" id="popup-translation"></div>
<div class="word-type" id="popup-type"></div>
<button class="word-tts-btn" id="popup-tts-btn" onclick="this.parentElement.storyReader.speakWordFromPopup()">🔊</button>
</div>
</div>
<!-- Navigation Controls -->
<div class="story-navigation">
<button class="nav-btn" id="prev-btn" disabled>⬅️ Previous</button>
<button class="nav-btn" id="pronunciation-toggle-btn">🔊 Pronunciations</button>
<button class="nav-btn" id="bookmark-btn">🔖 Bookmark</button>
<button class="nav-btn primary" id="next-btn">Next ➡️</button>
</div>
<!-- Reading Stats -->
<div class="reading-stats">
<div class="stat">
<span class="stat-label">Words Read:</span>
<span class="stat-value" id="words-read">0</span>
</div>
<div class="stat">
<span class="stat-label">Reading Time:</span>
<span class="stat-value" id="reading-time">00:00</span>
</div>
<div class="stat">
<span class="stat-label">Progress:</span>
<span class="stat-value" id="reading-percentage">0%</span>
</div>
</div>
</div>
`;
this.addStyles();
this.setupEventListeners();
}
addStyles() {
const style = document.createElement('style');
style.textContent = `
.story-reader-wrapper {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: 'Georgia', serif;
line-height: 1.6;
}
.story-selector {
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 10px;
padding: 15px 20px;
margin-bottom: 25px;
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.story-selector label {
font-weight: 600;
color: #2d3748;
font-size: 1.1em;
min-width: 120px;
}
.story-selector select {
flex: 1;
padding: 8px 12px;
border: 2px solid #cbd5e0;
border-radius: 6px;
background: white;
font-size: 1em;
color: #2d3748;
cursor: pointer;
transition: border-color 0.2s;
}
.story-selector select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.story-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #e2e8f0;
}
.story-title h2 {
margin: 0 0 10px 0;
color: #2d3748;
font-size: 1.8em;
}
.reading-progress {
display: flex;
align-items: center;
gap: 10px;
}
.progress-bar {
width: 200px;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #10b981);
width: 0%;
transition: width 0.3s ease;
}
.story-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.control-btn {
padding: 8px 12px;
border: 2px solid #e2e8f0;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s;
white-space: nowrap;
}
.control-btn:hover {
background: #f7fafc;
border-color: #cbd5e0;
}
.control-btn.secondary {
background: #f8fafc;
color: #4a5568;
}
.control-btn.secondary:hover {
background: #e2e8f0;
color: #2d3748;
}
.settings-panel {
background: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.setting-group {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.setting-group label {
font-weight: 600;
min-width: 100px;
}
.chapter-info {
background: #edf2f7;
padding: 10px 15px;
border-radius: 6px;
margin-bottom: 20px;
font-style: italic;
color: #4a5568;
}
.reading-area {
position: relative;
background: white;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
min-height: 200px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}
.sentence-display {
text-align: center;
}
.original-text {
font-size: 1.2em;
color: #2d3748;
margin-bottom: 15px;
cursor: pointer;
padding: 15px;
border-radius: 8px;
transition: background-color 0.2s;
}
.original-text:hover {
background-color: #f7fafc;
}
.original-text.small { font-size: 1em; }
.original-text.medium { font-size: 1.2em; }
.original-text.large { font-size: 1.4em; }
.original-text.extra-large { font-size: 1.6em; }
.translation-text {
font-style: italic;
color: #718096;
font-size: 1em;
padding: 10px;
background: #f0fff4;
border-radius: 6px;
border-left: 4px solid #10b981;
}
.clickable-word {
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
transition: background-color 0.2s;
position: relative;
display: inline-block;
}
.clickable-word:hover {
background-color: #fef5e7;
color: #d69e2e;
}
.punctuation {
color: #2d3748;
font-weight: normal;
cursor: default;
user-select: none;
}
.word-with-pronunciation {
position: relative;
display: inline-block;
margin: 0 2px;
vertical-align: top;
line-height: 1.8;
}
.pronunciation-text {
position: absolute;
top: -16px;
left: 50%;
transform: translateX(-50%);
font-size: 0.7em;
color: #718096;
font-style: italic;
white-space: nowrap;
pointer-events: none;
z-index: 10;
display: none;
}
.pronunciation-text.show {
display: block;
}
.reading-area {
position: relative;
background: white;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 40px 30px 30px 30px;
margin-bottom: 20px;
min-height: 200px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
line-height: 2.2;
}
.word-popup {
position: fixed;
background: white;
border: 2px solid #3b82f6;
border-radius: 6px;
padding: 8px 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9999;
max-width: 200px;
min-width: 120px;
font-size: 0.9em;
line-height: 1.3;
}
.word-original {
font-weight: bold;
color: #2d3748;
font-size: 1em;
margin-bottom: 3px;
}
.word-translation {
color: #10b981;
font-size: 0.9em;
margin-bottom: 2px;
}
.word-type {
font-size: 0.75em;
color: #718096;
font-style: italic;
}
.word-tts-btn {
position: absolute;
top: 5px;
right: 5px;
background: #3b82f6;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.word-tts-btn:hover {
background: #2563eb;
}
.story-navigation {
display: flex;
justify-content: center;
gap: 15px;
margin-bottom: 20px;
}
.nav-btn {
padding: 12px 24px;
border: 2px solid #e2e8f0;
background: white;
border-radius: 8px;
cursor: pointer;
font-size: 1em;
transition: all 0.2s;
}
.nav-btn:hover:not(:disabled) {
background: #f7fafc;
border-color: #cbd5e0;
}
.nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.nav-btn.primary {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.nav-btn.primary:hover {
background: #2563eb;
}
.reading-stats {
display: flex;
justify-content: space-around;
background: #f7fafc;
padding: 15px;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.stat {
text-align: center;
}
.stat-label {
display: block;
font-size: 0.9em;
color: #718096;
margin-bottom: 5px;
}
.stat-value {
display: block;
font-weight: bold;
font-size: 1.1em;
color: #2d3748;
}
@media (max-width: 768px) {
.story-reader-wrapper {
padding: 10px;
}
.story-header {
flex-direction: column;
gap: 15px;
}
.reading-stats {
flex-direction: column;
gap: 10px;
}
}
`;
document.head.appendChild(style);
}
setupEventListeners() {
// Story selector (if multiple stories)
const storySelect = document.getElementById('story-select');
if (storySelect) {
storySelect.addEventListener('change', (e) => this.changeStory(parseInt(e.target.value)));
}
// Navigation
document.getElementById('prev-btn').addEventListener('click', () => this.previousSentence());
document.getElementById('next-btn').addEventListener('click', () => this.nextSentence());
document.getElementById('bookmark-btn').addEventListener('click', () => this.saveBookmark());
// Controls
document.getElementById('play-sentence-btn').addEventListener('click', () => this.playSentenceTTS());
document.getElementById('settings-btn').addEventListener('click', () => this.toggleSettings());
document.getElementById('toggle-translation-btn').addEventListener('click', () => this.toggleTranslations());
document.getElementById('pronunciation-toggle-btn').addEventListener('click', () => this.togglePronunciations());
// Settings
document.getElementById('font-size-select').addEventListener('change', (e) => this.changeFontSize(e.target.value));
document.getElementById('reading-mode-select').addEventListener('change', (e) => this.changeReadingMode(e.target.value));
document.getElementById('auto-play-tts').addEventListener('change', (e) => this.toggleAutoPlayTTS(e.target.checked));
document.getElementById('tts-speed-select').addEventListener('change', (e) => this.changeTTSSpeed(e.target.value));
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') this.previousSentence();
if (e.key === 'ArrowRight') this.nextSentence();
if (e.key === 'Space') {
e.preventDefault();
this.nextSentence();
}
if (e.key === 't' || e.key === 'T') this.toggleTranslations();
if (e.key === 's' || e.key === 'S') this.playSentenceTTS();
});
// Click outside to close word popup
document.addEventListener('click', (e) => {
if (!e.target.closest('.word-popup') && !e.target.closest('.clickable-word')) {
this.hideWordPopup();
}
});
}
getCurrentSentenceData() {
let sentenceCount = 0;
for (let chapterIndex = 0; chapterIndex < this.story.chapters.length; chapterIndex++) {
const chapter = this.story.chapters[chapterIndex];
if (sentenceCount + chapter.sentences.length > this.currentSentence) {
const sentenceInChapter = this.currentSentence - sentenceCount;
return {
chapter: chapterIndex,
sentence: sentenceInChapter,
data: chapter.sentences[sentenceInChapter],
chapterTitle: chapter.title
};
}
sentenceCount += chapter.sentences.length;
}
return null;
}
// Match words from sentence with centralized vocabulary
matchWordsWithVocabulary(sentence) {
const words = sentence.split(/(\s+|[.,!?;:"'()[\]{}\-–—])/);
const matchedWords = [];
words.forEach(token => {
// Handle whitespace tokens
if (/^\s+$/.test(token)) {
matchedWords.push({
original: token,
hasVocab: false,
isWhitespace: true
});
return;
}
// Handle pure punctuation tokens (preserve them as non-clickable)
if (/^[.,!?;:"'()[\]{}\-–—]+$/.test(token)) {
matchedWords.push({
original: token,
hasVocab: false,
isPunctuation: true
});
return;
}
// Clean word (remove punctuation for matching)
const cleanWord = token.toLowerCase().replace(/[.,!?;:"'()[\]{}\-–—]/g, '');
// Skip empty tokens
if (!cleanWord) return;
// Check if word exists in vocabulary (try exact match first, then stems)
let vocabEntry = this.content.vocabulary[cleanWord];
// Try common variations if exact match not found
if (!vocabEntry) {
// Try without 's' for plurals
if (cleanWord.endsWith('s')) {
vocabEntry = this.content.vocabulary[cleanWord.slice(0, -1)];
}
// Try without 'ed' for past tense
if (!vocabEntry && cleanWord.endsWith('ed')) {
vocabEntry = this.content.vocabulary[cleanWord.slice(0, -2)];
}
// Try without 'ing' for present participle
if (!vocabEntry && cleanWord.endsWith('ing')) {
vocabEntry = this.content.vocabulary[cleanWord.slice(0, -3)];
}
}
if (vocabEntry) {
// Word found in vocabulary
matchedWords.push({
original: token,
hasVocab: true,
word: cleanWord,
translation: vocabEntry.translation || vocabEntry.user_language,
pronunciation: vocabEntry.pronunciation,
type: vocabEntry.type || 'unknown'
});
} else {
// Word not in vocabulary - render as plain text
matchedWords.push({
original: token,
hasVocab: false
});
}
});
return matchedWords;
}
renderCurrentSentence() {
const sentenceData = this.getCurrentSentenceData();
if (!sentenceData) return;
const { data, chapterTitle } = sentenceData;
// Update chapter info
document.getElementById('chapter-info').innerHTML = `
<span class="chapter-title">${chapterTitle}</span>
`;
// Update progress
const progress = ((this.currentSentence + 1) / this.totalSentences) * 100;
document.getElementById('progress-fill').style.width = `${progress}%`;
document.getElementById('progress-text').textContent = `Sentence ${this.currentSentence + 1} of ${this.totalSentences}`;
document.getElementById('reading-percentage').textContent = `${Math.round(progress)}%`;
// Check if sentence has word-by-word data (old format) or needs automatic matching
let wordsHtml;
if (data.words && data.words.length > 0) {
// Old format with word-by-word data
wordsHtml = data.words.map(wordData => {
const pronunciation = wordData.pronunciation || '';
const pronunciationHtml = pronunciation ?
`<span class="pronunciation-text">${pronunciation}</span>` : '';
return `<span class="word-with-pronunciation">
${pronunciationHtml}
<span class="clickable-word" data-word="${wordData.word}" data-translation="${wordData.translation}" data-type="${wordData.type}" data-pronunciation="${pronunciation}">${wordData.word}</span>
</span>`;
}).join(' ');
} else {
// New format with centralized vocabulary - use automatic matching
const matchedWords = this.matchWordsWithVocabulary(data.original);
wordsHtml = matchedWords.map(wordInfo => {
if (wordInfo.isWhitespace) {
return wordInfo.original;
} else if (wordInfo.isPunctuation) {
// Render punctuation as non-clickable text
return `<span class="punctuation">${wordInfo.original}</span>`;
} else if (wordInfo.hasVocab) {
const pronunciation = this.showPronunciation && wordInfo.pronunciation ?
wordInfo.pronunciation : '';
const pronunciationHtml = pronunciation ?
`<span class="pronunciation-text">${pronunciation}</span>` : '';
return `<span class="word-with-pronunciation">
${pronunciationHtml}
<span class="clickable-word" data-word="${wordInfo.word}" data-translation="${wordInfo.translation}" data-type="${wordInfo.type}" data-pronunciation="${wordInfo.pronunciation || ''}">${wordInfo.original}</span>
</span>`;
} else {
// No vocabulary entry - render as plain text
return wordInfo.original;
}
}).join('');
}
document.getElementById('original-text').innerHTML = wordsHtml;
document.getElementById('translation-text').textContent = data.translation;
// Add word click listeners
document.querySelectorAll('.clickable-word').forEach(word => {
word.addEventListener('click', (e) => this.showWordPopup(e));
});
// Update navigation buttons
document.getElementById('prev-btn').disabled = this.currentSentence === 0;
document.getElementById('next-btn').disabled = this.currentSentence >= this.totalSentences - 1;
// Update stats
this.updateStats();
// Auto-play TTS if enabled
if (this.autoPlayTTS && this.ttsEnabled) {
// Small delay to let the sentence render
setTimeout(() => this.playSentenceTTS(), 300);
}
}
showWordPopup(event) {
const word = event.target.dataset.word;
const translation = event.target.dataset.translation;
const type = event.target.dataset.type;
const pronunciation = event.target.dataset.pronunciation;
logSh(`🔍 Word clicked: ${word}, translation: ${translation}`, 'DEBUG');
const popup = document.getElementById('word-popup');
if (!popup) {
logSh('❌ Word popup element not found!', 'ERROR');
return;
}
// Store reference to story reader for TTS button
popup.storyReader = this;
popup.currentWord = word;
document.getElementById('popup-word').textContent = word;
document.getElementById('popup-translation').textContent = translation;
// Show pronunciation in popup if available
const typeText = pronunciation ? `${pronunciation} (${type})` : `(${type})`;
document.getElementById('popup-type').textContent = typeText;
// Position popup ABOVE the clicked word
const rect = event.target.getBoundingClientRect();
popup.style.display = 'block';
// Center horizontally on the word, show above it
const popupLeft = rect.left + (rect.width / 2) - 100; // Center popup (200px wide / 2)
const popupTop = rect.top - 10; // Above the word with small gap
popup.style.left = `${popupLeft}px`;
popup.style.top = `${popupTop}px`;
popup.style.transform = 'translateY(-100%)'; // Move up by its own height
// Ensure popup stays within viewport
if (popupLeft < 10) {
popup.style.left = '10px';
}
if (popupLeft + 200 > window.innerWidth) {
popup.style.left = `${window.innerWidth - 210}px`;
}
if (popupTop - 80 < 10) {
// If no room above, show below instead
popup.style.top = `${rect.bottom + 10}px`;
popup.style.transform = 'translateY(0)';
}
logSh(`📍 Popup positioned at: ${rect.left}px, ${rect.bottom + 10}px`, 'DEBUG');
}
hideWordPopup() {
document.getElementById('word-popup').style.display = 'none';
}
previousSentence() {
if (this.currentSentence > 0) {
this.currentSentence--;
this.renderCurrentSentence();
this.saveProgress();
}
}
nextSentence() {
if (this.currentSentence < this.totalSentences - 1) {
this.currentSentence++;
this.renderCurrentSentence();
this.saveProgress();
} else {
this.completeReading();
}
}
toggleSettings() {
const panel = document.getElementById('settings-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
toggleTranslations() {
this.showTranslations = !this.showTranslations;
const translationText = document.getElementById('translation-text');
translationText.style.display = this.showTranslations ? 'block' : 'none';
const btn = document.getElementById('toggle-translation-btn');
btn.textContent = this.showTranslations ? '🌐 Hide Translations' : '🌐 Show Translations';
}
togglePronunciations() {
this.showPronunciations = !this.showPronunciations;
const pronunciations = document.querySelectorAll('.pronunciation-text');
pronunciations.forEach(pronunciation => {
if (this.showPronunciations) {
pronunciation.classList.add('show');
} else {
pronunciation.classList.remove('show');
}
});
const btn = document.getElementById('pronunciation-toggle-btn');
btn.textContent = this.showPronunciations ? '🔇 Hide Pronunciations' : '🔊 Show Pronunciations';
}
changeFontSize(size) {
this.fontSize = size;
document.getElementById('original-text').className = `original-text ${size}`;
}
changeReadingMode(mode) {
this.readingMode = mode;
// Mode implementation can be extended later
}
changeStory(storyIndex) {
if (storyIndex !== this.currentStoryIndex) {
// Save progress for current story before switching
this.saveProgress();
// Select new story
this.selectStory(storyIndex);
// Load progress for new story
this.loadProgress();
// Update the interface title and progress
this.updateStoryTitle();
this.renderCurrentSentence();
logSh(`📖 Switched to story: "${this.story.title}"`, 'INFO');
}
}
updateStoryTitle() {
const titleElement = document.querySelector('.story-title h2');
if (titleElement) {
titleElement.textContent = this.story.title;
}
}
// NEW: Convert simple text to story format
convertTextToStory(text, index) {
// Split text into sentences for easier reading
const sentences = this.splitTextIntoSentences(text.original_language, text.user_language);
return {
title: text.title || `Text ${index + 1}`,
totalSentences: sentences.length,
chapters: [{
title: "Reading Text",
sentences: sentences
}]
};
}
// NEW: Convert array of sentences to story format
convertSentencesToStory(sentences) {
const storyTitle = this.content.name || "Reading Practice";
const convertedSentences = sentences.map((sentence, index) => ({
id: index + 1,
original: sentence.original_language || sentence.english || sentence.original || '',
translation: sentence.user_language || sentence.chinese || sentence.french || sentence.translation || '',
words: this.breakSentenceIntoWords(
sentence.original_language || sentence.english || sentence.original || '',
sentence.user_language || sentence.chinese || sentence.french || sentence.translation || ''
)
}));
return {
title: storyTitle,
totalSentences: convertedSentences.length,
chapters: [{
title: "Reading Sentences",
sentences: convertedSentences
}]
};
}
// NEW: Split long text into manageable sentences
splitTextIntoSentences(originalText, translationText) {
// Split by sentence endings
const originalSentences = originalText.split(/[.!?]+/).filter(s => s.trim().length > 0);
const translationSentences = translationText.split(/[.!?]+/).filter(s => s.trim().length > 0);
const sentences = [];
const maxSentences = Math.max(originalSentences.length, translationSentences.length);
for (let i = 0; i < maxSentences; i++) {
const original = (originalSentences[i] || '').trim();
const translation = (translationSentences[i] || '').trim();
if (original || translation) {
sentences.push({
id: i + 1,
original: original + (original && !original.match(/[.!?]$/) ? '.' : ''),
translation: translation + (translation && !translation.match(/[.!?]$/) ? '.' : ''),
words: this.breakSentenceIntoWords(original, translation)
});
}
}
return sentences;
}
// NEW: Break sentence into word-by-word format for Story Reader
breakSentenceIntoWords(original, translation) {
if (!original) return [];
// First, separate punctuation from words while preserving spaces
const preprocessed = original.replace(/([.,!?;:"'()[\]{}\-–—])/g, ' $1 ');
const words = preprocessed.split(/\s+/).filter(word => word.trim().length > 0);
// Do the same for translation
const translationPreprocessed = translation ? translation.replace(/([.,!?;:"'()[\]{}\-–—])/g, ' $1 ') : '';
const translationWords = translationPreprocessed ? translationPreprocessed.split(/\s+/).filter(word => word.trim().length > 0) : [];
return words.map((word, index) => {
// Clean punctuation for word lookup, but preserve punctuation in display
const cleanWord = word.replace(/[.,!?;:"'()[\]{}\-–—]/g, '').toLowerCase();
// Try to find in vocabulary
let wordTranslation = translationWords[index] || '';
let wordType = 'word';
let pronunciation = '';
// Special handling for letter pairs (like "Aa", "Bb", etc.)
if (/^[A-Za-z]{1,2}$/.test(cleanWord)) {
wordType = 'letter';
wordTranslation = word; // Keep the letter as is
}
// Special handling for punctuation marks
if (/^[.,!?;:"'()[\]{}]$/.test(word)) {
wordType = 'punctuation';
wordTranslation = word; // Keep punctuation as is
}
// Look up in content vocabulary if available
if (this.vocabulary && this.vocabulary[cleanWord]) {
const vocabEntry = this.vocabulary[cleanWord];
wordTranslation = vocabEntry.user_language || vocabEntry.translation || wordTranslation;
wordType = vocabEntry.type || wordType;
pronunciation = vocabEntry.pronunciation || '';
}
return {
word: word,
translation: wordTranslation,
type: wordType,
pronunciation: pronunciation
};
});
}
// TTS Methods
playSentenceTTS() {
const sentenceData = this.getCurrentSentenceData();
if (!sentenceData || !this.ttsEnabled) return;
const text = sentenceData.data.original;
this.speakText(text);
}
speakText(text, options = {}) {
if (!text || !this.ttsEnabled) return;
// Use SettingsManager if available for better language support
if (window.SettingsManager && window.SettingsManager.speak) {
const ttsOptions = {
lang: this.getContentLanguage(),
rate: parseFloat(document.getElementById('tts-speed-select')?.value || '0.8'),
...options
};
window.SettingsManager.speak(text, ttsOptions)
.catch(error => {
console.warn('🔊 SettingsManager TTS failed:', error);
this.fallbackTTS(text, ttsOptions);
});
} else {
this.fallbackTTS(text, options);
}
}
fallbackTTS(text, options = {}) {
if ('speechSynthesis' in window && text) {
// Cancel any ongoing speech
speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = this.getContentLanguage();
utterance.rate = options.rate || 0.8;
utterance.volume = 1.0;
speechSynthesis.speak(utterance);
}
}
getContentLanguage() {
// Get language from content or use sensible defaults
if (this.content.language) {
const langMap = {
'chinese': 'zh-CN',
'english': 'en-US',
'french': 'fr-FR',
'spanish': 'es-ES'
};
return langMap[this.content.language] || this.content.language;
}
return 'en-US'; // Default fallback
}
toggleAutoPlayTTS(enabled) {
this.autoPlayTTS = enabled;
logSh(`🔊 Auto-play TTS ${enabled ? 'enabled' : 'disabled'}`, 'INFO');
}
changeTTSSpeed(speed) {
logSh(`🔊 TTS speed changed to ${speed}x`, 'INFO');
}
speakWordFromPopup() {
const popup = document.getElementById('word-popup');
if (popup && popup.currentWord) {
this.speakText(popup.currentWord, { rate: 0.7 }); // Slower for individual words
}
}
updateStats() {
const sentenceData = this.getCurrentSentenceData();
if (sentenceData) {
this.wordsRead += sentenceData.data.words.length;
document.getElementById('words-read').textContent = this.wordsRead;
}
}
saveProgress() {
const progressData = {
currentSentence: this.currentSentence,
wordsRead: this.wordsRead,
timestamp: Date.now()
};
const progressKey = this.getProgressKey();
localStorage.setItem(progressKey, JSON.stringify(progressData));
}
loadProgress() {
const progressKey = this.getProgressKey();
const saved = localStorage.getItem(progressKey);
if (saved) {
try {
const data = JSON.parse(saved);
this.currentSentence = data.currentSentence || 0;
this.wordsRead = data.wordsRead || 0;
} catch (error) {
logSh('Error loading progress:', error, 'WARN');
this.currentSentence = 0;
this.wordsRead = 0;
}
} else {
// No saved progress - start fresh
this.currentSentence = 0;
this.wordsRead = 0;
}
}
getProgressKey() {
const storyId = this.availableStories[this.currentStoryIndex]?.id || 'main';
return `story_progress_${this.content.name}_${storyId}`;
}
saveBookmark() {
this.saveProgress();
const toast = document.createElement('div');
toast.textContent = '🔖 Bookmark saved!';
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #10b981;
color: white;
padding: 10px 20px;
border-radius: 6px;
z-index: 1000;
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
completeReading() {
this.onGameEnd(this.wordsRead);
const completionMessage = `
<div style="text-align: center; padding: 40px;">
<h2>🎉 Story Complete!</h2>
<p>You've finished reading "${this.story.title}"</p>
<p><strong>Words read:</strong> ${this.wordsRead}</p>
<p><strong>Total sentences:</strong> ${this.totalSentences}</p>
<button onclick="this.restart()" class="nav-btn primary">Read Again</button>
<button onclick="AppNavigation.navigateTo('games')" class="nav-btn">Back to Menu</button>
</div>
`;
document.getElementById('reading-area').innerHTML = completionMessage;
}
start() {
logSh('📖 Story Reader: Starting', 'INFO');
this.startReadingTimer();
}
startReadingTimer() {
this.startTime = Date.now();
this.readingTimer = setInterval(() => {
this.updateReadingTime();
}, 1000);
}
updateReadingTime() {
const currentTime = Date.now();
this.totalReadingTime = Math.floor((currentTime - this.startTime) / 1000);
const minutes = Math.floor(this.totalReadingTime / 60);
const seconds = this.totalReadingTime % 60;
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
document.getElementById('reading-time').textContent = timeString;
}
restart() {
this.currentSentence = 0;
this.wordsRead = 0;
// Restart reading timer
if (this.readingTimer) {
clearInterval(this.readingTimer);
}
this.startReadingTimer();
this.renderCurrentSentence();
this.saveProgress();
}
destroy() {
// Clean up timer
if (this.readingTimer) {
clearInterval(this.readingTimer);
}
this.container.innerHTML = '';
}
}
// Module registration
window.GameModules = window.GameModules || {};
window.GameModules.StoryReader = StoryReader;