diora-web/static/js/app/65-article.js

175 lines
6.2 KiB
JavaScript
Raw Permalink Normal View History

'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); });
});
})();