/** * Interactive Reader Module * Handles text display, word highlighting, and audio sync */ // ========================================== // READER STATE VARIABLES // ========================================== let allWordSpans = []; let wordMap = []; let sentenceData = []; let lastHighlightedWordSpan = null; let lastHighlightedSentenceSpans = []; // ========================================== // READER INITIALIZATION // ========================================== /** * Initialize reader with markdown text * @param {string} markdownText - Text content in markdown format */ function initReader(markdownText) { const container = document.getElementById('readerContent'); container.innerHTML = ''; allWordSpans = []; wordMap = []; const html = marked.parse(markdownText || '', { breaks: true }); const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; // Process nodes to wrap words in spans processNode(tempDiv); while (tempDiv.firstChild) { container.appendChild(tempDiv.firstChild); } runSmartSync(); } /** * Process DOM node to wrap words in clickable spans * @param {Node} node - DOM node to process */ function processNode(node) { if (node.nodeType === Node.TEXT_NODE) { const words = node.textContent.split(/(\s+|[^\w'])/g); const fragment = document.createDocumentFragment(); words.forEach(part => { if (part.trim().length > 0) { const span = document.createElement('span'); span.className = 'word'; span.textContent = part; span.onclick = handleWordClick; allWordSpans.push(span); fragment.appendChild(span); } else { fragment.appendChild(document.createTextNode(part)); } }); node.parentNode.replaceChild(fragment, node); } else if (node.nodeType === Node.ELEMENT_NODE) { Array.from(node.childNodes).forEach(processNode); } } /** * Handle click on a word span * @param {MouseEvent} e - Click event */ function handleWordClick(e) { e.stopPropagation(); const spanIndex = allWordSpans.indexOf(e.target); const aiIdx = wordMap[spanIndex]; if (aiIdx !== undefined && transcriptionData[aiIdx] && wavesurfer) { wavesurfer.setTime(transcriptionData[aiIdx].start); wavesurfer.play(); } } // ========================================== // TEXT-AUDIO SYNC FUNCTIONS // ========================================== /** * Run smart sync between text words and transcription data */ function runSmartSync() { wordMap = new Array(allWordSpans.length).fill(undefined); let aiIdx = 0; let matchCount = 0; allWordSpans.forEach((span, i) => { const clean = span.textContent.toLowerCase().replace(/[^\w]/g, ''); if (clean.length === 0) { span.classList.add('unmatched'); return; } // Look ahead up to 5 positions for a match for (let off = 0; off < 5; off++) { if (aiIdx + off >= transcriptionData.length) break; const transcriptWord = transcriptionData[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, ''); if (transcriptWord === clean) { wordMap[i] = aiIdx + off; aiIdx += off + 1; span.classList.remove('unmatched'); matchCount++; return; } } span.classList.add('unmatched'); }); mapSentences(); updateSyncStatus(matchCount); } /** * Update sync status badge * @param {number} matchCount - Number of matched words */ function updateSyncStatus(matchCount) { const badge = document.getElementById('syncStatus'); badge.textContent = `Synced (${matchCount}/${allWordSpans.length})`; badge.className = matchCount > allWordSpans.length * 0.8 ? 'badge bg-success' : 'badge bg-warning text-dark'; } /** * Map sentences for sentence-level highlighting */ function mapSentences() { sentenceData = []; let buffer = []; let startIdx = 0; allWordSpans.forEach((span, i) => { buffer.push(span); // Check for sentence-ending punctuation if (/[.!?]["']?$/.test(span.textContent.trim())) { let startT = 0; let endT = 0; // Find start time for (let k = startIdx; k <= i; k++) { if (wordMap[k] !== undefined) { startT = transcriptionData[wordMap[k]].start; break; } } // Find end time for (let k = i; k >= startIdx; k--) { if (wordMap[k] !== undefined) { endT = transcriptionData[wordMap[k]].end; break; } } if (endT > startT) { sentenceData.push({ spans: [...buffer], start: startT, end: endT }); } buffer = []; startIdx = i + 1; } }); } /** * Sync reader highlighting with audio playback time * @param {number} t - Current playback time in seconds */ function syncReader(t) { // Highlight current word highlightCurrentWord(t); // Highlight current sentence highlightCurrentSentence(t); } /** * Highlight the current word based on playback time * @param {number} t - Current playback time */ function highlightCurrentWord(t) { const aiIdx = transcriptionData.findIndex(d => t >= d.start && t < d.end); if (aiIdx !== -1) { const txtIdx = wordMap.findIndex(i => i === aiIdx); if (txtIdx !== -1 && allWordSpans[txtIdx] !== lastHighlightedWordSpan) { // Remove previous highlight if (lastHighlightedWordSpan) { lastHighlightedWordSpan.classList.remove('current-word'); } // Add new highlight lastHighlightedWordSpan = allWordSpans[txtIdx]; lastHighlightedWordSpan.classList.add('current-word'); // Scroll into view if needed scrollWordIntoView(lastHighlightedWordSpan); } } } /** * Highlight the current sentence based on playback time * @param {number} t - Current playback time */ function highlightCurrentSentence(t) { const sent = sentenceData.find(s => t >= s.start && t <= s.end); if (sent && sent.spans !== lastHighlightedSentenceSpans) { // Remove previous highlight if (lastHighlightedSentenceSpans) { lastHighlightedSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg')); } // Add new highlight lastHighlightedSentenceSpans = sent.spans; lastHighlightedSentenceSpans.forEach(s => s.classList.add('current-sentence-bg')); } } /** * Scroll word into view if outside visible area * @param {Element} wordSpan - Word span element */ function scrollWordIntoView(wordSpan) { const r = wordSpan.getBoundingClientRect(); const c = document.querySelector('.reader-section').getBoundingClientRect(); if (r.top < c.top || r.bottom > c.bottom) { wordSpan.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } // ========================================== // MISMATCH TOGGLE // ========================================== /** * Toggle display of mismatched words */ function toggleMismatches() { const toggle = document.getElementById('mismatchToggle'); document.getElementById('readerContent').classList.toggle('show-mismatches', toggle.checked); }