Fix StoryReader compatibility and memory leak issues
🔧 Changes: - StoryReader now handles both old/new text structures (original_language vs content) - Fixed memory leak: properly remove global event listeners on destroy - Added null check in _hideWordPopup() to prevent errors after DOM cleanup - Fixed chapter list display (was showing only one chapter instead of all) - Smart routing: only load chapter content when needed - Use module ContentLoader for proper content loading with vocabulary 🐛 Bugs Fixed: 1. "Cannot read properties of undefined (reading 'split')" - StoryReader couldn't handle texts with 'content' field 2. "Cannot read properties of null (reading 'style')" - Event listeners firing after game cleanup 3. Chapter list showing only book ID instead of all chapters 4. Game compatibility scores dropping to 0.00 after navigation ✨ Architecture Improvements: - Event listener cleanup follows best practices - Proper handler reference storage for removeEventListener - Defensive programming with null checks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3da40c0d73
commit
6cafb9218b
94
index.html
94
index.html
@ -272,6 +272,30 @@
|
||||
// Load content using ContentLoader to get reports
|
||||
const contentData = await contentLoader.loadContent(bookId);
|
||||
|
||||
// Generate chapter cards from contentData.chapters array
|
||||
const chapters = contentData.chapters || [];
|
||||
const chapterCards = chapters.map(chapter => `
|
||||
<div class="chapter-card" onclick="selectChapter('${bookId}', '${chapter.id}')"
|
||||
onmouseenter="showTooltip(event, '${chapter.id}')"
|
||||
onmouseleave="hideTooltip()">
|
||||
<div class="chapter-number">${chapter.chapter_number || '?'}</div>
|
||||
<div class="chapter-info">
|
||||
<h3>${chapter.name || chapter.title}</h3>
|
||||
<p class="chapter-description">${chapter.description || ''}</p>
|
||||
<div class="chapter-meta">
|
||||
<span class="difficulty-badge difficulty-${chapter.difficulty}">${chapter.difficulty}</span>
|
||||
<span class="language-badge">${chapter.language || contentData.language}</span>
|
||||
</div>
|
||||
<div class="chapter-progress">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="progress-text">0% Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
updateMainContent(`
|
||||
<div class="chapters-container">
|
||||
<div class="chapters-header">
|
||||
@ -287,25 +311,7 @@
|
||||
</div>
|
||||
|
||||
<div class="chapters-grid">
|
||||
<div class="chapter-card" onclick="navigateToGames('${bookId}')"
|
||||
onmouseenter="showTooltip(event, '${bookId}')"
|
||||
onmouseleave="hideTooltip()">
|
||||
<div class="chapter-number">7-8</div>
|
||||
<div class="chapter-info">
|
||||
<h3>${contentData.name}</h3>
|
||||
<p class="chapter-description">${contentData.description}</p>
|
||||
<div class="chapter-meta">
|
||||
<span class="difficulty-badge difficulty-${contentData.difficulty}">${contentData.difficulty}</span>
|
||||
<span class="language-badge">${contentData.language}</span>
|
||||
</div>
|
||||
<div class="chapter-progress">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="progress-text">0% Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${chapterCards || '<p>No chapters available</p>'}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
@ -339,19 +345,22 @@
|
||||
throw new Error('GameLoader not available');
|
||||
}
|
||||
|
||||
// Load current content for compatibility scoring
|
||||
const content = contentLoader.getContent(chapterId);
|
||||
if (content) {
|
||||
eventBus.emit('content:loaded', { content }, 'Bootstrap');
|
||||
|
||||
// Wait a bit for the event to be processed, then get games
|
||||
setTimeout(() => {
|
||||
window._renderGamesInterface(gameLoader, chapterId);
|
||||
}, 50);
|
||||
return;
|
||||
// Load CHAPTER content (not book) for compatibility scoring
|
||||
// Use module-based ContentLoader which loads chapter JSON
|
||||
const moduleContentLoader = app.getModule('contentLoader');
|
||||
if (moduleContentLoader) {
|
||||
const chapterContent = await moduleContentLoader.getContent(chapterId);
|
||||
if (chapterContent) {
|
||||
// Module ContentLoader already emits content:loaded, no need to emit again
|
||||
// Just wait for it to be processed
|
||||
setTimeout(() => {
|
||||
window._renderGamesInterface(gameLoader, chapterId);
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If no content, render immediately with no games
|
||||
// If no module content loader or content, render immediately
|
||||
window._renderGamesInterface(gameLoader, chapterId);
|
||||
} catch (error) {
|
||||
console.error('Error loading games:', error);
|
||||
@ -738,6 +747,21 @@
|
||||
}
|
||||
};
|
||||
|
||||
window.selectChapter = function(bookId, chapterId) {
|
||||
try {
|
||||
// Set global chapter ID for Games/Smart Guide
|
||||
window.currentBookId = bookId;
|
||||
window.currentChapterId = chapterId;
|
||||
console.log(`✅ Chapter selected: ${bookId}/${chapterId}`);
|
||||
|
||||
// Navigate to games for this chapter
|
||||
const { router } = app.getCore();
|
||||
router.navigate(`/games/${chapterId}`);
|
||||
} catch (error) {
|
||||
console.error('Chapter selection error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
window.openPlaceholder = function() {
|
||||
alert('Quick Play feature coming soon!');
|
||||
};
|
||||
@ -787,8 +811,14 @@
|
||||
throw new Error('GameLoader not available');
|
||||
}
|
||||
|
||||
// Get current content
|
||||
const content = contentLoader.getContent(currentChapterId);
|
||||
// Get current content using module ContentLoader (async)
|
||||
const moduleContentLoader = app.getModule('contentLoader');
|
||||
if (!moduleContentLoader) {
|
||||
throw new Error('ContentLoader not available');
|
||||
}
|
||||
|
||||
// Load chapter content asynchronously
|
||||
const content = await moduleContentLoader.getContent(currentChapterId || window.currentChapterId);
|
||||
if (!content) {
|
||||
throw new Error('No content loaded for current chapter');
|
||||
}
|
||||
|
||||
@ -332,30 +332,41 @@ class Application {
|
||||
}
|
||||
|
||||
async _handleGamesRoute(path, state) {
|
||||
this._eventBus.emit('navigation:games', { path, state }, 'Application');
|
||||
|
||||
// Simple approach: Force re-render by emitting the chapter navigation event
|
||||
console.log('🔄 Games route - path:', path, 'state:', state);
|
||||
|
||||
// Extract chapter ID from path or use current one
|
||||
// Extract ID from path
|
||||
const pathParts = path.split('/');
|
||||
let chapterId = pathParts[2] || window.currentChapterId || 'sbs';
|
||||
const pathId = pathParts[2];
|
||||
|
||||
console.log('🔄 Games route - using chapterId:', chapterId);
|
||||
// IMPORTANT: Use window.currentChapterId if available (set by Smart Guide/Chapter selection)
|
||||
// If not available, check if we should use the path ID
|
||||
let chapterId = window.currentChapterId;
|
||||
|
||||
// Make sure currentChapterId is set
|
||||
if (!window.currentChapterId) {
|
||||
window.currentChapterId = chapterId;
|
||||
if (!chapterId && pathId) {
|
||||
// Path has an ID, but we don't know if it's a book or chapter
|
||||
// Try to use it, but warn the user
|
||||
console.warn(`⚠️ Using ID from path: ${pathId} (may be book ID, should be chapter ID)`);
|
||||
chapterId = pathId;
|
||||
}
|
||||
|
||||
// Force navigation to the chapter games - this will trigger the content loading
|
||||
setTimeout(() => {
|
||||
console.log('🔄 Games route - forcing navigation event');
|
||||
this._eventBus.emit('navigation:games', {
|
||||
path: `/games/${chapterId}`,
|
||||
data: { path: `/games/${chapterId}` }
|
||||
}, 'Application');
|
||||
}, 100);
|
||||
if (!chapterId) {
|
||||
console.warn('⚠️ No chapter selected. Please select a chapter first.');
|
||||
// Redirect to home - user needs to select a chapter
|
||||
this._router.navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔄 Games route - using chapterId:', chapterId, '(from', window.currentChapterId ? 'window.currentChapterId' : 'path', ')');
|
||||
|
||||
// Keep currentChapterId consistent
|
||||
window.currentChapterId = chapterId;
|
||||
|
||||
// Emit navigation event ONCE - don't emit twice
|
||||
this._eventBus.emit('navigation:games', {
|
||||
path: `/games/${chapterId}`,
|
||||
data: { path: `/games/${chapterId}` }
|
||||
}, 'Application');
|
||||
}
|
||||
|
||||
async _handleDynamicRevisionRoute(path, state) {
|
||||
|
||||
@ -141,12 +141,8 @@ class ContentLoader extends Module {
|
||||
// Cache the result
|
||||
this._contentCache.set(cacheKey, content);
|
||||
|
||||
// Emit success event
|
||||
this._eventBus.emit('content:loaded', {
|
||||
request,
|
||||
content,
|
||||
cached: false
|
||||
}, this.name);
|
||||
// DON'T emit content:loaded for exercise content - that's for chapter content only
|
||||
// (exercises are ephemeral and shouldn't trigger game compatibility recalculation)
|
||||
|
||||
console.log(`✅ Content loaded: ${content.title || cacheKey}`);
|
||||
return content;
|
||||
@ -2491,6 +2487,12 @@ Return ONLY valid JSON:
|
||||
vocabCount: content.vocabulary ? Object.keys(content.vocabulary).length : 0
|
||||
});
|
||||
|
||||
// Emit content:loaded event for GameLoader
|
||||
this._eventBus.emit('content:loaded', {
|
||||
content,
|
||||
chapterId
|
||||
}, this.name);
|
||||
|
||||
return content;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@ -279,6 +279,15 @@ class GameLoader extends Module {
|
||||
|
||||
_handleContentUpdate(event) {
|
||||
console.log('🔄 GameLoader: Content updated, recalculating compatibility scores');
|
||||
const structure = {
|
||||
hasContent: !!event.data.content,
|
||||
contentKeys: Object.keys(event.data.content || {}),
|
||||
hasVocabulary: !!event.data.content?.vocabulary,
|
||||
vocabularyCount: event.data.content?.vocabulary ? Object.keys(event.data.content.vocabulary).length : 0,
|
||||
hasTexts: !!event.data.content?.texts,
|
||||
hasSentences: !!event.data.content?.sentences
|
||||
};
|
||||
console.log('📦 GameLoader: event.data structure:', JSON.stringify(structure, null, 2));
|
||||
this._currentContent = event.data.content;
|
||||
|
||||
// Recalculate compatibility scores for all games
|
||||
|
||||
@ -40,6 +40,10 @@ class StoryReader extends Module {
|
||||
this._startTime = null;
|
||||
this._totalReadingTime = 0;
|
||||
|
||||
// Event listener references for cleanup
|
||||
this._boundKeydownHandler = null;
|
||||
this._boundClickOutsideHandler = null;
|
||||
|
||||
Object.seal(this);
|
||||
}
|
||||
|
||||
@ -64,17 +68,33 @@ class StoryReader extends Module {
|
||||
* @returns {Object} Compatibility score and details
|
||||
*/
|
||||
static getCompatibilityScore(content) {
|
||||
const info = {
|
||||
hasStory: !!content?.story,
|
||||
hasAdditionalStories: !!content?.additionalStories,
|
||||
hasTexts: !!content?.texts,
|
||||
hasSentences: !!content?.sentences,
|
||||
contentKeys: Object.keys(content || {})
|
||||
};
|
||||
console.log('🔍 StoryReader: Checking compatibility with content:', JSON.stringify(info, null, 2));
|
||||
|
||||
let storyCount = 0;
|
||||
let hasMainStory = !!(content?.story?.title || content?.rawContent?.story?.title);
|
||||
let hasAdditionalStories = !!(content?.additionalStories?.length || content?.rawContent?.additionalStories?.length);
|
||||
let hasTexts = !!(content?.texts?.length || content?.rawContent?.texts?.length);
|
||||
let hasSentences = !!(content?.sentences?.length || content?.rawContent?.sentences?.length);
|
||||
let hasMainStory = !!(content?.story?.title);
|
||||
let hasAdditionalStories = !!(content?.additionalStories?.length);
|
||||
let hasTexts = !!(content?.texts?.length);
|
||||
let hasSentences = !!(content?.sentences?.length);
|
||||
|
||||
if (hasMainStory) storyCount++;
|
||||
if (hasAdditionalStories) storyCount += (content?.additionalStories?.length || content?.rawContent?.additionalStories?.length);
|
||||
if (hasTexts) storyCount += (content?.texts?.length || content?.rawContent?.texts?.length);
|
||||
if (hasAdditionalStories) storyCount += content.additionalStories.length;
|
||||
if (hasTexts) storyCount += content.texts.length;
|
||||
if (hasSentences) storyCount++;
|
||||
|
||||
console.log('🎯 StoryReader: Story count =', storyCount, {
|
||||
hasMainStory,
|
||||
hasAdditionalStories,
|
||||
hasTexts,
|
||||
hasSentences
|
||||
});
|
||||
|
||||
if (storyCount === 0) {
|
||||
return {
|
||||
score: 0,
|
||||
@ -114,7 +134,7 @@ class StoryReader extends Module {
|
||||
|
||||
// Select initial story
|
||||
this._selectStory(0);
|
||||
this._vocabulary = this._content?.vocabulary || this._content?.rawContent?.vocabulary || {};
|
||||
this._vocabulary = this._content?.vocabulary || {};
|
||||
|
||||
// Set up event listeners
|
||||
this._eventBus.on('game:pause', this._handlePause.bind(this), this.name);
|
||||
@ -161,6 +181,16 @@ class StoryReader extends Module {
|
||||
this._readingTimer = null;
|
||||
}
|
||||
|
||||
// Remove global event listeners
|
||||
if (this._boundKeydownHandler) {
|
||||
document.removeEventListener('keydown', this._boundKeydownHandler);
|
||||
this._boundKeydownHandler = null;
|
||||
}
|
||||
if (this._boundClickOutsideHandler) {
|
||||
document.removeEventListener('click', this._boundClickOutsideHandler);
|
||||
this._boundClickOutsideHandler = null;
|
||||
}
|
||||
|
||||
// Clean up container
|
||||
if (this._config.container) {
|
||||
this._config.container.innerHTML = '';
|
||||
@ -204,7 +234,7 @@ class StoryReader extends Module {
|
||||
this._availableStories = [];
|
||||
|
||||
// Check main story field
|
||||
const mainStory = this._content.rawContent?.story || this._content.story;
|
||||
const mainStory = this._content.story;
|
||||
if (mainStory && mainStory.title) {
|
||||
this._availableStories.push({
|
||||
id: 'main',
|
||||
@ -215,7 +245,7 @@ class StoryReader extends Module {
|
||||
}
|
||||
|
||||
// Check additional stories
|
||||
const additionalStories = this._content.rawContent?.additionalStories || this._content.additionalStories;
|
||||
const additionalStories = this._content.additionalStories;
|
||||
if (additionalStories && Array.isArray(additionalStories)) {
|
||||
additionalStories.forEach((story, index) => {
|
||||
if (story && story.title) {
|
||||
@ -230,7 +260,7 @@ class StoryReader extends Module {
|
||||
}
|
||||
|
||||
// Check texts and convert to stories
|
||||
const texts = this._content.rawContent?.texts || this._content.texts;
|
||||
const texts = this._content.texts;
|
||||
if (texts && Array.isArray(texts)) {
|
||||
texts.forEach((text, index) => {
|
||||
if (text && (text.title || text.original_language)) {
|
||||
@ -246,7 +276,7 @@ class StoryReader extends Module {
|
||||
}
|
||||
|
||||
// Check sentences and create story from them
|
||||
const sentences = this._content.rawContent?.sentences || this._content.sentences;
|
||||
const sentences = this._content.sentences;
|
||||
if (sentences && Array.isArray(sentences) && sentences.length > 0 && this._availableStories.length === 0) {
|
||||
const sentencesStory = this._convertSentencesToStory(sentences);
|
||||
this._availableStories.push({
|
||||
@ -280,7 +310,18 @@ class StoryReader extends Module {
|
||||
}
|
||||
|
||||
_convertTextToStory(text, index) {
|
||||
const sentences = this._splitTextIntoSentences(text.original_language, text.user_language);
|
||||
// Handle different text structures
|
||||
// 1. Old structure: text.original_language + text.user_language
|
||||
// 2. New structure: text.content (no translation yet)
|
||||
let originalText = text.original_language || text.content || '';
|
||||
let translationText = text.user_language || text.translation || '';
|
||||
|
||||
// If no translation, use empty string
|
||||
if (!translationText) {
|
||||
translationText = '';
|
||||
}
|
||||
|
||||
const sentences = this._splitTextIntoSentences(originalText, translationText);
|
||||
return {
|
||||
title: text.title || `Text ${index + 1}`,
|
||||
totalSentences: sentences.length,
|
||||
@ -309,9 +350,15 @@ class StoryReader extends Module {
|
||||
};
|
||||
}
|
||||
|
||||
_splitTextIntoSentences(originalText, translationText) {
|
||||
_splitTextIntoSentences(originalText, translationText = '') {
|
||||
// Validate inputs
|
||||
if (!originalText || typeof originalText !== 'string') {
|
||||
console.warn('StoryReader: Invalid originalText provided to _splitTextIntoSentences', originalText);
|
||||
return [];
|
||||
}
|
||||
|
||||
const originalSentences = originalText.split(/[.!?]+/).filter(s => s.trim().length > 0);
|
||||
const translationSentences = translationText.split(/[.!?]+/).filter(s => s.trim().length > 0);
|
||||
const translationSentences = translationText ? translationText.split(/[.!?]+/).filter(s => s.trim().length > 0) : [];
|
||||
const sentences = [];
|
||||
const maxSentences = Math.max(originalSentences.length, translationSentences.length);
|
||||
|
||||
@ -758,8 +805,8 @@ class StoryReader extends Module {
|
||||
// TTS button in popup
|
||||
document.getElementById('popup-tts-btn').addEventListener('click', () => this._speakWordFromPopup());
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Keyboard shortcuts - store handler reference for cleanup
|
||||
this._boundKeydownHandler = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||
|
||||
switch (e.key) {
|
||||
@ -780,14 +827,16 @@ class StoryReader extends Module {
|
||||
this._playSentenceTTS();
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
document.addEventListener('keydown', this._boundKeydownHandler);
|
||||
|
||||
// Click outside to close popup
|
||||
document.addEventListener('click', (e) => {
|
||||
// Click outside to close popup - store handler reference for cleanup
|
||||
this._boundClickOutsideHandler = (e) => {
|
||||
if (!e.target.closest('.word-popup') && !e.target.closest('.clickable-word')) {
|
||||
this._hideWordPopup();
|
||||
}
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', this._boundClickOutsideHandler);
|
||||
}
|
||||
|
||||
_getCurrentSentenceData() {
|
||||
@ -938,7 +987,10 @@ class StoryReader extends Module {
|
||||
}
|
||||
|
||||
_hideWordPopup() {
|
||||
document.getElementById('word-popup').style.display = 'none';
|
||||
const popup = document.getElementById('word-popup');
|
||||
if (popup) {
|
||||
popup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
_previousSentence() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user