/** * Interactive Reader Module - Rewritten * FIXED: Images from manual upload now render correctly using base64 data * Features: * - Single Start button, becomes play/pause after first click * - Auto-advance to next block audio * - Click any word to play from that point * - Word + Sentence highlighting with smart sync * - Left sidebar chapter navigation * - Images rendered from base64 data (both processed and uploaded) * - Pause/Resume works correctly */ // ============================================ // Reader State // ============================================ let readerInstances = []; let currentReaderInstance = null; let currentReaderIndex = -1; let readerStarted = false; let readerUICreated = false; // ============================================ // Render Reader // ============================================ function renderInteractiveReader() { const container = document.getElementById('readerContainer'); const chapters = collectEditorContent(); let hasAudio = false; const chaptersWithAudio = []; for (const chapter of chapters) { const chapterBlocks = []; for (const block of chapter.blocks) { // Match editor block by ID lookup const blockData = findEditorBlockForContent(block); const isImageBlock = block.block_type === 'image' || (block.content && block.content.trim().startsWith('![') && block.content.trim().includes('](')); chapterBlocks.push({ ...block, _editorData: blockData || null, _isImage: isImageBlock }); if (!isImageBlock && blockData && blockData.audio_data) { hasAudio = true; } } chaptersWithAudio.push({ ...chapter, blocks: chapterBlocks }); } if (!hasAudio) { container.innerHTML = `

Generate audio to view the interactive reader

Go to the Editor tab and click "Generate" on blocks or chapters

`; removeReaderUI(); return; } let html = ''; readerInstances = []; let globalBlockIndex = 0; for (const chapter of chaptersWithAudio) { html += `
`; html += `

Chapter ${chapter.chapter_number}

