Class_generator/src/DRS/exercise-modules/ImageModule.js
StillHammer 05142bdfbc Implement comprehensive AI text report/export system
- 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>
2025-09-26 21:24:13 +08:00

1949 lines
68 KiB
JavaScript

/**
* ImageModule - Visual comprehension exercises with AI validation
* Handles image content with visual analysis questions and description exercises
*/
import ExerciseModuleInterface from '../interfaces/ExerciseModuleInterface.js';
class ImageModule extends ExerciseModuleInterface {
constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) {
super();
// Validate dependencies
if (!orchestrator || !llmValidator || !prerequisiteEngine || !contextMemory) {
throw new Error('ImageModule requires all service dependencies');
}
this.orchestrator = orchestrator;
this.llmValidator = llmValidator;
this.prerequisiteEngine = prerequisiteEngine;
this.contextMemory = contextMemory;
// Module state
this.initialized = false;
this.container = null;
this.currentExerciseData = null;
this.currentImage = null;
this.currentQuestion = null;
this.questionIndex = 0;
this.questionResults = [];
this.validationInProgress = false;
this.lastValidationResult = null;
this.aiAvailable = false;
this.imageLoaded = false;
this.viewingTime = 0;
this.startViewTime = null;
// Configuration
this.config = {
requiredProvider: 'openai', // Prefer OpenAI for image analysis
model: 'gpt-4o-mini', // Model that supports vision
temperature: 0.2,
maxTokens: 800,
timeout: 45000, // Longer timeout for image analysis
questionsPerImage: 3, // Default number of questions per image
minViewTime: 5, // Minimum viewing time in seconds for bonus
showImageDuringQuestions: true, // Keep image visible during questions
allowZoom: true // Allow image zooming
};
// Languages configuration
this.languages = {
userLanguage: 'English',
targetLanguage: 'French'
};
// Bind methods
this._handleUserInput = this._handleUserInput.bind(this);
this._handleNextQuestion = this._handleNextQuestion.bind(this);
this._handleRetry = this._handleRetry.bind(this);
this._handleImageLoad = this._handleImageLoad.bind(this);
this._handleImageZoom = this._handleImageZoom.bind(this);
this._startViewTimer = this._startViewTimer.bind(this);
this._stopViewTimer = this._stopViewTimer.bind(this);
}
async init() {
if (this.initialized) return;
console.log('🖼️ Initializing ImageModule...');
// Test AI connectivity - required for image analysis
try {
const testResult = await this.llmValidator.testConnectivity();
if (testResult.success) {
console.log(`✅ AI connectivity verified for image analysis (providers: ${testResult.availableProviders?.join(', ') || testResult.provider})`);
this.aiAvailable = true;
} else {
console.warn('⚠️ AI connection failed - image comprehension will be very limited:', testResult.error);
this.aiAvailable = false;
}
} catch (error) {
console.warn('⚠️ AI connectivity test failed - using basic image analysis:', error.message);
this.aiAvailable = false;
}
this.initialized = true;
console.log(`✅ ImageModule initialized (AI: ${this.aiAvailable ? 'available for vision analysis' : 'limited - basic analysis only'})`);
}
/**
* Check if module can run with current prerequisites
* @param {Array} prerequisites - List of learned vocabulary/concepts
* @param {Object} chapterContent - Full chapter content
* @returns {boolean} - True if module can run
*/
canRun(prerequisites, chapterContent) {
// Check if there are images and if prerequisites allow them
const images = chapterContent?.images || [];
if (images.length === 0) return false;
// Find images that can be unlocked with current prerequisites
const availableImages = images.filter(image => {
const unlockStatus = this.prerequisiteEngine.canUnlock('image', image);
return unlockStatus.canUnlock;
});
return availableImages.length > 0;
}
/**
* Present exercise UI and content
* @param {HTMLElement} container - DOM container to render into
* @param {Object} exerciseData - Specific exercise data to present
* @returns {Promise<void>}
*/
async present(container, exerciseData) {
if (!this.initialized) {
throw new Error('ImageModule must be initialized before use');
}
this.container = container;
this.currentExerciseData = exerciseData;
this.currentImage = exerciseData.image;
this.questionIndex = 0;
this.questionResults = [];
this.validationInProgress = false;
this.lastValidationResult = null;
this.imageLoaded = false;
this.viewingTime = 0;
this.startViewTime = null;
// Detect languages from chapter content
this._detectLanguages(exerciseData);
// Generate or extract questions
this.questions = await this._prepareQuestions(this.currentImage);
console.log(`🖼️ Presenting image comprehension: "${this.currentImage.title || 'Visual Exercise'}" (${this.questions.length} questions)`);
// Render initial UI
await this._renderImageExercise();
// Start with image viewing phase
this._showImageViewing();
}
/**
* Validate user input with AI for deep image comprehension
* @param {string} userInput - User's response
* @param {Object} context - Exercise context
* @returns {Promise<ValidationResult>} - Validation result with score and feedback
*/
async validate(userInput, context) {
if (!userInput || !userInput.trim()) {
throw new Error('Please provide an answer');
}
if (!this.currentImage || !this.currentQuestion) {
throw new Error('No image or question loaded for validation');
}
console.log(`🖼️ Validating image comprehension answer for question ${this.questionIndex + 1}`);
// Build comprehensive prompt for image comprehension
const prompt = this._buildImageComprehensionPrompt(userInput);
try {
// Use AI validation with structured response - includes image if available
const aiResponse = await this.llmValidator.iaEngine.validateEducationalContent(prompt, {
preferredProvider: this.config.requiredProvider,
temperature: this.config.temperature,
maxTokens: this.config.maxTokens,
timeout: this.config.timeout,
imageUrl: this.currentImage.url || this.currentImage.src, // Include image for vision analysis
systemPrompt: `You are an expert visual comprehension evaluator. Analyze both the image content and the student's response. Focus on visual observation skills and interpretation. ALWAYS respond in the exact format: [answer]yes/no [explanation]your detailed analysis here`
});
// Parse structured response
const parsedResult = this._parseStructuredResponse(aiResponse);
// Apply viewing time bonus
if (this.viewingTime >= this.config.minViewTime) {
parsedResult.score = Math.min(parsedResult.score + 5, 100);
parsedResult.feedback += ` (Bonus: +5 points for thorough image observation)`;
}
// Record interaction in context memory
this.contextMemory.recordInteraction({
type: 'image',
subtype: 'comprehension',
content: {
image: this.currentImage,
question: this.currentQuestion,
imageTitle: this.currentImage.title || 'Visual Exercise',
imageDescription: this.currentImage.description || '',
viewingTime: this.viewingTime
},
userResponse: userInput.trim(),
validation: parsedResult,
context: {
languages: this.languages,
questionIndex: this.questionIndex,
totalQuestions: this.questions.length,
observationTime: this.viewingTime
}
});
return parsedResult;
} catch (error) {
console.error('❌ AI image comprehension validation failed:', error);
// Fallback to basic keyword analysis if AI fails
if (!this.aiAvailable) {
return this._performBasicImageValidation(userInput);
}
throw new Error(`Image comprehension validation failed: ${error.message}. Please check your answer and try again.`);
}
}
/**
* Get current progress data
* @returns {ProgressData} - Progress information for this module
*/
getProgress() {
const totalQuestions = this.questions ? this.questions.length : 0;
const completedQuestions = this.questionResults.length;
const correctAnswers = this.questionResults.filter(result => result.correct).length;
return {
type: 'image',
imageTitle: this.currentImage?.title || 'Visual Exercise',
totalQuestions,
completedQuestions,
correctAnswers,
currentQuestionIndex: this.questionIndex,
questionResults: this.questionResults,
progressPercentage: totalQuestions > 0 ? Math.round((completedQuestions / totalQuestions) * 100) : 0,
comprehensionRate: completedQuestions > 0 ? Math.round((correctAnswers / completedQuestions) * 100) : 0,
observationTime: this.viewingTime,
aiAnalysisAvailable: this.aiAvailable
};
}
/**
* Clean up and prepare for unloading
*/
cleanup() {
console.log('🧹 Cleaning up ImageModule...');
// Stop view timer
this._stopViewTimer();
// Remove event listeners
if (this.container) {
this.container.innerHTML = '';
}
// Reset state
this.container = null;
this.currentExerciseData = null;
this.currentImage = null;
this.currentQuestion = null;
this.questionIndex = 0;
this.questionResults = [];
this.questions = null;
this.validationInProgress = false;
this.lastValidationResult = null;
this.imageLoaded = false;
this.viewingTime = 0;
this.startViewTime = null;
console.log('✅ ImageModule cleaned up');
}
/**
* Get module metadata
* @returns {Object} - Module information
*/
getMetadata() {
return {
name: 'ImageModule',
type: 'image',
version: '1.0.0',
description: 'Visual comprehension exercises with AI-powered image analysis',
capabilities: ['image_comprehension', 'visual_analysis', 'description_skills', 'ai_vision', 'ai_feedback'],
aiRequired: true, // Highly recommended for image analysis
config: this.config
};
}
// Private Methods
/**
* Detect languages from exercise data
* @private
*/
_detectLanguages(exerciseData) {
const chapterContent = this.currentExerciseData?.chapterContent;
if (chapterContent?.metadata?.userLanguage) {
this.languages.userLanguage = chapterContent.metadata.userLanguage;
}
if (chapterContent?.metadata?.targetLanguage) {
this.languages.targetLanguage = chapterContent.metadata.targetLanguage;
}
console.log(`🌍 Image languages detected: ${this.languages.userLanguage} -> ${this.languages.targetLanguage}`);
}
/**
* Prepare questions for the image
* @private
*/
async _prepareQuestions(image) {
// If image already has questions, use them
if (image.questions && image.questions.length > 0) {
return image.questions.map((q, index) => ({
id: `q${index + 1}`,
question: q.question || q.text || q,
type: q.type || 'open',
expectedAnswer: q.answer || q.expectedAnswer,
keywords: q.keywords || [],
difficulty: q.difficulty || 'medium',
focusArea: q.focusArea || 'general' // general, details, interpretation, description
}));
}
// Generate default visual comprehension questions
const defaultQuestions = [
{
id: 'description',
question: `Describe what you see in this image in detail.`,
type: 'open',
keywords: ['see', 'image', 'shows', 'contains', 'depicts'],
difficulty: 'easy',
focusArea: 'description'
},
{
id: 'details',
question: `What specific details or objects can you identify in the image?`,
type: 'open',
keywords: ['details', 'objects', 'specific', 'identify', 'notice'],
difficulty: 'medium',
focusArea: 'details'
},
{
id: 'interpretation',
question: `What do you think is happening in this image? What story does it tell?`,
type: 'open',
keywords: ['happening', 'story', 'context', 'situation', 'interpret'],
difficulty: 'hard',
focusArea: 'interpretation'
}
];
// Limit to configured number of questions
return defaultQuestions.slice(0, this.config.questionsPerImage);
}
/**
* Build comprehensive prompt for image comprehension validation
* @private
*/
_buildImageComprehensionPrompt(userAnswer) {
const imageTitle = this.currentImage.title || 'Visual Exercise';
const imageDescription = this.currentImage.description || 'No description provided';
const imageUrl = this.currentImage.url || this.currentImage.src || '';
return `You are evaluating visual comprehension for a language learning exercise.
CRITICAL: You MUST respond in this EXACT format: [answer]yes/no [explanation]your detailed analysis here
IMAGE CONTENT:
Title: "${imageTitle}"
Description: "${imageDescription}"
${imageUrl ? `Image URL: ${imageUrl}` : 'Image: Available for visual analysis'}
QUESTION: ${this.currentQuestion.question}
STUDENT RESPONSE: "${userAnswer}"
EVALUATION CONTEXT:
- Exercise Type: Visual comprehension
- Languages: ${this.languages.userLanguage} -> ${this.languages.targetLanguage}
- Question Type: ${this.currentQuestion.type}
- Question Difficulty: ${this.currentQuestion.difficulty}
- Focus Area: ${this.currentQuestion.focusArea}
- Question ${this.questionIndex + 1} of ${this.questions.length}
- Observation Time: ${this.viewingTime}s
EVALUATION CRITERIA:
- [answer]yes if the student demonstrates good visual observation and understanding
- [answer]no if the response shows poor observation or is unrelated to the image
- Focus on VISUAL COMPREHENSION and OBSERVATION SKILLS, not perfect language
- Accept different perspectives and interpretations if they show visual understanding
- Reward specific details, accurate observations, and thoughtful interpretation
- Consider the student's language learning level and cultural context
- For description questions: reward comprehensive and accurate descriptions
- For detail questions: reward specific and accurate observations
- For interpretation questions: reward thoughtful analysis and context understanding
[explanation] should provide:
1. What the student observed correctly from the image
2. What important visual elements they might have missed
3. How well their interpretation matches the image content
4. Encouragement and specific suggestions for better visual observation
5. Tips for developing visual comprehension skills
6. Recognition of cultural or contextual insights if present
Format: [answer]yes/no [explanation]your comprehensive educational feedback here`;
}
/**
* Parse structured AI response for image comprehension
* @private
*/
_parseStructuredResponse(aiResponse) {
try {
let responseText = '';
// Extract text from AI response
if (typeof aiResponse === 'string') {
responseText = aiResponse;
} else if (aiResponse.content) {
responseText = aiResponse.content;
} else if (aiResponse.text) {
responseText = aiResponse.text;
} else {
responseText = JSON.stringify(aiResponse);
}
console.log('🔍 Parsing AI image comprehension response:', responseText.substring(0, 150) + '...');
// Extract [answer] - case insensitive
const answerMatch = responseText.match(/\[answer\](yes|no)/i);
if (!answerMatch) {
throw new Error('AI response missing [answer] format');
}
// Extract [explanation] - multiline support
const explanationMatch = responseText.match(/\[explanation\](.+)/s);
if (!explanationMatch) {
throw new Error('AI response missing [explanation] format');
}
const isCorrect = answerMatch[1].toLowerCase() === 'yes';
const explanation = explanationMatch[1].trim();
// Standard scores for image comprehension
const result = {
score: isCorrect ? 88 : 58, // Good scores for visual comprehension
correct: isCorrect,
feedback: explanation,
answer: answerMatch[1].toLowerCase(),
explanation: explanation,
timestamp: new Date().toISOString(),
provider: this.config.requiredProvider,
model: this.config.model,
cached: false,
formatValid: true,
imageComprehension: true,
visionAnalysis: true
};
console.log(`✅ AI image comprehension parsed: ${result.answer} - Score: ${result.score}`);
return result;
} catch (error) {
console.error('❌ Failed to parse AI image comprehension response:', error);
console.error('Raw response:', aiResponse);
throw new Error(`AI response format invalid: ${error.message}`);
}
}
/**
* Perform basic image validation when AI is unavailable
* @private
*/
_performBasicImageValidation(userAnswer) {
console.log('🔍 Performing basic image validation (AI unavailable)');
const answerLength = userAnswer.trim().length;
const hasKeywords = this.currentQuestion.keywords?.some(keyword =>
userAnswer.toLowerCase().includes(keyword.toLowerCase())
);
// Basic scoring based on answer length and keyword presence
let score = 30; // Lower base score for images (much harder without AI vision)
if (answerLength > 20) score += 15; // Substantial answer
if (answerLength > 50) score += 10; // Detailed answer
if (hasKeywords) score += 20; // Contains relevant keywords
if (answerLength > 100) score += 10; // Very detailed
if (this.viewingTime >= this.config.minViewTime) score += 10; // Good observation time
const isCorrect = score >= 60;
return {
score: Math.min(score, 100),
correct: isCorrect,
feedback: isCorrect
? "Good visual observation! Your description shows attention to detail and understanding of the image."
: "Try to observe the image more carefully and include more specific details about what you see. Look for objects, people, actions, colors, and settings.",
timestamp: new Date().toISOString(),
provider: 'basic_image_analysis',
model: 'keyword_length_analysis',
cached: false,
mockGenerated: true,
imageComprehension: true,
limitedAnalysis: true
};
}
/**
* Render the image exercise interface
* @private
*/
async _renderImageExercise() {
if (!this.container || !this.currentImage) return;
const imageTitle = this.currentImage.title || 'Visual Exercise';
const imageDescription = this.currentImage.description || '';
const imageUrl = this.currentImage.url || this.currentImage.src || '';
this.container.innerHTML = `
<div class="image-exercise">
<div class="exercise-header">
<h2>🖼️ Visual Comprehension</h2>
<div class="image-info">
<span class="image-meta">
${this.questions?.length || 0} questions
${!this.aiAvailable ? ' • ⚠️ Limited analysis mode' : ' • 🧠 AI vision analysis'}
</span>
</div>
</div>
<div class="image-content">
<div class="image-viewer-section" id="image-viewer-section">
<div class="image-viewer-card">
<div class="viewer-header">
<h3>${imageTitle}</h3>
<div class="viewer-stats">
<span class="view-time">Viewing: <span id="view-timer">0s</span></span>
${this.config.allowZoom ? '<button id="zoom-btn" class="btn btn-outline btn-sm">🔍 Zoom</button>' : ''}
</div>
</div>
<div class="image-container" id="image-container">
${imageUrl ? `
<img
id="main-image"
src="${imageUrl}"
alt="${imageTitle}"
class="exercise-image"
onload="this.closest('.image-exercise').querySelector('#image-viewer-section')._handleImageLoad()"
/>
` : `
<div class="image-placeholder">
🖼️ Image placeholder (${imageTitle})
${imageDescription ? `<p class="placeholder-desc">${imageDescription}</p>` : ''}
</div>
`}
<div class="image-overlay" id="image-overlay" style="display: none;">
<div class="overlay-content">
<button id="close-overlay" class="btn btn-outline">✕ Close</button>
</div>
</div>
</div>
${imageDescription ? `
<div class="image-description">
<h4>📝 Context</h4>
<p>${imageDescription}</p>
</div>
` : ''}
<div class="viewing-actions">
<button id="start-questions-btn" class="btn btn-success" style="display: none;">
<span class="btn-icon">❓</span>
<span class="btn-text">Start Questions</span>
</button>
<button id="observe-more-btn" class="btn btn-primary">
<span class="btn-icon">👁️</span>
<span class="btn-text">Observe Image</span>
</button>
</div>
</div>
</div>
<div class="questions-section" id="questions-section" style="display: none;">
<div class="question-card">
<div class="question-progress">
<div class="progress-indicator">
<span id="question-counter">Question 1 of ${this.questions?.length || 0}</span>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
</div>
</div>
</div>
<div class="question-content" id="question-content">
<!-- Question content will be populated dynamically -->
</div>
<div class="answer-input-section">
<label for="answer-input">Your Answer:</label>
<textarea
id="answer-input"
placeholder="Describe what you observe in the image..."
rows="5"
autocomplete="off"
></textarea>
</div>
<div class="question-controls">
<button id="look-again-btn" class="btn btn-outline">
<span class="btn-icon">👁️</span>
<span class="btn-text">Look at Image Again</span>
</button>
<button id="validate-answer-btn" class="btn btn-primary" disabled>
<span class="btn-icon">${this.aiAvailable ? '🧠' : '🔍'}</span>
<span class="btn-text">${this.aiAvailable ? 'Validate with AI Vision' : 'Validate Answer'}</span>
</button>
<div id="validation-status" class="validation-status"></div>
</div>
</div>
</div>
<div class="explanation-panel" id="explanation-panel" style="display: none;">
<div class="panel-header">
<h3>${this.aiAvailable ? '🤖 AI Vision Analysis' : '🔍 Analysis'}</h3>
<span class="analysis-model">${this.aiAvailable ? this.config.model + ' (Vision)' : 'Basic Analysis'}</span>
</div>
<div class="explanation-content" id="explanation-content">
<!-- Analysis will appear here -->
</div>
<div class="panel-actions">
<button id="next-question-btn" class="btn btn-primary" style="display: none;">
Next Question
</button>
<button id="retry-answer-btn" class="btn btn-secondary" style="display: none;">
Try Again
</button>
<button id="finish-image-btn" class="btn btn-success" style="display: none;">
Complete Image Exercise
</button>
</div>
</div>
<div class="image-results" id="image-results" style="display: none;">
<!-- Final results will be shown here -->
</div>
</div>
</div>
`;
// Add CSS styles
this._addStyles();
// Add event listeners
this._setupEventListeners();
// Start observation timer after a brief delay
setTimeout(() => {
this._startViewTimer();
const observeBtn = document.getElementById('observe-more-btn');
if (observeBtn) observeBtn.style.display = 'none';
const startQuestionsBtn = document.getElementById('start-questions-btn');
if (startQuestionsBtn) startQuestionsBtn.style.display = 'inline-block';
}, 2000);
}
/**
* Setup event listeners for image exercise
* @private
*/
_setupEventListeners() {
const startQuestionsBtn = document.getElementById('start-questions-btn');
const observeMoreBtn = document.getElementById('observe-more-btn');
const lookAgainBtn = document.getElementById('look-again-btn');
const zoomBtn = document.getElementById('zoom-btn');
const closeOverlay = document.getElementById('close-overlay');
const answerInput = document.getElementById('answer-input');
const validateBtn = document.getElementById('validate-answer-btn');
const retryBtn = document.getElementById('retry-answer-btn');
const nextBtn = document.getElementById('next-question-btn');
const finishBtn = document.getElementById('finish-image-btn');
// Navigation buttons
if (startQuestionsBtn) {
startQuestionsBtn.onclick = () => this._startQuestions();
}
if (observeMoreBtn) {
observeMoreBtn.onclick = () => this._continueObservation();
}
if (lookAgainBtn) {
lookAgainBtn.onclick = () => this._showImageViewing();
}
// Image interaction
if (zoomBtn) {
zoomBtn.onclick = this._handleImageZoom;
}
if (closeOverlay) {
closeOverlay.onclick = () => {
const overlay = document.getElementById('image-overlay');
if (overlay) overlay.style.display = 'none';
};
}
// Handle image load
const mainImage = document.getElementById('main-image');
if (mainImage) {
mainImage.addEventListener('load', this._handleImageLoad);
}
// Answer input validation
if (answerInput) {
answerInput.addEventListener('input', () => {
const hasText = answerInput.value.trim().length > 0;
if (validateBtn) {
validateBtn.disabled = !hasText || this.validationInProgress;
}
});
// Allow Ctrl+Enter to validate
answerInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !validateBtn.disabled) {
e.preventDefault();
this._handleUserInput();
}
});
}
// Validate button
if (validateBtn) {
validateBtn.onclick = this._handleUserInput;
}
// Action buttons
if (retryBtn) retryBtn.onclick = this._handleRetry;
if (nextBtn) nextBtn.onclick = this._handleNextQuestion;
if (finishBtn) finishBtn.onclick = () => this._completeImageExercise();
}
/**
* Handle image load event
* @private
*/
_handleImageLoad() {
this.imageLoaded = true;
console.log('🖼️ Image loaded successfully');
}
/**
* Handle image zoom functionality
* @private
*/
_handleImageZoom() {
const overlay = document.getElementById('image-overlay');
const mainImage = document.getElementById('main-image');
if (!overlay || !mainImage) return;
const overlayContent = overlay.querySelector('.overlay-content');
const zoomedImage = mainImage.cloneNode(true);
zoomedImage.className = 'zoomed-image';
zoomedImage.id = 'zoomed-image';
// Clear previous content and add zoomed image
overlayContent.innerHTML = `
<button id="close-overlay" class="btn btn-outline close-btn">✕ Close</button>
<div class="zoomed-container"></div>
`;
overlayContent.querySelector('.zoomed-container').appendChild(zoomedImage);
overlay.style.display = 'flex';
// Re-attach close event
overlayContent.querySelector('#close-overlay').onclick = () => {
overlay.style.display = 'none';
};
}
/**
* Start view timer
* @private
*/
_startViewTimer() {
if (this.startViewTime) return; // Already started
this.startViewTime = Date.now();
const timer = document.getElementById('view-timer');
const updateTimer = () => {
if (this.startViewTime) {
this.viewingTime = Math.floor((Date.now() - this.startViewTime) / 1000);
if (timer) timer.textContent = `${this.viewingTime}s`;
setTimeout(updateTimer, 1000);
}
};
updateTimer();
}
/**
* Stop view timer
* @private
*/
_stopViewTimer() {
if (this.startViewTime) {
this.viewingTime = Math.floor((Date.now() - this.startViewTime) / 1000);
this.startViewTime = null;
}
}
/**
* Continue observation phase
* @private
*/
_continueObservation() {
// Just hide the button and show start questions
const observeBtn = document.getElementById('observe-more-btn');
const startQuestionsBtn = document.getElementById('start-questions-btn');
if (observeBtn) observeBtn.style.display = 'none';
if (startQuestionsBtn) startQuestionsBtn.style.display = 'inline-block';
}
/**
* Show image viewing phase
* @private
*/
_showImageViewing() {
const imageSection = document.getElementById('image-viewer-section');
const questionsSection = document.getElementById('questions-section');
if (imageSection) imageSection.style.display = 'block';
if (questionsSection) questionsSection.style.display = 'none';
// Resume timer if needed
if (!this.startViewTime) {
this._startViewTimer();
}
}
/**
* Start questions phase
* @private
*/
_startQuestions() {
const imageSection = document.getElementById('image-viewer-section');
const questionsSection = document.getElementById('questions-section');
// Keep image visible but minimized if configured
if (imageSection) {
imageSection.style.display = this.config.showImageDuringQuestions ? 'block' : 'none';
if (this.config.showImageDuringQuestions) {
imageSection.classList.add('minimized');
}
}
if (questionsSection) questionsSection.style.display = 'block';
// Stop continuous timer - we'll track total time
this._stopViewTimer();
this._presentCurrentQuestion();
}
/**
* Present current question
* @private
*/
_presentCurrentQuestion() {
if (this.questionIndex >= this.questions.length) {
this._showImageResults();
return;
}
this.currentQuestion = this.questions[this.questionIndex];
const questionContent = document.getElementById('question-content');
const questionCounter = document.getElementById('question-counter');
const progressFill = document.getElementById('progress-fill');
if (!questionContent || !this.currentQuestion) return;
// Update progress
const progressPercentage = ((this.questionIndex + 1) / this.questions.length) * 100;
if (progressFill) progressFill.style.width = `${progressPercentage}%`;
if (questionCounter) questionCounter.textContent = `Question ${this.questionIndex + 1} of ${this.questions.length}`;
// Display question
questionContent.innerHTML = `
<div class="question-display">
<div class="question-text">${this.currentQuestion.question}</div>
<div class="question-meta">
<span class="question-type">${this.currentQuestion.type}</span>
<span class="question-difficulty difficulty-${this.currentQuestion.difficulty}">
${this.currentQuestion.difficulty}
</span>
<span class="focus-area focus-${this.currentQuestion.focusArea}">
${this.currentQuestion.focusArea}
</span>
</div>
</div>
`;
// Clear previous answer and focus
const answerInput = document.getElementById('answer-input');
if (answerInput) {
answerInput.value = '';
answerInput.focus();
}
// Hide explanation panel
const explanationPanel = document.getElementById('explanation-panel');
if (explanationPanel) explanationPanel.style.display = 'none';
}
/**
* Handle user input validation
* @private
*/
async _handleUserInput() {
const answerInput = document.getElementById('answer-input');
const validateBtn = document.getElementById('validate-answer-btn');
const statusDiv = document.getElementById('validation-status');
if (!answerInput || !validateBtn || !statusDiv) return;
const userAnswer = answerInput.value.trim();
if (!userAnswer) return;
try {
// Set validation in progress
this.validationInProgress = true;
validateBtn.disabled = true;
answerInput.disabled = true;
// Show loading status
statusDiv.innerHTML = `
<div class="status-loading">
<div class="loading-spinner">🔍</div>
<span>${this.aiAvailable ? 'AI is analyzing the image and your response...' : 'Analyzing your answer...'}</span>
</div>
`;
// Call validation
const result = await this.validate(userAnswer, {});
this.lastValidationResult = result;
// Store result
this.questionResults[this.questionIndex] = {
question: this.currentQuestion.question,
userAnswer: userAnswer,
correct: result.correct,
score: result.score,
feedback: result.feedback,
timestamp: new Date().toISOString(),
observationTime: this.viewingTime
};
// Show result
this._showValidationResult(result);
// Update status
statusDiv.innerHTML = `
<div class="status-complete">
<span class="result-icon">${result.correct ? '✅' : '👁️'}</span>
<span>Analysis complete</span>
</div>
`;
} catch (error) {
console.error('❌ Image validation error:', error);
// Show error status
statusDiv.innerHTML = `
<div class="status-error">
<span class="error-icon">⚠️</span>
<span>Error: ${error.message}</span>
</div>
`;
// Re-enable input for retry
this._enableRetry();
}
}
/**
* Show validation result in explanation panel
* @private
*/
_showValidationResult(result) {
const explanationPanel = document.getElementById('explanation-panel');
const explanationContent = document.getElementById('explanation-content');
const nextBtn = document.getElementById('next-question-btn');
const retryBtn = document.getElementById('retry-answer-btn');
const finishBtn = document.getElementById('finish-image-btn');
if (!explanationPanel || !explanationContent) return;
// Show panel
explanationPanel.style.display = 'block';
// Set explanation content
explanationContent.innerHTML = `
<div class="explanation-result ${result.correct ? 'correct' : 'needs-improvement'}">
<div class="result-header">
<span class="result-indicator">
${result.correct ? '✅ Excellent Observation!' : '👁️ Keep Looking!'}
</span>
<span class="comprehension-score">Score: ${result.score}/100</span>
</div>
<div class="explanation-text">${result.explanation || result.feedback}</div>
${result.imageComprehension ? '<div class="analysis-note">🖼️ This analysis evaluates your visual observation skills and image interpretation abilities.</div>' : ''}
${result.visionAnalysis ? '<div class="vision-note">🤖 AI Vision analyzed both the image content and your response for comprehensive feedback.</div>' : ''}
${this.viewingTime >= this.config.minViewTime ? '<div class="time-bonus">⏱️ Observation time bonus applied for thorough viewing!</div>' : ''}
</div>
`;
// Show appropriate buttons
const isLastQuestion = this.questionIndex >= this.questions.length - 1;
if (nextBtn) nextBtn.style.display = isLastQuestion ? 'none' : 'inline-block';
if (finishBtn) finishBtn.style.display = isLastQuestion ? 'inline-block' : 'none';
if (retryBtn) retryBtn.style.display = result.correct ? 'none' : 'inline-block';
// Scroll to explanation
explanationPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/**
* Handle next question
* @private
*/
_handleNextQuestion() {
this.questionIndex++;
this._presentCurrentQuestion();
}
/**
* Handle retry
* @private
*/
_handleRetry() {
// Hide explanation and enable new input
const explanationPanel = document.getElementById('explanation-panel');
const statusDiv = document.getElementById('validation-status');
if (explanationPanel) explanationPanel.style.display = 'none';
if (statusDiv) statusDiv.innerHTML = '';
this._enableRetry();
}
/**
* Enable retry after error
* @private
*/
_enableRetry() {
this.validationInProgress = false;
const answerInput = document.getElementById('answer-input');
const validateBtn = document.getElementById('validate-answer-btn');
if (answerInput) {
answerInput.disabled = false;
answerInput.focus();
}
if (validateBtn) {
validateBtn.disabled = false;
}
}
/**
* Show final image results
* @private
*/
_showImageResults() {
const resultsContainer = document.getElementById('image-results');
const questionsSection = document.getElementById('questions-section');
if (!resultsContainer) return;
const correctCount = this.questionResults.filter(result => result.correct).length;
const totalCount = this.questionResults.length;
const comprehensionRate = totalCount > 0 ? Math.round((correctCount / totalCount) * 100) : 0;
const avgObservationTime = this.questionResults.length > 0 ?
Math.round(this.questionResults.reduce((sum, r) => sum + (r.observationTime || 0), 0) / this.questionResults.length) : 0;
let resultClass = 'results-poor';
if (comprehensionRate >= 80) resultClass = 'results-excellent';
else if (comprehensionRate >= 60) resultClass = 'results-good';
const resultsHTML = `
<div class="image-results-content ${resultClass}">
<h3>📊 Visual Comprehension Results</h3>
<div class="results-summary">
<div class="comprehension-display">
<span class="comprehension-rate">${comprehensionRate}%</span>
<span class="comprehension-label">Comprehension Rate</span>
</div>
<div class="questions-summary">
${correctCount} / ${totalCount} visual observations understood well
</div>
<div class="observation-stats">
<div class="stat-item">
<span class="stat-value">${this.viewingTime}s</span>
<span class="stat-label">Total Viewing Time</span>
</div>
<div class="stat-item">
<span class="stat-value">${this.viewingTime >= this.config.minViewTime ? '👍' : '⚡'}</span>
<span class="stat-label">${this.viewingTime >= this.config.minViewTime ? 'Thorough observation' : 'Quick observation'}</span>
</div>
${this.aiAvailable ? `
<div class="stat-item">
<span class="stat-value">🤖</span>
<span class="stat-label">AI Vision Analysis</span>
</div>
` : ''}
</div>
</div>
<div class="question-breakdown">
${this.questionResults.map((result, index) => `
<div class="question-result ${result.correct ? 'understood' : 'needs-work'}">
<div class="question-summary">
<span class="question-num">Q${index + 1}</span>
<span class="comprehension-icon">${result.correct ? '✅' : '👁️'}</span>
<span class="score">Score: ${result.score}/100</span>
<span class="focus-area">${this.questions[index]?.focusArea || 'general'}</span>
</div>
</div>
`).join('')}
</div>
<div class="results-actions">
<button id="complete-image-btn" class="btn btn-primary">Continue to Next Exercise</button>
<button id="review-image-btn" class="btn btn-outline">Review Image Again</button>
</div>
</div>
`;
resultsContainer.innerHTML = resultsHTML;
resultsContainer.style.display = 'block';
// Hide other sections
if (questionsSection) questionsSection.style.display = 'none';
// Add action listeners
document.getElementById('complete-image-btn').onclick = () => this._completeImageExercise();
document.getElementById('review-image-btn').onclick = () => this._reviewImage();
}
/**
* Complete image exercise
* @private
*/
_completeImageExercise() {
// Mark image as comprehended if performance is good
const correctCount = this.questionResults.filter(result => result.correct).length;
const comprehensionRate = correctCount / this.questionResults.length;
if (comprehensionRate >= 0.6) { // 60% comprehension threshold
const imageId = this.currentImage.id || this.currentImage.title || 'image_exercise';
const metadata = {
comprehensionRate: Math.round(comprehensionRate * 100),
questionsAnswered: this.questionResults.length,
correctAnswers: correctCount,
observationTime: this.viewingTime,
sessionId: this.orchestrator?.sessionId || 'unknown',
moduleType: 'image',
aiAnalysisUsed: this.aiAvailable,
visionAnalysisUsed: this.aiAvailable,
observationQuality: this.viewingTime >= this.config.minViewTime ? 'thorough' : 'quick'
};
this.prerequisiteEngine.markPhraseMastered(imageId, metadata);
// Also save to persistent storage
if (window.addMasteredItem && this.orchestrator?.bookId && this.orchestrator?.chapterId) {
window.addMasteredItem(
this.orchestrator.bookId,
this.orchestrator.chapterId,
'images',
imageId,
metadata
);
}
}
// Emit completion event
this.orchestrator._eventBus.emit('drs:exerciseCompleted', {
moduleType: 'image',
results: this.questionResults,
progress: this.getProgress()
}, 'ImageModule');
}
/**
* Review image again
* @private
*/
_reviewImage() {
this.questionIndex = 0;
this.questionResults = [];
this.viewingTime = 0;
this.startViewTime = null;
this._showImageViewing();
const resultsContainer = document.getElementById('image-results');
if (resultsContainer) resultsContainer.style.display = 'none';
// Reset UI elements
const viewTimer = document.getElementById('view-timer');
if (viewTimer) viewTimer.textContent = '0s';
const startQuestionsBtn = document.getElementById('start-questions-btn');
if (startQuestionsBtn) startQuestionsBtn.style.display = 'none';
const observeBtn = document.getElementById('observe-more-btn');
if (observeBtn) observeBtn.style.display = 'inline-block';
}
/**
* Add CSS styles for image exercise
* @private
*/
_addStyles() {
if (document.getElementById('image-module-styles')) return;
const styles = document.createElement('style');
styles.id = 'image-module-styles';
styles.textContent = `
.image-exercise {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
display: grid;
gap: 20px;
}
.exercise-header {
text-align: center;
margin-bottom: 20px;
}
.image-info {
margin-top: 10px;
}
.image-meta {
color: #666;
font-size: 0.9em;
}
.image-content {
display: grid;
gap: 20px;
}
.image-viewer-card, .question-card {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.image-viewer-section.minimized .image-viewer-card {
padding: 20px;
background: #f8f9fa;
border: 2px solid #e9ecef;
}
.viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #eee;
}
.viewer-header h3 {
margin: 0;
color: #333;
font-size: 1.5em;
}
.viewer-stats {
display: flex;
gap: 15px;
align-items: center;
font-size: 0.9em;
color: #666;
}
.view-time {
font-weight: 600;
color: #17a2b8;
}
.image-container {
position: relative;
margin-bottom: 25px;
text-align: center;
}
.exercise-image {
max-width: 100%;
max-height: 400px;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
cursor: pointer;
transition: transform 0.3s ease;
}
.exercise-image:hover {
transform: scale(1.02);
}
.image-placeholder {
text-align: center;
padding: 60px 40px;
background: linear-gradient(135deg, #f8f9ff, #e8f4fd);
border-radius: 10px;
color: #666;
font-size: 1.2em;
border: 2px dashed #ddd;
margin-bottom: 20px;
}
.placeholder-desc {
margin-top: 15px;
font-size: 0.9em;
color: #888;
font-style: italic;
}
.image-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.9);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.overlay-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
}
.close-btn {
position: absolute;
top: -50px;
right: 0;
z-index: 1001;
background: rgba(255,255,255,0.9);
border: 2px solid #ddd;
}
.zoomed-container {
max-width: 100%;
max-height: 100%;
overflow: auto;
}
.zoomed-image {
max-width: none;
max-height: 80vh;
border-radius: 10px;
}
.image-description {
margin-top: 20px;
padding: 20px;
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
border-radius: 10px;
border-left: 4px solid #6c757d;
}
.image-description h4 {
margin: 0 0 10px 0;
color: #333;
}
.image-description p {
margin: 0;
line-height: 1.6;
color: #555;
}
.viewing-actions {
text-align: center;
margin-top: 20px;
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
}
.question-progress {
margin-bottom: 25px;
}
.progress-indicator {
text-align: center;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: #e0e0e0;
border-radius: 4px;
margin-top: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #fd79a8, #e84393);
transition: width 0.5s ease;
}
.question-display {
margin-bottom: 25px;
padding: 20px;
background: linear-gradient(135deg, #f8f9ff, #e8f4fd);
border-radius: 10px;
border-left: 4px solid #fd79a8;
}
.question-text {
font-size: 1.3em;
font-weight: 600;
color: #333;
margin-bottom: 15px;
line-height: 1.4;
}
.question-meta {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.question-type {
background: #e3f2fd;
color: #1976d2;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.focus-general {
background: #f3e5f5;
color: #7b1fa2;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.focus-description {
background: #e8f5e8;
color: #2e7d32;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.focus-details {
background: #fff3e0;
color: #f57c00;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.focus-interpretation {
background: #ffebee;
color: #c62828;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.difficulty-easy {
background: #e8f5e8;
color: #2e7d32;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.difficulty-medium {
background: #fff3e0;
color: #f57c00;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.difficulty-hard {
background: #ffebee;
color: #c62828;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.answer-input-section {
margin-bottom: 20px;
}
.answer-input-section label {
display: block;
margin-bottom: 10px;
font-weight: 600;
color: #555;
}
.answer-input-section textarea {
width: 100%;
padding: 15px;
font-size: 1.05em;
border: 2px solid #ddd;
border-radius: 8px;
resize: vertical;
min-height: 120px;
box-sizing: border-box;
transition: border-color 0.3s ease;
font-family: inherit;
line-height: 1.5;
}
.answer-input-section textarea:focus {
outline: none;
border-color: #fd79a8;
}
.question-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
.question-controls > div:first-child {
display: flex;
gap: 15px;
flex-wrap: wrap;
justify-content: center;
}
.validation-status {
min-height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.status-loading, .status-complete, .status-error {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-radius: 25px;
font-weight: 500;
}
.status-loading {
background: #e3f2fd;
color: #1976d2;
}
.status-complete {
background: #e8f5e8;
color: #2e7d32;
}
.status-error {
background: #ffebee;
color: #c62828;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
.explanation-panel {
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
border-left: 4px solid #fd79a8;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.panel-header h3 {
margin: 0;
color: #333;
}
.analysis-model {
font-size: 0.9em;
color: #666;
background: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
}
.explanation-result {
padding: 20px;
border-radius: 8px;
margin-bottom: 15px;
}
.explanation-result.correct {
border-left: 4px solid #4caf50;
background: linear-gradient(135deg, #f1f8e9, #e8f5e8);
}
.explanation-result.needs-improvement {
border-left: 4px solid #ff9800;
background: linear-gradient(135deg, #fff8e1, #fff3e0);
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
font-weight: 600;
}
.result-indicator {
font-size: 1.1em;
}
.comprehension-score {
font-size: 0.9em;
color: #666;
}
.explanation-text {
line-height: 1.6;
color: #333;
font-size: 1.05em;
margin-bottom: 10px;
}
.analysis-note, .vision-note, .time-bonus {
font-size: 0.9em;
color: #666;
font-style: italic;
padding-top: 10px;
border-top: 1px solid #eee;
margin-top: 10px;
}
.vision-note {
color: #1976d2;
}
.time-bonus {
color: #2e7d32;
}
.panel-actions {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
}
.image-results-content {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
text-align: center;
}
.results-summary {
margin-bottom: 30px;
}
.comprehension-display {
margin-bottom: 15px;
}
.comprehension-rate {
font-size: 3em;
font-weight: bold;
display: block;
}
.results-excellent .comprehension-rate { color: #4caf50; }
.results-good .comprehension-rate { color: #ff9800; }
.results-poor .comprehension-rate { color: #f44336; }
.comprehension-label {
font-size: 1.2em;
color: #666;
}
.questions-summary {
font-size: 1.1em;
color: #555;
margin-bottom: 20px;
}
.observation-stats {
display: flex;
justify-content: center;
gap: 40px;
margin-top: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
}
.stat-item {
text-align: center;
}
.stat-value {
display: block;
font-size: 1.5em;
font-weight: bold;
color: #333;
}
.stat-label {
font-size: 0.9em;
color: #666;
}
.question-breakdown {
display: grid;
gap: 10px;
margin-bottom: 30px;
}
.question-result {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
border-radius: 8px;
background: #f8f9fa;
}
.question-result.understood {
background: linear-gradient(135deg, #e8f5e8, #f1f8e9);
border-left: 4px solid #4caf50;
}
.question-result.needs-work {
background: linear-gradient(135deg, #fff8e1, #fff3e0);
border-left: 4px solid #ff9800;
}
.question-summary {
display: flex;
align-items: center;
gap: 15px;
}
.question-num {
font-weight: bold;
color: #333;
}
.focus-area {
font-size: 0.85em;
color: #666;
background: rgba(255,255,255,0.7);
padding: 4px 8px;
border-radius: 10px;
}
.results-actions {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1em;
font-weight: 500;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: linear-gradient(135deg, #fd79a8, #e84393);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(253, 121, 168, 0.3);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #5a6268;
}
.btn-outline {
background: transparent;
border: 2px solid #fd79a8;
color: #fd79a8;
}
.btn-outline:hover:not(:disabled) {
background: #fd79a8;
color: white;
}
.btn-success {
background: linear-gradient(135deg, #00b894, #00cec9);
color: white;
}
.btn-success:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 184, 148, 0.3);
}
.btn-sm {
padding: 8px 16px;
font-size: 0.9em;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.image-exercise {
padding: 15px;
}
.image-viewer-card, .question-card, .explanation-panel {
padding: 20px;
}
.exercise-image {
max-height: 300px;
}
.question-text {
font-size: 1.2em;
}
.comprehension-rate {
font-size: 2.5em;
}
.panel-actions, .results-actions {
flex-direction: column;
}
.viewer-header {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
.observation-stats {
flex-direction: column;
gap: 20px;
}
.question-controls > div:first-child {
flex-direction: column;
align-items: center;
}
.viewing-actions {
flex-direction: column;
}
}
`;
document.head.appendChild(styles);
}
}
export default ImageModule;