/** * TextBuffer Module * Manages text processing and sentence detection for the UI */ import { BaseModule } from './base-module.js'; class TextBufferModule extends BaseModule { constructor() { super('text-buffer', 'Text Buffer'); this.buffer = ''; this.sentenceEndRegex = /[.!?]\s+/g; this.onSentenceReadyCallback = null; this.processingLock = false; this.processingQueue = []; this.isProcessingActive = false; this.paragraphCounter = 0; this.currentTextBlockId = 0; this.markupParser = null; this.dependencies = ['markup-parser']; // Bind methods using parent's bindMethods utility this.bindMethods([ 'addBlock', 'addBlocks', 'processNextFromQueue', 'processSentences', 'processNextSentence', 'clear', 'getBuffer', 'getStatus', 'setOnSentenceReady' ]); } /** * Initialize the module * @returns {Promise} - Resolves with success status */ async initialize() { try { this.markupParser = this.getModule('markup-parser'); if (!this.markupParser) { console.warn("TextBuffer: Markup parser not found, using plain paragraph splitting"); } // Use parent's addEventListener for automatic cleanup this.addEventListener(document, 'ui:paragraph:complete', (event) => { if (this.processingQueue.length > 0 && !this.isProcessingActive) { console.log('TextBuffer: Previous paragraph complete, processing next from queue'); this.processNextFromQueue(); } }); // Use parent's addEventListener for automatic cleanup this.addEventListener(document, 'animation:complete', () => { if (this.processingQueue.length > 0 && !this.isProcessingActive) { console.log('TextBuffer: Animations complete, processing next from queue'); this.processNextFromQueue(); } }); this.reportProgress(100, "Text buffer ready"); return true; } catch (error) { console.error("Error initializing Text Buffer:", error); return false; } } /** * Set callback function for when a sentence is ready * @param {Function} callback - Function to call with the sentence and completion callback */ setOnSentenceReady(callback) { if (typeof callback === 'function') { this.onSentenceReadyCallback = callback; console.log("Text Buffer: Sentence ready callback set"); // Process any queued text immediately if (this.processingQueue.length > 0 && !this.isProcessingActive) { this.processNextFromQueue(); } } else { console.warn("Text Buffer: Invalid sentence ready callback provided"); } } /** * Add an already parsed render block to the processing queue. * Engine protocols should prefer this over re-serializing tags into text markup. * @param {Object} block - Parsed paragraph/media/heading block */ addBlock(block) { if (!block || !block.type) return; if (block.type === 'paragraph') { const paragraphId = block.id || `paragraph-${this.paragraphCounter + 1}`; this.processingQueue.push({ ...block, id: paragraphId, paragraphIndex: this.paragraphCounter, textBlockId: this.currentTextBlockId, text: String(block.text || '').trim(), layoutText: block.layoutText || block.text || '' }); this.paragraphCounter += 1; } else { this.processingQueue.push({ ...block, id: block.id || `${block.type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` }); if (block.type === 'image') { this.currentTextBlockId += 1; } } if (!this.isProcessingActive && this.onSentenceReadyCallback) { this.processNextFromQueue(); } else { console.log(`TextBuffer: ${block.type} block queued for processing`); } } addBlocks(blocks = []) { if (!Array.isArray(blocks)) return; blocks.forEach(block => this.addBlock(block)); } /** * Process the next text fragment from the queue */ processNextFromQueue() { if (this.processingQueue.length === 0 || this.isProcessingActive) { return; } this.isProcessingActive = true; const paragraph = this.processingQueue.shift(); console.log(`TextBuffer: Processing next fragment from queue, remaining: ${this.processingQueue.length}`); this.buffer = paragraph.text; super.dispatchEvent('buffer:sentence', { sentence: paragraph.text, paragraph, remaining: this.processingQueue.length }); if (this.onSentenceReadyCallback) { this.onSentenceReadyCallback(paragraph, () => {}); this.buffer = ''; this.isProcessingActive = false; this.processingLock = false; if (this.processingQueue.length > 0) { queueMicrotask(() => this.processNextFromQueue()); } else { super.dispatchEvent('buffer:empty', {}); } } else { this.isProcessingActive = false; } } /** * Process complete sentences in the buffer */ processSentences() { // If already processing, don't start another processing cycle if (this.processingLock) { return; } this.processingLock = true; // If the buffer is empty, release the lock and check queue if (this.buffer.length === 0) { this.processingLock = false; // If no more text to process, end processing if (this.processingQueue.length === 0) { this.isProcessingActive = false; return; } // Process the next text fragment this.processNextFromQueue(); return; } // Process the next sentence this.processNextSentence(); } /** * Process the next sentence in the buffer */ processNextSentence() { // Check for different sentence endings const patterns = [/[.!?]\s+/, /[.!?]$/, /\n/]; let match = null; let endIndex = -1; // Try to find the first sentence ending for (const pattern of patterns) { match = this.buffer.match(pattern); if (match) { endIndex = match.index + match[0].length; break; } } if (endIndex === -1) { // No complete sentence found this.processingLock = false; this.isProcessingActive = false; return; } const sentence = this.buffer.substring(0, endIndex); // Remove the processed sentence from buffer this.buffer = this.buffer.substring(endIndex); console.log(`TextBuffer: Processing sentence: "${sentence.trim()}"`); // Use parent's dispatchEvent method super.dispatchEvent('buffer:sentence', { sentence: sentence, remaining: this.buffer.length }); // Call the callback if set if (this.onSentenceReadyCallback) { this.onSentenceReadyCallback(sentence, () => { // After processing is complete, check for more sentences this.processingLock = false; // Release lock immediately to allow processing of next sentence // Check if there are more sentences to process if (this.buffer.length > 0) { // Use requestAnimationFrame to prevent stack overflow and ensure UI update between sentences requestAnimationFrame(() => { this.processSentences(); }); } else if (this.processingQueue.length > 0) { // No more sentences in buffer but we have more text in the queue requestAnimationFrame(() => { this.isProcessingActive = false; this.processNextFromQueue(); }); } else { // All processed this.isProcessingActive = false; super.dispatchEvent('buffer:empty', {}); } }); } else { // No callback set, just release lock and continue processing this.processingLock = false; if (this.buffer.length > 0) { // Use requestAnimationFrame instead of setTimeout for better performance requestAnimationFrame(() => { this.processSentences(); }); } else if (this.processingQueue.length > 0) { requestAnimationFrame(() => { this.isProcessingActive = false; this.processNextFromQueue(); }); } else { this.isProcessingActive = false; } } } /** * Clear the text buffer */ clear() { this.buffer = ''; this.processingQueue = []; this.isProcessingActive = false; this.processingLock = false; this.paragraphCounter = 0; this.currentTextBlockId = 0; } /** * Get the current buffer content * @returns {string} - Current buffer content */ getBuffer() { return this.buffer; } /** * Get the current processing status * @returns {Object} - Processing status object */ getStatus() { return { bufferLength: this.buffer.length, queueLength: this.processingQueue.length, isProcessing: this.isProcessingActive, isLocked: this.processingLock }; } } // Create the singleton instance const TextBuffer = new TextBufferModule(); // Export the module export { TextBuffer };