'use strict'; // --------------------------------------------------------------------------- // Articles in the book reader (DioraArticle) — blog posts and news (feeds.js) // read like books: the same overlay, typography and colours (⚙, kept for all // articles together), pages or scrolling (63-pages.js) with the notes at the // page's foot, search, read-aloud, full screen. Bookmarks, markings and the // notes margin stay with books (an article has nowhere to keep them). // // The article is one "chapter" in #reader-content; currentBookId stays null, // so nothing of a book's position or annotations is saved. Where it was read // to goes back to its owner (onProgress), as do clicks on its own buttons. // --------------------------------------------------------------------------- (function () { let session = null; // {title, html, startFraction, onProgress, onClick, onClose, actions} let progressTimer = 0; const overlay = () => document.getElementById('reader-overlay'); const content = () => document.getElementById('reader-content'); // Same-page links: the notes of a blog post ("¹" → "#fn1") become references // like in books; other jumps inside the post must not change the app's // address (#books, #feeds …): kept as internal links (the apps follow them). function markLinks(root) { root.querySelectorAll('a[href^="#"]').forEach(a => { const href = a.getAttribute('href'); a.removeAttribute('href'); if (href.length < 2) return; // "#t=754": a second in a podcast's article, never a note. if (!/^#t=\d+$/.test(href) && !_isNoteBacklink(a) && _looksLikeFootnoteLink(a, href)) { a.dataset.footnoteRef = href.slice(1); a.dataset.footnoteHref = href; a.style.cursor = 'pointer'; } else { a.dataset.internalHref = href; } }); // Everything else opens outside. root.querySelectorAll('a[href]').forEach(a => { a.target = '_blank'; a.rel = 'noopener noreferrer'; }); } // How far it is read: down to the bottom of what is on screen (0-1). function fraction() { const c = content(); const ch = c && c.querySelector(':scope > [data-epub-src]'); if (!ch || !ch.offsetHeight) return 0; return Math.max(0, Math.min(1, (c.scrollTop + c.clientHeight - ch.offsetTop) / ch.offsetHeight)); } function report() { if (session && session.onProgress) session.onProgress(fraction()); } function onScroll() { clearTimeout(progressTimer); progressTimer = setTimeout(report, 1200); } function onClick(e) { if (session && session.onClick) session.onClick(e); } // The article's own buttons (star, project, original): in the browser's // header; the apps put them into their reader menu (native-ui.js asks actions()). function renderActions() { const bar = document.querySelector('#reader-overlay .reader-header-actions'); if (!bar) return; let box = bar.querySelector('.reader-article-actions'); if (!box) { box = document.createElement('span'); box.className = 'reader-article-actions'; bar.insertBefore(box, bar.firstChild); } box.textContent = ''; actions().forEach(a => { const b = document.createElement('button'); b.type = 'button'; b.className = 'btn-icon' + (a.active ? ' active' : ''); b.title = a.label; b.textContent = a.icon; b.addEventListener('click', e => { e.stopPropagation(); a.action(); }); box.appendChild(b); }); } function actions() { return session && session.actions ? session.actions().filter(Boolean) : []; } async function open(o) { const ov = overlay(); const c = content(); if (!ov || !c) return; if (session) { report(); stopReadAloud(); // the next article: the voice was reading the last one } else if (ov.style.display !== 'none') closeReader(); // a book was open session = o; loadReaderSettings('article'); _charIndex = null; _readPos = null; currentBookId = null; ov.classList.add('reader-article-mode'); $('reader-title').textContent = o.title || ''; c.textContent = ''; const ch = document.createElement('div'); ch.setAttribute('data-epub-src', 'article'); ch.id = 'epub-chapter-0'; ch.className = 'fd-artview'; ch.innerHTML = o.html; markLinks(ch); c.appendChild(ch); ov.style.display = ''; applyReaderSettings(false); enterReaderImmersiveMode(); renderActions(); c.scrollTop = 0; if (window.DioraPages) DioraPages.opened(); if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter(); const f = o.startFraction || 0; if (f > 0.02 && f < 0.9) { // Back where reading stopped (on any device). await new Promise(r => requestAnimationFrame(r)); if (session !== o) return; _suppressScrollJumpDetect(); c.scrollTop = Math.max(0, f * ch.offsetHeight + ch.offsetTop - c.clientHeight); } else { setTimeout(() => { if (session === o) report(); }, 1500); // short enough to fit: read at once } } // Replaces what is shown (the overview of a story arrived) without moving. function update(html) { const ch = content() && content().querySelector(':scope > [data-epub-src="article"]'); if (!ch || !session) return; session.html = html; ch.innerHTML = html; markLinks(ch); } // closeReader (62-reader.js) calls this before it empties the reader. window._articleReaderClosed = function () { if (!session) return; clearTimeout(progressTimer); report(); const s = session; session = null; overlay().classList.remove('reader-article-mode'); const box = document.querySelector('#reader-overlay .reader-article-actions'); if (box) box.remove(); if (s.onClose) setTimeout(s.onClose, 0); }; window.DioraArticle = { open, update, isOpen: () => !!session, close: () => { if (session) closeReader(); }, actions, refreshActions: renderActions, fraction, }; document.addEventListener('DOMContentLoaded', () => { const c = content(); if (!c) return; c.addEventListener('scroll', () => { if (session) onScroll(); }, {passive: true}); c.addEventListener('click', e => { if (session) onClick(e); }); }); })();