- 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>
1362 lines
48 KiB
JavaScript
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; |