- Add AIReportSystem.js for detailed AI response capture and report generation - Add AIReportInterface.js UI component for report access and export - Integrate AI reporting into LLMValidator and SmartPreviewOrchestrator - Add missing modules to Application.js configuration (unifiedDRS, smartPreviewOrchestrator) - Create missing content/chapters/sbs.json for book metadata - Enhance Application.js with debug logging for module loading - Add multi-format export capabilities (text, HTML, JSON) - Implement automatic learning insights extraction from AI feedback - Add session management and performance tracking for AI reports 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1168 lines
40 KiB
JavaScript
1168 lines
40 KiB
JavaScript
import Module from '../core/Module.js';
|
|
|
|
/**
|
|
* StoryReader - Interactive story reading game with vocabulary support
|
|
* Allows reading stories sentence by sentence with word translations and TTS
|
|
*/
|
|
class StoryReader extends Module {
|
|
constructor(name, dependencies, config = {}) {
|
|
super(name, ['eventBus']);
|
|
|
|
// Validate dependencies
|
|
if (!dependencies.eventBus || !dependencies.content) {
|
|
throw new Error('StoryReader requires eventBus and content dependencies');
|
|
}
|
|
|
|
this._eventBus = dependencies.eventBus;
|
|
this._content = dependencies.content;
|
|
this._config = {
|
|
container: null,
|
|
autoPlayTTS: true,
|
|
fontSize: 'medium',
|
|
readingMode: 'sentence',
|
|
ttsSpeed: 0.8,
|
|
...config
|
|
};
|
|
|
|
// Reading state
|
|
this._currentStory = null;
|
|
this._availableStories = [];
|
|
this._currentStoryIndex = 0;
|
|
this._currentSentence = 0;
|
|
this._totalSentences = 0;
|
|
this._wordsRead = 0;
|
|
this._vocabulary = {};
|
|
|
|
// UI state
|
|
this._showTranslations = false;
|
|
this._showPronunciations = false;
|
|
this._readingTimer = null;
|
|
this._startTime = null;
|
|
this._totalReadingTime = 0;
|
|
|
|
Object.seal(this);
|
|
}
|
|
|
|
/**
|
|
* Get game metadata
|
|
* @returns {Object} Game metadata
|
|
*/
|
|
static getMetadata() {
|
|
return {
|
|
name: 'Story Reader',
|
|
description: 'Read stories with interactive vocabulary and translations',
|
|
difficulty: 'beginner',
|
|
category: 'reading',
|
|
estimatedTime: 15, // minutes
|
|
skills: ['reading', 'vocabulary', 'comprehension']
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate compatibility score with content
|
|
* @param {Object} content - Content to check compatibility with
|
|
* @returns {Object} Compatibility score and details
|
|
*/
|
|
static getCompatibilityScore(content) {
|
|
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);
|
|
|
|
if (hasMainStory) storyCount++;
|
|
if (hasAdditionalStories) storyCount += (content?.additionalStories?.length || content?.rawContent?.additionalStories?.length);
|
|
if (hasTexts) storyCount += (content?.texts?.length || content?.rawContent?.texts?.length);
|
|
if (hasSentences) storyCount++;
|
|
|
|
if (storyCount === 0) {
|
|
return {
|
|
score: 0,
|
|
reason: 'No story content found',
|
|
requirements: ['story', 'texts', 'or sentences'],
|
|
details: 'Story Reader needs stories, texts, or sentences to read'
|
|
};
|
|
}
|
|
|
|
// Perfect score for multiple stories, good score for single story
|
|
const score = Math.min(storyCount / 3, 1);
|
|
|
|
return {
|
|
score,
|
|
reason: `${storyCount} story/text sources available`,
|
|
requirements: ['story content'],
|
|
optimalSources: 3,
|
|
details: `Can create reading experience from ${storyCount} content sources`
|
|
};
|
|
}
|
|
|
|
async init() {
|
|
this._validateNotDestroyed();
|
|
|
|
try {
|
|
// Validate container
|
|
if (!this._config.container) {
|
|
throw new Error('Game container is required');
|
|
}
|
|
|
|
// Discover and prepare story content
|
|
this._discoverAvailableStories();
|
|
|
|
if (this._availableStories.length === 0) {
|
|
throw new Error('No story content found for reading');
|
|
}
|
|
|
|
// Select initial story
|
|
this._selectStory(0);
|
|
this._vocabulary = this._content?.vocabulary || this._content?.rawContent?.vocabulary || {};
|
|
|
|
// Set up event listeners
|
|
this._eventBus.on('game:pause', this._handlePause.bind(this), this.name);
|
|
this._eventBus.on('game:resume', this._handleResume.bind(this), this.name);
|
|
|
|
// Initialize game interface
|
|
this._injectCSS();
|
|
this._createGameInterface();
|
|
this._setupEventListeners();
|
|
this._loadProgress();
|
|
|
|
// Start reading session
|
|
this._startTime = Date.now();
|
|
this._startReadingTimer();
|
|
|
|
// Render initial content
|
|
this._renderCurrentSentence();
|
|
|
|
// Emit game ready event
|
|
this._eventBus.emit('game:ready', {
|
|
gameId: 'story-reader',
|
|
instanceId: this.name,
|
|
stories: this._availableStories.length,
|
|
sentences: this._totalSentences
|
|
}, this.name);
|
|
|
|
this._setInitialized();
|
|
|
|
} catch (error) {
|
|
this._showError(error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async destroy() {
|
|
this._validateNotDestroyed();
|
|
|
|
// Save progress before cleanup
|
|
this._saveProgress();
|
|
|
|
// Clean up timer
|
|
if (this._readingTimer) {
|
|
clearInterval(this._readingTimer);
|
|
this._readingTimer = null;
|
|
}
|
|
|
|
// Clean up container
|
|
if (this._config.container) {
|
|
this._config.container.innerHTML = '';
|
|
}
|
|
|
|
// Remove injected CSS
|
|
this._removeInjectedCSS();
|
|
|
|
// Emit game end event
|
|
this._eventBus.emit('game:ended', {
|
|
gameId: 'story-reader',
|
|
instanceId: this.name,
|
|
wordsRead: this._wordsRead,
|
|
readingTime: this._totalReadingTime,
|
|
sentencesRead: this._currentSentence
|
|
}, this.name);
|
|
|
|
this._setDestroyed();
|
|
}
|
|
|
|
/**
|
|
* Get current game state
|
|
* @returns {Object} Current game state
|
|
*/
|
|
getGameState() {
|
|
this._validateInitialized();
|
|
|
|
return {
|
|
currentStory: this._currentStoryIndex,
|
|
currentSentence: this._currentSentence,
|
|
totalSentences: this._totalSentences,
|
|
wordsRead: this._wordsRead,
|
|
readingTime: this._totalReadingTime,
|
|
progress: this._totalSentences > 0 ? (this._currentSentence / this._totalSentences) * 100 : 0,
|
|
isComplete: this._currentSentence >= this._totalSentences - 1
|
|
};
|
|
}
|
|
|
|
// Private methods
|
|
_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 additional stories
|
|
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'
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Check texts and convert 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'
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Check sentences and create 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'
|
|
});
|
|
}
|
|
}
|
|
|
|
_selectStory(storyIndex) {
|
|
if (storyIndex >= 0 && storyIndex < this._availableStories.length) {
|
|
this._currentStoryIndex = storyIndex;
|
|
this._currentStory = this._availableStories[storyIndex].data;
|
|
this._calculateTotalSentences();
|
|
|
|
// Reset reading position for new story
|
|
this._currentSentence = 0;
|
|
this._wordsRead = 0;
|
|
}
|
|
}
|
|
|
|
_calculateTotalSentences() {
|
|
this._totalSentences = 0;
|
|
if (this._currentStory && this._currentStory.chapters) {
|
|
this._currentStory.chapters.forEach(chapter => {
|
|
this._totalSentences += chapter.sentences.length;
|
|
});
|
|
}
|
|
}
|
|
|
|
_convertTextToStory(text, index) {
|
|
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
|
|
}]
|
|
};
|
|
}
|
|
|
|
_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 || ''
|
|
}));
|
|
|
|
return {
|
|
title: storyTitle,
|
|
totalSentences: convertedSentences.length,
|
|
chapters: [{
|
|
title: "Reading Sentences",
|
|
sentences: convertedSentences
|
|
}]
|
|
};
|
|
}
|
|
|
|
_splitTextIntoSentences(originalText, translationText) {
|
|
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(/[.!?]$/) ? '.' : '')
|
|
});
|
|
}
|
|
}
|
|
|
|
return sentences;
|
|
}
|
|
|
|
_injectCSS() {
|
|
if (document.getElementById('story-reader-styles')) return;
|
|
|
|
const styleSheet = document.createElement('style');
|
|
styleSheet.id = 'story-reader-styles';
|
|
styleSheet.textContent = `
|
|
.story-reader-wrapper {
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
font-family: 'Georgia', serif;
|
|
line-height: 1.6;
|
|
height: 100vh;
|
|
overflow-y: auto;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.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.6em;
|
|
}
|
|
|
|
.reading-progress {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-size: 0.9em;
|
|
color: #718096;
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 150px;
|
|
height: 6px;
|
|
background: #e2e8f0;
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #3b82f6, #10b981);
|
|
width: 0%;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.story-controls {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.control-btn {
|
|
padding: 6px 12px;
|
|
border: 1px solid #e2e8f0;
|
|
background: white;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.85em;
|
|
transition: all 0.2s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.control-btn:hover {
|
|
background: #f7fafc;
|
|
border-color: #cbd5e0;
|
|
}
|
|
|
|
.control-btn.active {
|
|
background: #3b82f6;
|
|
color: white;
|
|
border-color: #3b82f6;
|
|
}
|
|
|
|
.reading-area {
|
|
background: white;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
margin-bottom: 20px;
|
|
min-height: 200px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.sentence-display {
|
|
text-align: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.original-text {
|
|
font-size: 1.2em;
|
|
color: #2d3748;
|
|
margin-bottom: 15px;
|
|
line-height: 1.8;
|
|
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;
|
|
display: none;
|
|
}
|
|
|
|
.translation-text.show {
|
|
display: block;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
display: none;
|
|
}
|
|
|
|
.word-original {
|
|
font-weight: bold;
|
|
color: #2d3748;
|
|
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: 20px;
|
|
height: 20px;
|
|
cursor: pointer;
|
|
font-size: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.story-navigation {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.nav-btn {
|
|
padding: 10px 20px;
|
|
border: 2px solid #e2e8f0;
|
|
background: white;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 0.95em;
|
|
transition: all 0.2s;
|
|
min-width: 100px;
|
|
}
|
|
|
|
.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.8em;
|
|
color: #718096;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.stat-value {
|
|
display: block;
|
|
font-weight: bold;
|
|
font-size: 1em;
|
|
color: #2d3748;
|
|
}
|
|
|
|
.story-selector {
|
|
background: #f8fafc;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
padding: 10px 15px;
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.story-selector select {
|
|
flex: 1;
|
|
padding: 6px 10px;
|
|
border: 1px solid #cbd5e0;
|
|
border-radius: 4px;
|
|
background: white;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.story-reader-wrapper {
|
|
padding: 15px;
|
|
}
|
|
|
|
.story-header {
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
}
|
|
|
|
.story-controls {
|
|
justify-content: center;
|
|
}
|
|
|
|
.reading-stats {
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.story-navigation {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.nav-btn {
|
|
min-width: auto;
|
|
padding: 8px 16px;
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(styleSheet);
|
|
}
|
|
|
|
_removeInjectedCSS() {
|
|
const styleSheet = document.getElementById('story-reader-styles');
|
|
if (styleSheet) {
|
|
styleSheet.remove();
|
|
}
|
|
}
|
|
|
|
_createGameInterface() {
|
|
// Create story selector if multiple stories available
|
|
const storySelector = this._availableStories.length > 1 ? `
|
|
<div class="story-selector">
|
|
<label for="story-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._config.container.innerHTML = `
|
|
<div class="story-reader-wrapper">
|
|
${storySelector}
|
|
|
|
<!-- Header -->
|
|
<div class="story-header">
|
|
<div class="story-title">
|
|
<h2>${this._currentStory.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" id="play-btn">🔊 Play</button>
|
|
<button class="control-btn" id="translation-btn">🌐 Translation</button>
|
|
<button class="control-btn" id="bookmark-btn">🔖 Save</button>
|
|
<button class="control-btn" id="exit-btn">← Exit</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reading Area -->
|
|
<div class="reading-area">
|
|
<div class="sentence-display">
|
|
<div class="original-text ${this._config.fontSize}" id="original-text">
|
|
Loading story...
|
|
</div>
|
|
<div class="translation-text" id="translation-text">
|
|
Translation will appear here...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Word popup -->
|
|
<div class="word-popup" id="word-popup">
|
|
<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">🔊</button>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
<div class="story-navigation">
|
|
<button class="nav-btn" id="prev-btn" disabled>⬅️ Previous</button>
|
|
<button class="nav-btn primary" id="next-btn">Next ➡️</button>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="reading-stats">
|
|
<div class="stat">
|
|
<span class="stat-label">Progress</span>
|
|
<span class="stat-value" id="progress-percent">0%</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-label">Words Read</span>
|
|
<span class="stat-value" id="words-count">0</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-label">Time</span>
|
|
<span class="stat-value" id="time-count">00:00</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_setupEventListeners() {
|
|
// Story selector
|
|
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());
|
|
|
|
// Controls
|
|
document.getElementById('play-btn').addEventListener('click', () => this._playSentenceTTS());
|
|
document.getElementById('translation-btn').addEventListener('click', () => this._toggleTranslations());
|
|
document.getElementById('bookmark-btn').addEventListener('click', () => this._saveBookmark());
|
|
document.getElementById('exit-btn').addEventListener('click', () => {
|
|
this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name);
|
|
});
|
|
|
|
// TTS button in popup
|
|
document.getElementById('popup-tts-btn').addEventListener('click', () => this._speakWordFromPopup());
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowLeft':
|
|
this._previousSentence();
|
|
break;
|
|
case 'ArrowRight':
|
|
case ' ':
|
|
e.preventDefault();
|
|
this._nextSentence();
|
|
break;
|
|
case 't':
|
|
case 'T':
|
|
this._toggleTranslations();
|
|
break;
|
|
case 's':
|
|
case 'S':
|
|
this._playSentenceTTS();
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Click outside to close 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._currentStory.chapters.length; chapterIndex++) {
|
|
const chapter = this._currentStory.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;
|
|
}
|
|
|
|
_matchWordsWithVocabulary(sentence) {
|
|
const words = sentence.split(/(\s+|[.,!?;:"'()[\]{}\-–—])/);
|
|
const matchedWords = [];
|
|
|
|
words.forEach(token => {
|
|
if (/^\s+$/.test(token)) {
|
|
matchedWords.push({ original: token, hasVocab: false, isWhitespace: true });
|
|
return;
|
|
}
|
|
|
|
if (/^[.,!?;:"'()[\]{}\-–—]+$/.test(token)) {
|
|
matchedWords.push({ original: token, hasVocab: false, isPunctuation: true });
|
|
return;
|
|
}
|
|
|
|
const cleanWord = token.toLowerCase().replace(/[.,!?;:"'()[\]{}\-–—]/g, '');
|
|
if (!cleanWord) return;
|
|
|
|
let vocabEntry = this._vocabulary[cleanWord];
|
|
|
|
// Try variations if exact match not found
|
|
if (!vocabEntry) {
|
|
if (cleanWord.endsWith('s')) vocabEntry = this._vocabulary[cleanWord.slice(0, -1)];
|
|
if (!vocabEntry && cleanWord.endsWith('ed')) vocabEntry = this._vocabulary[cleanWord.slice(0, -2)];
|
|
if (!vocabEntry && cleanWord.endsWith('ing')) vocabEntry = this._vocabulary[cleanWord.slice(0, -3)];
|
|
}
|
|
|
|
if (vocabEntry) {
|
|
matchedWords.push({
|
|
original: token,
|
|
hasVocab: true,
|
|
word: cleanWord,
|
|
translation: vocabEntry.user_language || vocabEntry.translation,
|
|
pronunciation: vocabEntry.pronunciation,
|
|
type: vocabEntry.type || 'unknown'
|
|
});
|
|
} else {
|
|
matchedWords.push({ original: token, hasVocab: false });
|
|
}
|
|
});
|
|
|
|
return matchedWords;
|
|
}
|
|
|
|
_renderCurrentSentence() {
|
|
const sentenceData = this._getCurrentSentenceData();
|
|
if (!sentenceData) {
|
|
console.warn('StoryReader: No sentence data found for current sentence index', this._currentSentence);
|
|
return;
|
|
}
|
|
|
|
const { data } = sentenceData;
|
|
if (!data || !data.original) {
|
|
console.warn('StoryReader: Invalid sentence data - missing original text', data);
|
|
return;
|
|
}
|
|
|
|
// Update progress
|
|
const progress = ((this._currentSentence + 1) / this._totalSentences) * 100;
|
|
const progressFill = document.getElementById('progress-fill');
|
|
const progressText = document.getElementById('progress-text');
|
|
const progressPercent = document.getElementById('progress-percent');
|
|
|
|
if (progressFill) progressFill.style.width = `${progress}%`;
|
|
if (progressText) progressText.textContent = `Sentence ${this._currentSentence + 1} of ${this._totalSentences}`;
|
|
if (progressPercent) progressPercent.textContent = `${Math.round(progress)}%`;
|
|
|
|
// Render sentence with vocabulary matching
|
|
const matchedWords = this._matchWordsWithVocabulary(data.original);
|
|
const wordsHtml = matchedWords.map(wordInfo => {
|
|
if (wordInfo.isWhitespace) return wordInfo.original;
|
|
if (wordInfo.isPunctuation) return `<span class="punctuation">${wordInfo.original}</span>`;
|
|
if (wordInfo.hasVocab) {
|
|
return `<span class="clickable-word" data-word="${wordInfo.word}" data-translation="${wordInfo.translation}" data-type="${wordInfo.type}" data-pronunciation="${wordInfo.pronunciation || ''}">${wordInfo.original}</span>`;
|
|
}
|
|
return wordInfo.original;
|
|
}).join('');
|
|
|
|
const originalTextEl = document.getElementById('original-text');
|
|
const translationTextEl = document.getElementById('translation-text');
|
|
const prevBtn = document.getElementById('prev-btn');
|
|
const nextBtn = document.getElementById('next-btn');
|
|
|
|
if (originalTextEl) originalTextEl.innerHTML = wordsHtml;
|
|
if (translationTextEl) translationTextEl.textContent = data.translation || '';
|
|
|
|
// Add word click listeners
|
|
document.querySelectorAll('.clickable-word').forEach(word => {
|
|
word.addEventListener('click', (e) => this._showWordPopup(e));
|
|
});
|
|
|
|
// Update navigation buttons
|
|
if (prevBtn) prevBtn.disabled = this._currentSentence === 0;
|
|
if (nextBtn) nextBtn.disabled = this._currentSentence >= this._totalSentences - 1;
|
|
|
|
// Update stats
|
|
this._updateStats();
|
|
|
|
// Auto-play TTS if enabled
|
|
if (this._config.autoPlayTTS) {
|
|
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;
|
|
|
|
const popup = document.getElementById('word-popup');
|
|
popup.currentWord = word;
|
|
|
|
document.getElementById('popup-word').textContent = word;
|
|
document.getElementById('popup-translation').textContent = translation;
|
|
document.getElementById('popup-type').textContent = pronunciation ? `${pronunciation} (${type})` : `(${type})`;
|
|
|
|
// Position popup
|
|
const rect = event.target.getBoundingClientRect();
|
|
popup.style.display = 'block';
|
|
|
|
const popupLeft = Math.min(rect.left + (rect.width / 2) - 100, window.innerWidth - 210);
|
|
const popupTop = rect.top - 10;
|
|
|
|
popup.style.left = `${Math.max(10, popupLeft)}px`;
|
|
popup.style.top = `${popupTop}px`;
|
|
popup.style.transform = popupTop > 80 ? 'translateY(-100%)' : 'translateY(10px)';
|
|
}
|
|
|
|
_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();
|
|
}
|
|
}
|
|
|
|
_toggleTranslations() {
|
|
this._showTranslations = !this._showTranslations;
|
|
const translationElement = document.getElementById('translation-text');
|
|
const btn = document.getElementById('translation-btn');
|
|
|
|
if (this._showTranslations) {
|
|
translationElement.classList.add('show');
|
|
btn.classList.add('active');
|
|
btn.textContent = '🌐 Hide';
|
|
} else {
|
|
translationElement.classList.remove('show');
|
|
btn.classList.remove('active');
|
|
btn.textContent = '🌐 Translation';
|
|
}
|
|
}
|
|
|
|
_playSentenceTTS() {
|
|
const sentenceData = this._getCurrentSentenceData();
|
|
if (!sentenceData) return;
|
|
|
|
this._speakText(sentenceData.data.original);
|
|
}
|
|
|
|
_speakText(text, options = {}) {
|
|
if (!text) return;
|
|
|
|
try {
|
|
if ('speechSynthesis' in window) {
|
|
speechSynthesis.cancel();
|
|
|
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
utterance.lang = this._getContentLanguage();
|
|
utterance.rate = options.rate || this._config.ttsSpeed;
|
|
utterance.volume = 1.0;
|
|
|
|
speechSynthesis.speak(utterance);
|
|
}
|
|
} catch (error) {
|
|
console.warn('TTS error:', error);
|
|
}
|
|
}
|
|
|
|
_speakWordFromPopup() {
|
|
const popup = document.getElementById('word-popup');
|
|
if (popup && popup.currentWord) {
|
|
this._speakText(popup.currentWord, { rate: 0.7 });
|
|
}
|
|
}
|
|
|
|
_getContentLanguage() {
|
|
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';
|
|
}
|
|
|
|
_changeStory(storyIndex) {
|
|
if (storyIndex !== this._currentStoryIndex) {
|
|
this._saveProgress();
|
|
this._selectStory(storyIndex);
|
|
this._loadProgress();
|
|
|
|
// Update interface
|
|
document.querySelector('.story-title h2').textContent = this._currentStory.title;
|
|
this._renderCurrentSentence();
|
|
}
|
|
}
|
|
|
|
_startReadingTimer() {
|
|
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('time-count').textContent = timeString;
|
|
}
|
|
|
|
_updateStats() {
|
|
const sentenceData = this._getCurrentSentenceData();
|
|
if (sentenceData && sentenceData.data.original) {
|
|
// Count words in current sentence
|
|
const wordCount = sentenceData.data.original.split(/\s+/).length;
|
|
this._wordsRead += wordCount;
|
|
document.getElementById('words-count').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) {
|
|
this._currentSentence = 0;
|
|
this._wordsRead = 0;
|
|
}
|
|
} else {
|
|
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();
|
|
|
|
// Show toast notification
|
|
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;
|
|
font-size: 0.9em;
|
|
`;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 2000);
|
|
}
|
|
|
|
_completeReading() {
|
|
// Emit completion event
|
|
this._eventBus.emit('game:completed', {
|
|
gameId: 'story-reader',
|
|
instanceId: this.name,
|
|
wordsRead: this._wordsRead,
|
|
readingTime: this._totalReadingTime,
|
|
sentencesRead: this._totalSentences
|
|
}, this.name);
|
|
|
|
// Show completion message
|
|
const completionMessage = `
|
|
<div style="text-align: center; padding: 40px;">
|
|
<h2>🎉 Story Complete!</h2>
|
|
<p>You've finished reading "${this._currentStory.title}"</p>
|
|
<p><strong>Words read:</strong> ${this._wordsRead}</p>
|
|
<p><strong>Reading time:</strong> ${Math.floor(this._totalReadingTime / 60)}:${(this._totalReadingTime % 60).toString().padStart(2, '0')}</p>
|
|
<button class="nav-btn primary" onclick="location.reload()">Read Again</button>
|
|
</div>
|
|
`;
|
|
|
|
document.querySelector('.reading-area').innerHTML = completionMessage;
|
|
}
|
|
|
|
_showError(message) {
|
|
if (this._config.container) {
|
|
this._config.container.innerHTML = `
|
|
<div class="story-error" style="text-align: center; padding: 40px;">
|
|
<div style="font-size: 3em; margin-bottom: 20px;">❌</div>
|
|
<h3>Story Reader Error</h3>
|
|
<p>${message}</p>
|
|
<button class="nav-btn primary" onclick="history.back()">Go Back</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
_handlePause() {
|
|
if (this._readingTimer) {
|
|
clearInterval(this._readingTimer);
|
|
this._readingTimer = null;
|
|
}
|
|
this._eventBus.emit('game:paused', { instanceId: this.name }, this.name);
|
|
}
|
|
|
|
_handleResume() {
|
|
this._startReadingTimer();
|
|
this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name);
|
|
}
|
|
}
|
|
|
|
export default StoryReader; |