Spaces:
Sleeping
Sleeping
// Core game logic for minimal cloze reader | |
import bookDataService from './bookDataService.js'; | |
import { AIService } from './aiService.js'; | |
import ChatService from './conversationManager.js'; | |
const aiService = new AIService(); | |
class ClozeGame { | |
constructor() { | |
this.currentBook = null; | |
this.originalText = ''; | |
this.clozeText = ''; | |
this.blanks = []; | |
this.userAnswers = []; | |
this.score = 0; | |
this.currentRound = 1; | |
this.currentLevel = 1; // Track difficulty level separately from round | |
this.contextualization = ''; | |
this.hints = []; | |
this.chatService = new ChatService(aiService); | |
this.lastResults = null; // Store results for answer revelation | |
// Two-passage system properties | |
this.currentBooks = []; // Array of two books per round | |
this.passages = []; // Array of two passages per round | |
this.currentPassageIndex = 0; // 0 for first passage, 1 for second | |
} | |
async initialize() { | |
try { | |
await bookDataService.loadDataset(); | |
console.log('Game initialized successfully'); | |
} catch (error) { | |
console.error('Failed to initialize game:', error); | |
throw error; | |
} | |
} | |
async startNewRound() { | |
try { | |
// Get two random books for this round | |
const book1 = await bookDataService.getRandomBook(); | |
const book2 = await bookDataService.getRandomBook(); | |
// Extract passages from both books | |
const passage1 = this.extractCoherentPassage(book1.text); | |
const passage2 = this.extractCoherentPassage(book2.text); | |
// Store both books and passages | |
this.currentBooks = [book1, book2]; | |
this.passages = [passage1.trim(), passage2.trim()]; | |
this.currentPassageIndex = 0; | |
// Calculate blanks per passage based on level | |
const blanksPerPassage = Math.min(this.currentLevel + 2, 5); | |
// Process both passages in a single API call | |
try { | |
const batchResult = await aiService.processBothPassages( | |
passage1, book1, passage2, book2, blanksPerPassage | |
); | |
// Store the preprocessed data for both passages | |
this.preprocessedData = batchResult; | |
// Set up first passage using preprocessed data | |
this.currentBook = book1; | |
this.originalText = this.passages[0]; | |
await this.createClozeTextFromPreprocessed(0); | |
this.contextualization = this.preprocessedData.passage1.context; | |
} catch (error) { | |
console.warn('Batch processing failed, falling back to sequential:', error); | |
// Fallback to sequential processing | |
this.currentBook = book1; | |
this.originalText = this.passages[0]; | |
await this.createClozeText(); | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
await this.generateContextualization(); | |
} | |
return { | |
title: this.currentBook.title, | |
author: this.currentBook.author, | |
text: this.clozeText, | |
blanks: this.blanks, | |
contextualization: this.contextualization, | |
hints: this.hints, | |
passageNumber: 1, | |
totalPassages: 2 | |
}; | |
} catch (error) { | |
console.error('Error starting new round:', error); | |
throw error; | |
} | |
} | |
extractCoherentPassage(text) { | |
// Simple elegant solution: start from middle third of book where actual content is | |
const textLength = text.length; | |
const startFromMiddle = Math.floor(textLength * 0.3); // Skip first 30% | |
const endAtThreeQuarters = Math.floor(textLength * 0.8); // Stop before last 20% | |
let attempts = 0; | |
let passage = ''; | |
while (attempts < 5) { | |
// Random position in the middle section | |
const availableLength = endAtThreeQuarters - startFromMiddle; | |
const randomOffset = Math.floor(Math.random() * Math.max(0, availableLength - 1000)); | |
const startIndex = startFromMiddle + randomOffset; | |
// Extract longer initial passage for better sentence completion | |
passage = text.substring(startIndex, startIndex + 1000); | |
// Clean up start - find first complete sentence that starts with capital letter | |
const firstSentenceMatch = passage.match(/[.!?]\s+([A-Z][^.!?]*)/); | |
if (firstSentenceMatch && firstSentenceMatch.index < 200) { | |
// Start from the capital letter after punctuation | |
passage = passage.substring(firstSentenceMatch.index + firstSentenceMatch[0].length - firstSentenceMatch[1].length); | |
} else { | |
// If no good sentence break found, find first capital letter | |
const firstCapitalMatch = passage.match(/[A-Z][^.!?]*/); | |
if (firstCapitalMatch) { | |
passage = passage.substring(firstCapitalMatch.index); | |
} | |
} | |
// Clean up end - ensure we end at a complete sentence | |
const sentences = passage.split(/(?<=[.!?])\s+/); | |
if (sentences.length > 1) { | |
// Remove the last sentence if it might be incomplete | |
sentences.pop(); | |
passage = sentences.join(' '); | |
} | |
// Quality check: reject passages with excessive caps, numbers, or special formatting | |
const words = passage.split(/\s+/); | |
const capsCount = words.filter(w => w.length > 1 && w === w.toUpperCase()).length; | |
const numbersCount = words.filter(w => /\d/.test(w)).length; | |
const totalWords = words.length; | |
// Skip if more than 10% caps or 5% numbers | |
if (capsCount / totalWords > 0.1 || numbersCount / totalWords > 0.05) { | |
console.log(`Skipping passage with ${capsCount} caps and ${numbersCount} numbers out of ${totalWords} words`); | |
attempts++; | |
continue; | |
} | |
// Check for other quality issues | |
if (passage.includes('CHAPTER') || passage.includes('Section') || | |
passage.match(/\b(Fig\.|Table|Illustration)\b/)) { | |
attempts++; | |
continue; | |
} | |
// Good passage found | |
break; | |
} | |
// Ensure minimum length - if too short, return what we have rather than infinite recursion | |
if (passage.length < 400) { | |
console.warn('Short passage extracted, using fallback approach'); | |
// Try one more time with a simpler approach | |
const simpleStart = text.indexOf('. ') + 2; | |
if (simpleStart > 1 && simpleStart < text.length - 500) { | |
passage = text.substring(simpleStart, simpleStart + 600); | |
const lastPeriod = passage.lastIndexOf('.'); | |
if (lastPeriod > 200) { | |
passage = passage.substring(0, lastPeriod + 1); | |
} | |
} | |
} | |
return passage.trim(); | |
} | |
async createClozeTextFromPreprocessed(passageIndex) { | |
// Use preprocessed word selection from batch API call | |
const preprocessed = passageIndex === 0 ? this.preprocessedData.passage1 : this.preprocessedData.passage2; | |
const selectedWords = preprocessed.words; | |
// Split passage into words | |
const words = this.originalText.split(/(\s+)/); | |
const wordsOnly = words.filter(w => w.trim() !== ''); | |
// Find indices of selected words | |
const selectedIndices = []; | |
selectedWords.forEach(word => { | |
const index = wordsOnly.findIndex((w, idx) => | |
w.toLowerCase().includes(word.toLowerCase()) && !selectedIndices.includes(idx) | |
); | |
if (index !== -1) { | |
selectedIndices.push(index); | |
} | |
}); | |
// Create blanks | |
this.blanks = []; | |
this.hints = []; | |
const clozeWords = [...wordsOnly]; | |
selectedIndices.forEach((wordIndex, blankIndex) => { | |
const originalWord = wordsOnly[wordIndex]; | |
const cleanWord = originalWord.replace(/[^\w]/g, ''); | |
this.blanks.push({ | |
index: blankIndex, | |
originalWord: cleanWord, | |
wordIndex: wordIndex | |
}); | |
// Generate structural hint | |
const hint = this.currentLevel <= 2 | |
? `${cleanWord.length} letters, starts with "${cleanWord[0]}", ends with "${cleanWord[cleanWord.length - 1]}"` | |
: `${cleanWord.length} letters, starts with "${cleanWord[0]}"`; | |
this.hints.push({ index: blankIndex, hint }); | |
// Replace with placeholder | |
clozeWords[wordIndex] = `___BLANK_${blankIndex}___`; | |
}); | |
// Reconstruct text with original spacing | |
let reconstructed = ''; | |
let wordIndex = 0; | |
words.forEach(part => { | |
if (part.trim() === '') { | |
reconstructed += part; | |
} else { | |
reconstructed += clozeWords[wordIndex++]; | |
} | |
}); | |
this.clozeText = reconstructed; | |
this.userAnswers = new Array(this.blanks.length).fill(''); | |
} | |
async createClozeText() { | |
const words = this.originalText.split(' '); | |
// Progressive difficulty: levels 1-2 = 1 blank, levels 3-4 = 2 blanks, level 5+ = 3 blanks | |
let numberOfBlanks; | |
if (this.currentLevel <= 2) { | |
numberOfBlanks = 1; | |
} else if (this.currentLevel <= 4) { | |
numberOfBlanks = 2; | |
} else { | |
numberOfBlanks = 3; | |
} | |
// Update chat service with current level | |
this.chatService.setLevel(this.currentLevel); | |
// Always use AI for word selection with fallback | |
let significantWords; | |
try { | |
significantWords = await aiService.selectSignificantWords( | |
this.originalText, | |
numberOfBlanks | |
); | |
console.log('AI selected words:', significantWords); | |
} catch (error) { | |
console.warn('AI word selection failed, using manual fallback:', error); | |
significantWords = this.selectWordsManually(words, numberOfBlanks); | |
console.log('Manual selected words:', significantWords); | |
} | |
// Ensure we have valid words | |
if (!significantWords || significantWords.length === 0) { | |
console.warn('No words selected, using emergency fallback'); | |
significantWords = this.selectWordsManually(words, numberOfBlanks); | |
} | |
// Find word indices for selected significant words, distributed throughout passage | |
const selectedIndices = []; | |
const wordsLower = words.map(w => w.toLowerCase().replace(/[^\w]/g, '')); | |
// Create sections of the passage to ensure distribution | |
const passageSections = this.dividePassageIntoSections(words.length, numberOfBlanks); | |
significantWords.forEach((significantWord, index) => { | |
// Clean the significant word for matching | |
const cleanSignificant = significantWord.toLowerCase().replace(/[^\w]/g, ''); | |
// Look for the word within the appropriate section for better distribution | |
const sectionStart = passageSections[index] ? passageSections[index].start : 0; | |
const sectionEnd = passageSections[index] ? passageSections[index].end : words.length; | |
let wordIndex = -1; | |
// First try to find the word in the designated section (avoiding first 10 words) | |
for (let i = Math.max(10, sectionStart); i < sectionEnd; i++) { | |
if (wordsLower[i] === cleanSignificant && !selectedIndices.includes(i)) { | |
wordIndex = i; | |
break; | |
} | |
} | |
// If not found in section, look globally (but still avoid first 10 words) | |
if (wordIndex === -1) { | |
wordIndex = wordsLower.findIndex((word, idx) => | |
word === cleanSignificant && !selectedIndices.includes(idx) && idx >= 10 | |
); | |
} | |
if (wordIndex !== -1) { | |
selectedIndices.push(wordIndex); | |
} else { | |
console.warn(`Could not find word "${significantWord}" in passage`); | |
} | |
}); | |
// Log the matching results | |
console.log(`Found ${selectedIndices.length} of ${significantWords.length} words in passage`); | |
// If no words were matched, fall back to manual selection | |
if (selectedIndices.length === 0) { | |
console.warn('No AI words matched in passage, using manual selection'); | |
const manualWords = this.selectWordsManually(words, numberOfBlanks); | |
// Try to match manual words (avoiding first 10 words) | |
manualWords.forEach((manualWord, index) => { | |
const cleanManual = manualWord.toLowerCase().replace(/[^\w]/g, ''); | |
const wordIndex = wordsLower.findIndex((word, idx) => | |
word === cleanManual && !selectedIndices.includes(idx) && idx >= 10 | |
); | |
if (wordIndex !== -1) { | |
selectedIndices.push(wordIndex); | |
} | |
}); | |
console.log(`After manual fallback: ${selectedIndices.length} words found`); | |
} | |
// Sort indices for easier processing | |
selectedIndices.sort((a, b) => a - b); | |
// Final safety check - if still no words found, pick random content words (avoiding first 10) | |
if (selectedIndices.length === 0) { | |
console.error('Critical: No words could be selected, using emergency fallback'); | |
const contentWords = words.map((word, idx) => ({ word: word.toLowerCase().replace(/[^\w]/g, ''), idx })) | |
.filter(item => item.word.length > 3 && !['the', 'and', 'but', 'for', 'are', 'was'].includes(item.word) && item.idx >= 10) | |
.slice(0, numberOfBlanks); | |
selectedIndices.push(...contentWords.map(item => item.idx)); | |
console.log(`Emergency fallback selected ${selectedIndices.length} words`); | |
} | |
// Create blanks array and cloze text | |
this.blanks = []; | |
this.hints = []; | |
const clozeWords = [...words]; | |
for (let i = 0; i < selectedIndices.length; i++) { | |
const index = selectedIndices[i]; | |
const originalWord = words[index]; | |
const cleanWord = originalWord.replace(/[^\w]/g, ''); | |
const blankData = { | |
index: i, | |
originalWord: cleanWord, | |
wordIndex: index | |
}; | |
this.blanks.push(blankData); | |
// Initialize chat context for this word | |
const wordContext = { | |
originalWord: cleanWord, | |
sentence: this.originalText, | |
passage: this.originalText, | |
bookTitle: this.currentBook.title, | |
author: this.currentBook.author, | |
year: this.currentBook.year, | |
wordPosition: index, | |
difficulty: this.calculateWordDifficulty(cleanWord, index, words) | |
}; | |
this.chatService.initializeWordContext(`blank_${i}`, wordContext); | |
// Generate structural hint based on level | |
let structuralHint; | |
if (this.currentLevel <= 2) { | |
// Levels 1-2: show length, first letter, and last letter | |
structuralHint = `${cleanWord.length} letters, starts with "${cleanWord[0]}", ends with "${cleanWord[cleanWord.length - 1]}"`; | |
} else { | |
// Level 3+: show length and first letter only | |
structuralHint = `${cleanWord.length} letters, starts with "${cleanWord[0]}"`; | |
} | |
this.hints.push({ index: i, hint: structuralHint }); | |
// Replace word with input field placeholder | |
clozeWords[index] = `___BLANK_${i}___`; | |
} | |
this.clozeText = clozeWords.join(' '); | |
this.userAnswers = new Array(this.blanks.length).fill(''); | |
// Debug: Log the created cloze text | |
console.log('Created cloze text:', this.clozeText); | |
console.log('Number of blanks:', this.blanks.length); | |
return true; // Return success indicator | |
} | |
dividePassageIntoSections(totalWords, numberOfBlanks) { | |
const sections = []; | |
const sectionSize = Math.floor(totalWords / numberOfBlanks); | |
for (let i = 0; i < numberOfBlanks; i++) { | |
const start = i * sectionSize; | |
const end = i === numberOfBlanks - 1 ? totalWords : (i + 1) * sectionSize; | |
sections.push({ start, end }); | |
} | |
return sections; | |
} | |
selectWordsManually(words, numberOfBlanks) { | |
// Fallback manual word selection - avoid function words completely | |
const functionWords = new Set([ | |
// Articles | |
'the', 'a', 'an', | |
// Prepositions | |
'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'up', 'about', 'into', 'over', 'after', | |
// Conjunctions | |
'and', 'or', 'but', 'so', 'yet', 'nor', 'because', 'since', 'although', 'if', 'when', 'while', | |
// Pronouns | |
'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'her', 'its', 'our', 'their', | |
'this', 'that', 'these', 'those', 'who', 'what', 'which', 'whom', 'whose', | |
// Auxiliary verbs | |
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', | |
'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'shall' | |
]); | |
// Get content words with their indices for better distribution | |
const contentWordIndices = []; | |
words.forEach((word, index) => { | |
const cleanWord = word.toLowerCase().replace(/[^\w]/g, ''); | |
if (cleanWord.length > 3 && cleanWord.length <= 12 && !functionWords.has(cleanWord)) { | |
contentWordIndices.push({ word: cleanWord, index }); | |
} | |
}); | |
// Distribute selection across sections | |
const passageSections = this.dividePassageIntoSections(words.length, numberOfBlanks); | |
const selectedWords = []; | |
for (let i = 0; i < numberOfBlanks && i < passageSections.length; i++) { | |
const section = passageSections[i]; | |
const sectionWords = contentWordIndices.filter(item => | |
item.index >= section.start && item.index < section.end | |
); | |
if (sectionWords.length > 0) { | |
const randomIndex = Math.floor(Math.random() * sectionWords.length); | |
selectedWords.push(sectionWords[randomIndex].word); | |
} | |
} | |
// Fill remaining slots if needed | |
while (selectedWords.length < numberOfBlanks && contentWordIndices.length > 0) { | |
const availableWords = contentWordIndices | |
.map(item => item.word) | |
.filter(word => !selectedWords.includes(word)); | |
if (availableWords.length > 0) { | |
const randomIndex = Math.floor(Math.random() * availableWords.length); | |
selectedWords.push(availableWords[randomIndex]); | |
} else { | |
break; | |
} | |
} | |
return selectedWords; | |
} | |
async generateContextualization() { | |
// Always use AI for contextualization | |
try { | |
this.contextualization = await aiService.generateContextualization( | |
this.currentBook.title, | |
this.currentBook.author | |
); | |
return this.contextualization; | |
} catch (error) { | |
console.warn('AI contextualization failed, using fallback:', error); | |
this.contextualization = `"${this.currentBook.title}" by ${this.currentBook.author} - A classic work of literature.`; | |
return this.contextualization; | |
} | |
} | |
renderClozeText() { | |
let html = this.clozeText; | |
this.blanks.forEach((blank, index) => { | |
const inputHtml = `<input type="text" | |
class="cloze-input" | |
data-blank-index="${index}" | |
placeholder="${'_ '.repeat(Math.max(3, blank.originalWord.length)).trim()}" | |
style="width: ${Math.max(80, blank.originalWord.length * 16)}px;">`; | |
html = html.replace(`___BLANK_${index}___`, inputHtml); | |
}); | |
return html; | |
} | |
submitAnswers(answers) { | |
this.userAnswers = answers; | |
let correctCount = 0; | |
const results = []; | |
this.blanks.forEach((blank, index) => { | |
const userAnswer = answers[index].trim().toLowerCase(); | |
const correctAnswer = blank.originalWord.toLowerCase(); | |
const isCorrect = userAnswer === correctAnswer; | |
if (isCorrect) correctCount++; | |
results.push({ | |
blankIndex: index, | |
userAnswer: answers[index], | |
correctAnswer: blank.originalWord, | |
isCorrect | |
}); | |
}); | |
const scorePercentage = Math.round((correctCount / this.blanks.length) * 100); | |
this.score = scorePercentage; | |
// Calculate pass requirements based on number of blanks | |
const totalBlanks = this.blanks.length; | |
const requiredCorrect = this.calculateRequiredCorrect(totalBlanks); | |
const passed = correctCount >= requiredCorrect; | |
const resultsData = { | |
correct: correctCount, | |
total: this.blanks.length, | |
percentage: scorePercentage, | |
passed: passed, | |
results, | |
canAdvanceLevel: passed, | |
shouldRevealAnswers: !passed, | |
requiredCorrect: requiredCorrect, | |
currentLevel: this.currentLevel | |
}; | |
// Store results for potential answer revelation | |
this.lastResults = resultsData; | |
return resultsData; | |
} | |
// Calculate required correct answers based on total blanks | |
calculateRequiredCorrect(totalBlanks) { | |
if (totalBlanks === 1) { | |
// Level 1: Must get the single word correct | |
return 1; | |
} else if (totalBlanks % 2 === 1) { | |
// Odd number of blanks (3, 5, etc.): require all but one | |
return totalBlanks - 1; | |
} else { | |
// Even number of blanks: require all correct | |
return totalBlanks; | |
} | |
} | |
showAnswers() { | |
return this.blanks.map(blank => ({ | |
index: blank.index, | |
word: blank.originalWord | |
})); | |
} | |
async nextPassage() { | |
try { | |
// Move to the second passage in the current round | |
if (this.currentPassageIndex === 0 && this.passages && this.passages.length > 1) { | |
this.currentPassageIndex = 1; | |
this.currentBook = this.currentBooks[1]; | |
this.originalText = this.passages[1]; | |
// Clear chat conversations for new passage | |
this.chatService.clearConversations(); | |
// Clear last results | |
this.lastResults = null; | |
// Use preprocessed data if available | |
if (this.preprocessedData && this.preprocessedData.passage2) { | |
await this.createClozeTextFromPreprocessed(1); | |
this.contextualization = this.preprocessedData.passage2.context; | |
} else { | |
// Fallback to sequential processing | |
await this.createClozeText(); | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
await this.generateContextualization(); | |
} | |
return { | |
title: this.currentBook.title, | |
author: this.currentBook.author, | |
text: this.clozeText, | |
blanks: this.blanks, | |
contextualization: this.contextualization, | |
hints: this.hints, | |
passageNumber: 2, | |
totalPassages: 2 | |
}; | |
} else { | |
// If we're already on the second passage, move to next round | |
return this.nextRound(); | |
} | |
} catch (error) { | |
console.error('Error loading next passage:', error); | |
throw error; | |
} | |
} | |
nextRound() { | |
// Check if user passed the previous round | |
const passed = this.lastResults && this.lastResults.passed; | |
// Always increment round counter | |
this.currentRound++; | |
// Only advance level if user passed | |
if (passed) { | |
this.currentLevel++; | |
} | |
// If failed, stay at same level | |
// Clear chat conversations for new round | |
this.chatService.clearConversations(); | |
// Clear last results since we're moving to new round | |
this.lastResults = null; | |
return this.startNewRound(); | |
} | |
// Get answers for current round (for revelation when switching passages) | |
getCurrentAnswers() { | |
if (!this.lastResults) return null; | |
return { | |
hasResults: true, | |
passed: this.lastResults.passed, | |
shouldRevealAnswers: this.lastResults.shouldRevealAnswers, | |
currentLevel: this.lastResults.currentLevel, | |
requiredCorrect: this.lastResults.requiredCorrect, | |
answers: this.blanks.map(blank => ({ | |
index: blank.index, | |
correctAnswer: blank.originalWord, | |
userAnswer: this.lastResults.results[blank.index]?.userAnswer || '', | |
isCorrect: this.lastResults.results[blank.index]?.isCorrect || false | |
})) | |
}; | |
} | |
// Calculate difficulty of a word based on various factors | |
calculateWordDifficulty(word, position, allWords) { | |
let difficulty = 1; | |
// Length factor | |
if (word.length > 8) difficulty += 2; | |
else if (word.length > 5) difficulty += 1; | |
// Position factor (middle words might be harder) | |
const relativePosition = position / allWords.length; | |
if (relativePosition > 0.3 && relativePosition < 0.7) difficulty += 1; | |
// Complexity factors | |
if (word.includes('ing') || word.includes('ed')) difficulty += 0.5; | |
if (word.includes('tion') || word.includes('sion')) difficulty += 1; | |
// Current level factor | |
difficulty += (this.currentLevel - 1) * 0.5; | |
return Math.min(5, Math.max(1, Math.round(difficulty))); | |
} | |
// Simple, clean hint with just essential info based on level | |
generateContextualFallbackHint(word, wordIndex, allWords) { | |
if (this.currentLevel <= 2) { | |
return `${word.length} letters, starts with "${word[0]}", ends with "${word[word.length - 1]}"`; | |
} else { | |
return `${word.length} letters, starts with "${word[0]}"`; | |
} | |
} | |
// Chat functionality methods | |
async askQuestionAboutBlank(blankIndex, questionType, currentInput = '') { | |
const blankId = `blank_${blankIndex}`; | |
return await this.chatService.askQuestion(blankId, questionType, currentInput); | |
} | |
getSuggestedQuestionsForBlank(blankIndex) { | |
const blankId = `blank_${blankIndex}`; | |
return this.chatService.getSuggestedQuestions(blankId); | |
} | |
// Enhanced render method to include chat buttons | |
renderClozeTextWithChat() { | |
let html = this.clozeText; | |
this.blanks.forEach((blank, index) => { | |
const chatButtonId = `chat-btn-${index}`; | |
const inputHtml = ` | |
<span class="inline-flex items-center gap-1"> | |
<input type="text" | |
class="cloze-input" | |
data-blank-index="${index}" | |
placeholder="${'_ '.repeat(Math.max(3, blank.originalWord.length)).trim()}" | |
style="width: ${Math.max(80, blank.originalWord.length * 16)}px;"> | |
<button id="${chatButtonId}" | |
class="chat-button text-blue-500 hover:text-blue-700 text-sm" | |
data-blank-index="${index}" | |
title="Ask question about this word"> | |
💬 | |
</button> | |
</span>`; | |
html = html.replace(`___BLANK_${index}___`, inputHtml); | |
}); | |
return html; | |
} | |
} | |
export default ClozeGame; |