`; for (const block of chapter.blocks) { const blockData = block._editorData; const isImageBlock = block._isImage; const hasBlockAudio = !isImageBlock && blockData && blockData.audio_data; const blockId = blockData ? blockData.id : `reader_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; html += `
`; if (isImageBlock) { const imageHtml = buildImageHtml(block, blockData); html += `
${imageHtml}
`; } else { // Render before-position images from the block's images array const blockImages = getBlockImages(block, blockData); for (const img of blockImages) { if (img.position === 'before' && img.data) { html += `
${img.alt_text || 'Image'}
`; } } html += `
`; // Render after-position images for (const img of blockImages) { if (img.position === 'after' && img.data) { html += `
${img.alt_text || 'Image'}
`; } } } html += `
`; readerInstances.push({ index: globalBlockIndex, blockId: blockId, blockData: blockData, content: block.content, hasAudio: !!hasBlockAudio, isImage: isImageBlock, chapterNumber: chapter.chapter_number, wordSpans: [], wordMap: [], sentenceData: [], audio: null, transcription: (!isImageBlock && blockData) ? (blockData.transcription || []) : [], animFrameId: null, lastWordSpan: null, lastSentenceSpans: [] }); globalBlockIndex++; } html += `
`; } container.innerHTML = html; for (const inst of readerInstances) { if (inst.isImage || !inst.content) continue; const contentEl = document.getElementById(`reader-content-${inst.index}`); if (!contentEl) continue; renderWordsIntoContainer(contentEl, inst); if (inst.hasAudio && inst.transcription.length > 0) { runReaderSmartSync(inst); } } addReaderStyles(); setupReaderUI(chaptersWithAudio); } // ============================================ // Image Resolution Helpers // ============================================ /** * Find the editorBlocks entry that corresponds to a collected block. * Uses multiple strategies: ID match, then content match. */ function findEditorBlockForContent(block) { // Strategy 1: Try matching via DOM element ID for (const eb of editorBlocks) { const el = document.getElementById(eb.id); if (el) { const textarea = el.querySelector('.md-block-textarea'); if (textarea && textarea.value === block.content) { return eb; } } } // Strategy 2: Match by content string directly for (const eb of editorBlocks) { if (eb.content === block.content) { return eb; } } return null; } /** * Get all images for a block from every available source. */ function getBlockImages(block, blockData) { // Priority 1: block.images from collectEditorContent (most reliable) if (block.images && block.images.length > 0) { const valid = block.images.filter(img => img.data && img.data.length > 0); if (valid.length > 0) return valid; } // Priority 2: editorBlocks data if (blockData && blockData.images && blockData.images.length > 0) { const valid = blockData.images.filter(img => img.data && img.data.length > 0); if (valid.length > 0) return valid; } return []; } /** * Build the HTML for an image block in the reader. * Resolves base64 data from multiple sources. */ function buildImageHtml(block, blockData) { // Source 1: block.images array (from collectEditorContent) if (block.images && block.images.length > 0) { let html = ''; for (const img of block.images) { if (img.data && img.data.length > 0) { html += `${img.alt_text || 'Image'}`; } } if (html) return html; } // Source 2: editorBlocks images array if (blockData && blockData.images && blockData.images.length > 0) { let html = ''; for (const img of blockData.images) { if (img.data && img.data.length > 0) { html += `${img.alt_text || 'Image'}`; } } if (html) return html; } // Source 3: Extract data URI from markdown content itself if (block.content) { const dataUriMatch = block.content.match(/!\[([^\]]*)\]\((data:image\/[^)]+)\)/); if (dataUriMatch) { return `${dataUriMatch[1] || 'Image'}`; } } // Source 4: Grab the rendered image directly from the editor DOM if (blockData && blockData.id) { const editorBlock = document.getElementById(blockData.id); if (editorBlock) { const editorImg = editorBlock.querySelector('.image-block img, .md-block-content img'); if (editorImg && editorImg.src && editorImg.src.startsWith('data:image')) { return `Image`; } } } // Source 5: Scan ALL editorBlocks for matching content to find images if (block.content) { for (const eb of editorBlocks) { if (eb.content === block.content && eb.images && eb.images.length > 0) { let html = ''; for (const img of eb.images) { if (img.data && img.data.length > 0) { html += `${img.alt_text || 'Image'}`; } } if (html) return html; } } } // Fallback: placeholder return `

Image not available

`; } // ============================================ // Word Rendering & Sync // ============================================ function renderWordsIntoContainer(container, inst) { const div = document.createElement('div'); div.innerHTML = marked.parse(inst.content, { breaks: true, gfm: true }); inst.wordSpans = []; function processNode(node) { if (node.nodeType === Node.TEXT_NODE) { const words = node.textContent.split(/(\s+)/); const fragment = document.createDocumentFragment(); words.forEach(part => { if (part.trim().length > 0) { const span = document.createElement('span'); span.className = 'reader-word'; span.textContent = part; span.dataset.readerIndex = inst.index; span.dataset.wordIdx = inst.wordSpans.length; inst.wordSpans.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); } } processNode(div); while (div.firstChild) container.appendChild(div.firstChild); } function runReaderSmartSync(inst) { const { wordSpans, transcription } = inst; inst.wordMap = new Array(wordSpans.length).fill(undefined); let aiIdx = 0; wordSpans.forEach((span, i) => { const textWord = span.textContent.toLowerCase().replace(/[^\w]/g, ''); for (let off = 0; off < 5; off++) { if (aiIdx + off >= transcription.length) break; const aiWord = transcription[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, ''); if (textWord === aiWord) { inst.wordMap[i] = aiIdx + off; aiIdx += off + 1; return; } } }); inst.sentenceData = []; let buffer = []; let startIdx = 0; wordSpans.forEach((span, i) => { buffer.push(span); if (/[.!?]["'\u201D\u2019]?$/.test(span.textContent.trim())) { let startT = 0, endT = 0; for (let k = startIdx; k <= i; k++) { if (inst.wordMap[k] !== undefined) { startT = transcription[inst.wordMap[k]].start; break; } } for (let k = i; k >= startIdx; k--) { if (inst.wordMap[k] !== undefined) { endT = transcription[inst.wordMap[k]].end; break; } } if (endT > startT) { inst.sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT }); } buffer = []; startIdx = i + 1; } }); if (buffer.length > 0) { let startT = 0, endT = 0; for (let k = startIdx; k < wordSpans.length; k++) { if (inst.wordMap[k] !== undefined) { startT = transcription[inst.wordMap[k]].start; break; } } for (let k = wordSpans.length - 1; k >= startIdx; k--) { if (inst.wordMap[k] !== undefined) { endT = transcription[inst.wordMap[k]].end; break; } } if (endT > startT) { inst.sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT }); } } } // ============================================ // Reader UI // ============================================ function setupReaderUI(chaptersWithAudio) { removeReaderUI(); const btn = document.createElement('button'); btn.id = 'reader-floating-btn'; btn.innerHTML = ` Start `; document.body.appendChild(btn); btn.addEventListener('click', handleReaderFloatingClick); const nav = document.createElement('nav'); nav.id = 'reader-chapter-nav'; let navHtml = ''; nav.innerHTML = navHtml; document.body.appendChild(nav); const container = document.getElementById('readerContainer'); container.addEventListener('click', handleReaderWordClick); setupReaderNavObserver(); readerStarted = false; currentReaderInstance = null; currentReaderIndex = -1; readerUICreated = true; positionReaderUI(); window.addEventListener('resize', positionReaderUI); window.addEventListener('scroll', positionReaderUI); } function positionReaderUI() { const readerContainer = document.getElementById('readerContainer'); const btn = document.getElementById('reader-floating-btn'); const nav = document.getElementById('reader-chapter-nav'); if (!readerContainer || !btn || !nav) return; const containerRect = readerContainer.getBoundingClientRect(); btn.style.position = 'fixed'; btn.style.top = '80px'; const rightPos = window.innerWidth - (containerRect.right + 8); btn.style.right = Math.max(rightPos, 8) + 'px'; btn.style.left = 'auto'; nav.style.position = 'fixed'; nav.style.top = '50%'; nav.style.transform = 'translateY(-50%)'; const navWidth = nav.offsetWidth || 52; const leftPos = containerRect.left - navWidth - 8; nav.style.left = Math.max(leftPos, 8) + 'px'; } function removeReaderUI() { const oldBtn = document.getElementById('reader-floating-btn'); if (oldBtn) oldBtn.remove(); const oldNav = document.getElementById('reader-chapter-nav'); if (oldNav) oldNav.remove(); readerStarted = false; currentReaderInstance = null; currentReaderIndex = -1; readerUICreated = false; window.removeEventListener('resize', positionReaderUI); window.removeEventListener('scroll', positionReaderUI); for (const inst of readerInstances) { if (inst.audio) { inst.audio.pause(); inst.audio = null; } cancelAnimationFrame(inst.animFrameId); } } function showReaderUI() { const btn = document.getElementById('reader-floating-btn'); const nav = document.getElementById('reader-chapter-nav'); if (btn) btn.style.display = 'flex'; if (nav) nav.style.display = 'block'; positionReaderUI(); } function hideReaderUI() { const btn = document.getElementById('reader-floating-btn'); const nav = document.getElementById('reader-chapter-nav'); if (btn) btn.style.display = 'none'; if (nav) nav.style.display = 'none'; } function setupReaderNavObserver() { const chapters = document.querySelectorAll('.reader-chapter'); if (chapters.length === 0) return; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const chNum = entry.target.id.replace('reader-chapter-', ''); const navLinks = document.querySelectorAll('#reader-chapter-nav a'); navLinks.forEach(l => l.classList.remove('active')); const activeLink = document.querySelector(`#reader-chapter-nav a[data-chapter="${chNum}"]`); if (activeLink) activeLink.classList.add('active'); } }); }, { threshold: 0.3 }); chapters.forEach(ch => observer.observe(ch)); } // ============================================ // Playback Logic // ============================================ function handleReaderFloatingClick() { if (!readerStarted) { readerStarted = true; updateReaderButton('playing'); playReaderInstanceByIndex(findNextAudioIndex(-1)); return; } if (currentReaderInstance && currentReaderInstance.audio) { if (currentReaderInstance.audio.paused) { currentReaderInstance.audio.play(); updateReaderButton('playing'); } else { currentReaderInstance.audio.pause(); updateReaderButton('paused'); } } else { playReaderInstanceByIndex(findNextAudioIndex(-1)); updateReaderButton('playing'); } } function handleReaderWordClick(event) { const wordSpan = event.target.closest('.reader-word'); if (!wordSpan) return; const readerIdx = parseInt(wordSpan.dataset.readerIndex, 10); const wordIdx = parseInt(wordSpan.dataset.wordIdx, 10); const inst = readerInstances[readerIdx]; if (!inst || !inst.hasAudio) return; const aiIdx = inst.wordMap[wordIdx]; if (aiIdx === undefined) return; const timestamp = inst.transcription[aiIdx].start; if (currentReaderInstance && currentReaderInstance !== inst) { stopReaderInstance(currentReaderInstance); } readerStarted = true; currentReaderIndex = readerIdx; currentReaderInstance = inst; ensureAudioLoaded(inst); inst.audio.currentTime = timestamp; inst.audio.play(); updateReaderButton('playing'); startReaderHighlightLoop(inst); } function findNextAudioIndex(afterIndex) { for (let i = afterIndex + 1; i < readerInstances.length; i++) { if (readerInstances[i].hasAudio) return i; } return -1; } function playReaderInstanceByIndex(index) { if (index < 0 || index >= readerInstances.length) { updateReaderButton('paused'); currentReaderInstance = null; currentReaderIndex = -1; return; } const inst = readerInstances[index]; if (!inst.hasAudio) { playReaderInstanceByIndex(findNextAudioIndex(index)); return; } if (currentReaderInstance && currentReaderInstance !== inst) { stopReaderInstance(currentReaderInstance); } currentReaderIndex = index; currentReaderInstance = inst; ensureAudioLoaded(inst); inst.audio.currentTime = 0; inst.audio.play(); updateReaderButton('playing'); startReaderHighlightLoop(inst); const blockEl = document.querySelector(`.reader-block[data-reader-index="${index}"]`); if (blockEl) { blockEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } function ensureAudioLoaded(inst) { if (!inst.audio) { const blockData = inst.blockData; if (!blockData || !blockData.audio_data) return; const audioBlob = base64ToBlob(blockData.audio_data, `audio/${blockData.audio_format || 'mp3'}`); const audioUrl = URL.createObjectURL(audioBlob); inst.audio = new Audio(audioUrl); inst.audio.addEventListener('ended', () => { stopReaderHighlightLoop(inst); clearReaderHighlights(inst); const nextIdx = findNextAudioIndex(inst.index); if (nextIdx >= 0) { playReaderInstanceByIndex(nextIdx); } else { updateReaderButton('paused'); currentReaderInstance = null; currentReaderIndex = -1; } }); inst.audio.addEventListener('pause', () => { stopReaderHighlightLoop(inst); }); inst.audio.addEventListener('play', () => { startReaderHighlightLoop(inst); updateReaderButton('playing'); }); } } function stopReaderInstance(inst) { if (inst.audio) { inst.audio.pause(); inst.audio.currentTime = 0; } stopReaderHighlightLoop(inst); clearReaderHighlights(inst); } // ============================================ // Highlighting // ============================================ function startReaderHighlightLoop(inst) { cancelAnimationFrame(inst.animFrameId); function loop() { if (!inst.audio || inst.audio.paused) return; const currentTime = inst.audio.currentTime; const activeAiIndex = inst.transcription.findIndex(w => currentTime >= w.start && currentTime < w.end); if (activeAiIndex !== -1) { const activeTextIndex = inst.wordMap.findIndex(i => i === activeAiIndex); if (activeTextIndex !== -1) { const activeSpan = inst.wordSpans[activeTextIndex]; if (activeSpan !== inst.lastWordSpan) { if (inst.lastWordSpan) inst.lastWordSpan.classList.remove('current-word'); activeSpan.classList.add('current-word'); const rect = activeSpan.getBoundingClientRect(); if (rect.top < window.innerHeight * 0.25 || rect.bottom > window.innerHeight * 0.75) { activeSpan.scrollIntoView({ behavior: 'smooth', block: 'center' }); } inst.lastWordSpan = activeSpan; } } } const activeSentence = inst.sentenceData.find(s => currentTime >= s.startTime && currentTime <= s.endTime); if (activeSentence && activeSentence.spans !== inst.lastSentenceSpans) { if (inst.lastSentenceSpans && inst.lastSentenceSpans.length) { inst.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg')); } activeSentence.spans.forEach(s => s.classList.add('current-sentence-bg')); inst.lastSentenceSpans = activeSentence.spans; } inst.animFrameId = requestAnimationFrame(loop); } inst.animFrameId = requestAnimationFrame(loop); } function stopReaderHighlightLoop(inst) { cancelAnimationFrame(inst.animFrameId); } function clearReaderHighlights(inst) { if (inst.lastWordSpan) { inst.lastWordSpan.classList.remove('current-word'); inst.lastWordSpan = null; } if (inst.lastSentenceSpans && inst.lastSentenceSpans.length) { inst.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg')); inst.lastSentenceSpans = []; } } // ============================================ // Button State // ============================================ function updateReaderButton(state) { const btn = document.getElementById('reader-floating-btn'); if (!btn) return; const textEl = document.getElementById('reader-btn-text'); const playIcon = document.getElementById('reader-btn-play'); const pauseIcon = document.getElementById('reader-btn-pause'); if (readerStarted) { if (textEl) textEl.style.display = 'none'; btn.classList.add('active-mode'); if (state === 'playing') { playIcon.style.display = 'none'; pauseIcon.style.display = 'block'; } else { playIcon.style.display = 'block'; pauseIcon.style.display = 'none'; } } } // ============================================ // Utility // ============================================ function base64ToBlob(base64, mimeType) { const byteCharacters = atob(base64); const byteNumbers = new Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); return new Blob([byteArray], { type: mimeType }); } function addReaderStyles() { if (document.getElementById('readerStyles')) return; const style = document.createElement('style'); style.id = 'readerStyles'; style.textContent = ` .reader-chapter { margin-bottom: 48px; } .reader-chapter-title { font-family: var(--font-serif); font-size: 1.75rem; font-weight: 700; color: var(--text-primary); margin-bottom: 24px; padding-bottom: 12px; border-bottom: 2px solid var(--border-color); } .reader-block { position: relative; margin-bottom: 16px; padding: 8px 16px; border-radius: var(--border-radius-sm); transition: background 0.2s; } .reader-content { font-family: var(--font-serif); font-size: 1.125rem; line-height: 1.8; } .reader-content p { margin-bottom: 1em; } .reader-content h1, .reader-content h2, .reader-content h3 { font-family: var(--font-serif); margin-top: 1.5em; margin-bottom: 0.5em; } .reader-image-block { text-align: center; margin: 24px 0; } .reader-image-block img { max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: block; margin: 0 auto; } .reader-image { max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: block; margin: 16px auto; } .reader-image-placeholder { text-align: center; padding: 40px; background: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 12px; } .reader-word { cursor: pointer; padding: 1px 0; border-radius: 3px; transition: background 0.15s, color 0.15s; } .reader-word:hover { background: #e3f2fd; } .reader-word.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; } .current-sentence-bg { -webkit-box-decoration-break: clone; box-decoration-break: clone; background-color: #e0e7ff; padding: 0.1em 0.2em; margin: 0 -0.15em; border-radius: 6px; } #reader-floating-btn { position: fixed; top: 80px; right: 24px; height: 56px; min-width: 56px; padding: 0 20px; border-radius: 28px; background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%); border: none; color: white; box-shadow: 0 4px 15px rgba(0,0,0,0.25); display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer; z-index: 1050; transition: transform 0.2s, box-shadow 0.2s; font-family: var(--font-sans); font-weight: 600; font-size: 1rem; } #reader-floating-btn:hover { transform: scale(1.05); box-shadow: 0 6px 20px rgba(0,0,0,0.3); } #reader-floating-btn:active { transform: scale(0.95); } #reader-floating-btn.active-mode { width: 56px; padding: 0; border-radius: 50%; } #reader-floating-btn.active-mode #reader-btn-text { display: none; } #reader-chapter-nav { position: fixed; top: 50%; transform: translateY(-50%); background: rgba(255,255,255,0.9); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-radius: 20px; padding: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; max-height: 70vh; overflow-y: auto; } #reader-chapter-nav ul { list-style: none; padding: 0; margin: 0; } #reader-chapter-nav a { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; margin: 4px 0; border-radius: 50%; text-decoration: none; color: var(--text-secondary); font-family: var(--font-sans); font-weight: 600; font-size: 0.85rem; transition: all 0.2s; } #reader-chapter-nav a:hover { background: #e0e7ff; color: var(--primary-color); } #reader-chapter-nav a.active { background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%); color: white; transform: scale(1.1); } @media (max-width: 768px) { #reader-chapter-nav { display: none !important; } #reader-floating-btn { top: auto; bottom: 20px; right: 20px !important; left: auto !important; } } `; document.head.appendChild(style); }