378 lines
13 KiB
JavaScript
378 lines
13 KiB
JavaScript
/**
|
|
* 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([
|
|
'addText',
|
|
'addBlock',
|
|
'splitIntoParagraphs',
|
|
'processNextFromQueue',
|
|
'processSentences',
|
|
'processNextSentence',
|
|
'clear',
|
|
'getBuffer',
|
|
'getStatus',
|
|
'setOnSentenceReady'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Initialize the module
|
|
* @returns {Promise<boolean>} - 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 text to the buffer and process sentences
|
|
* @param {string} text - Text to add to the buffer
|
|
*/
|
|
addText(text) {
|
|
if (!text) return;
|
|
|
|
console.log(`TextBuffer: Adding text: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
|
|
|
const blocks = this.markupParser && typeof this.markupParser.parse === 'function'
|
|
? this.markupParser.parse(text)
|
|
: this.splitIntoParagraphs(text).map(paragraphText => ({
|
|
type: 'paragraph',
|
|
text: paragraphText,
|
|
layoutText: paragraphText,
|
|
cueMarkers: [],
|
|
role: 'body',
|
|
isFirstParagraphInChapter: false
|
|
}));
|
|
|
|
blocks.forEach(block => {
|
|
if (block.type === 'paragraph') {
|
|
const paragraphId = `paragraph-${this.paragraphCounter + 1}`;
|
|
this.processingQueue.push({
|
|
...block,
|
|
id: paragraphId,
|
|
paragraphIndex: this.paragraphCounter,
|
|
textBlockId: this.currentTextBlockId
|
|
});
|
|
this.paragraphCounter += 1;
|
|
} else {
|
|
this.processingQueue.push({
|
|
...block,
|
|
id: `${block.type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
});
|
|
|
|
if (block.type === 'image') {
|
|
this.currentTextBlockId += 1;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Process the queue if not already processing
|
|
if (!this.isProcessingActive && this.onSentenceReadyCallback) {
|
|
this.processNextFromQueue();
|
|
} else {
|
|
console.log('TextBuffer: Text queued for processing');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Split an incoming narrative fragment into book paragraphs.
|
|
* Single newlines inside a paragraph are normalized to spaces; blank lines
|
|
* mark distinct paragraphs.
|
|
* @param {string} text - Raw text fragment
|
|
* @returns {Array<string>} - Normalized paragraph strings
|
|
*/
|
|
splitIntoParagraphs(text) {
|
|
return String(text)
|
|
.split(/\n\s*\n/g)
|
|
.map(paragraph => paragraph.replace(/\s*\n\s*/g, ' ').trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* 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 };
|