POST/DELETE /podcasts/episodes/<id>/article/ (podcasts/article.py): legt die Quelle Podcast-Artikel an und erzeugt oder ersetzt das Item der Folge; Sanitizer behält href=#t=<Sekunden>; _item_data und Item-Ansicht liefern episode_id; feeds.js: Folge anhören und Zeitmarken-Links. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
174 lines
6.2 KiB
JavaScript
174 lines
6.2 KiB
JavaScript
'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); });
|
|
});
|
|
})();
|