'use strict'; // --------------------------------------------------------------------------- // Zeitschriften: das Heft und seine Artikel (docs/library.md, "Heft und Artikel"). // The issue stays the document — read through in the editors' order — and the article // is the unit one moves in: its own state (gelesen / übersprungen), reading time, a // running head ("Artikel 8 von 23") and a pause at its end. Each determines the other: // reading the issue through marks the articles one passes; reading articles moves the // issue on ("7 von 23"); "Im Heft weiterlesen" goes to the next one not read yet. // // The index comes from the file, on the device (the server never sees it): an EPUB's // table of contents (its leaves are the articles, their parents the Rubriken) or a PDF's // outline (page ranges). It is kept in the book's encrypted meta (`issue`), the states // too (`issueRead`, `issueAt`), so every device has them; a state that couldn't be saved // (offline) waits in localStorage and goes with the next save. Chrome-61-safe (tolino). // --------------------------------------------------------------------------- const ISSUE_WPM = 200; // words per minute, for the reading times const _issuePendingKey = 'diora_issue_pending'; function _issueEsc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); } const _issueMeta = b => (b && (bookMetaCache[b.id] || {})) || {}; function _issueIndexOf(b) { const m = _issueMeta(b); const ix = m.issue || (b && b.issue); return ix && ix.a && ix.a.length ? ix.a : null; } // ---- state: {href: 'r' (gelesen) | 's' (übersprungen)} and the article one was in ----------- function _issuePending() { try { return JSON.parse(localStorage.getItem(_issuePendingKey) || '{}') || {}; } catch (e) { return {}; } } function _issueSetPending(all) { try { localStorage.setItem(_issuePendingKey, JSON.stringify(all)); } catch (e) {} } function _issueState(b) { const m = _issueMeta(b); const read = Object.assign({}, m.issueRead || b.issueRead || {}); let at = m.issueAt || b.issueAt || ''; const p = _issuePending()[b.id]; if (p) { Object.keys(p.read || {}).forEach(h => { if (p.read[h]) read[h] = p.read[h]; else delete read[h]; }); if (p.at) at = p.at; } return {read, at}; } // What the issue amounts to: counts, the next article to read, the time still to read. function _issueSummary(b) { const arts = _issueIndexOf(b); if (!arts) return null; const st = _issueState(b); let read = 0, skipped = 0, left = 0; arts.forEach(a => { const s = st.read[a.h]; if (s === 'r') read++; else if (s === 's') skipped++; else left += a.m || 0; }); const at = arts.findIndex(a => a.h === st.at); const open = i => !st.read[arts[i].h]; let next = -1; if (at >= 0 && open(at)) next = at; for (let i = at + 1; next < 0 && i < arts.length; i++) if (open(i)) next = i; for (let i = 0; next < 0 && i < arts.length; i++) if (open(i)) next = i; return {arts, st, total: arts.length, read, skipped, left, next, at, started: at >= 0 && open(at)}; } function _issueMinutes(m) { if (m < 60) return Math.max(1, Math.round(m)) + ' min'; const h = Math.floor(m / 60); const r = Math.round(m - h * 60); return r >= 45 ? 'knapp ' + (h + 1) + ' h' : r >= 15 ? 'gut ' + h + ' h' : h + ' h'; } // One bar for the issue, one segment per article: read (full), skipped (hatched), open // (empty), the one one is in (accent). `now`: index of the article shown right now. function _issueSegments(sum, now, cls) { return `` + sum.arts.map((a, i) => { const s = sum.st.read[a.h]; const k = i === now ? 'iss-s-now' : s === 'r' ? 'iss-s-read' : s === 's' ? 'iss-s-skip' : i === sum.at ? 'iss-s-at' : ''; return ``; }).join('') + ''; } function _issueCounts(sum) { const parts = [`${sum.read} von ${sum.total} gelesen`]; if (sum.skipped) parts.push(`${sum.skipped} übersprungen`); return parts.join(' · '); } let _issueSaveTimer = {}; function _issueMark(bookId, href, state, at) { const all = _issuePending(); const p = all[bookId] || {read: {}}; if (href) p.read[href] = state || ''; if (at) p.at = at; all[bookId] = p; _issueSetPending(all); clearTimeout(_issueSaveTimer[bookId]); _issueSaveTimer[bookId] = setTimeout(() => _issueFlush(bookId), 2500); } async function _issueFlush(bookId) { const all = _issuePending(); const p = all[bookId]; const b = _lastBookListData.find(x => x.id === bookId); if (!p || !b) return; const st = _issueState(b); try { await _updateBookMeta(bookId, {issueRead: st.read, issueAt: st.at}); const now = _issuePending(); if (JSON.stringify(now[bookId]) === JSON.stringify(p)) { delete now[bookId]; _issueSetPending(now); } } catch (e) { /* offline: it waits and goes with the next save */ } } window.addEventListener('online', () => Object.keys(_issuePending()).forEach(id => _issueFlush(Number(id)))); // ---- the index ----------------------------------------------------------------------------------- // The articles of a table of contents: its leaves, each under the nearest entry above it // (the Rubrik); one per target. [{h, t, r}] function _issueArticlesFromToc(toc) { const out = []; const seen = new Set(); const parents = []; for (let i = 0; i < toc.length; i++) { const e = toc[i]; parents[e.depth] = e.label; parents.length = e.depth + 1; const nxt = toc[i + 1]; const leaf = !nxt || nxt.depth <= e.depth; if (!leaf || !e.href || seen.has(e.href)) continue; seen.add(e.href); out.push({h: e.href, t: (e.label || '').trim() || 'Ohne Titel', r: e.depth > 0 ? (parents[e.depth - 1] || '').trim() : ''}); } return out; } function _issueTextStats(text, title) { let t = String(text || '').replace(/\s+/g, ' ').trim(); const words = t ? t.split(' ').length : 0; if (title && t.toLowerCase().indexOf(title.toLowerCase()) === 0) t = t.slice(title.length).trim(); const x = t.length > 110 ? t.slice(0, 110).replace(/\s+\S*$/, '') + ' …' : t; return {x, m: Math.max(1, Math.round(words / ISSUE_WPM))}; } const _issueHtmlText = html => String(html || '') .replace(//gi, ' ') .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'"); async function _issueIndexEpub(bytes) { const zip = await JSZip.loadAsync(bytes); const container = zip.file('META-INF/container.xml'); if (!container) return null; const cdoc = new DOMParser().parseFromString(await container.async('text'), 'application/xml'); const root = cdoc.querySelector('rootfile'); if (!root) return null; const opfPath = root.getAttribute('full-path'); const opfDir = opfPath.indexOf('/') >= 0 ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : ''; const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml'); const manifest = {}; Array.prototype.forEach.call(opfDoc.querySelectorAll('manifest > item'), item => { manifest[item.getAttribute('id')] = {href: opfDir + item.getAttribute('href'), mediaType: item.getAttribute('media-type') || '', properties: item.getAttribute('properties') || ''}; }); const spine = Array.prototype.map.call(opfDoc.querySelectorAll('spine > itemref'), r => (manifest[r.getAttribute('idref')] || {}).href).filter(Boolean); let arts = _issueArticlesFromToc(await _parseEpubToc(zip, opfDoc, manifest)); const files = {}; const fileText = async path => { if (!(path in files)) { const f = zip.file(path) || zip.file(decodeURIComponent(path)); files[path] = f ? await f.async('text') : ''; } return files[path]; }; if (arts.length < 2) { // No usable table of contents: one article per spine file with some text in it. arts = []; for (const path of spine) { const html = await fileText(path); const m = /]*>([\s\S]*?)<\/h[1-3]>/i.exec(html); const text = _issueHtmlText(html); if (text.replace(/\s+/g, '').length < 300) continue; arts.push({h: path, t: m ? _issueHtmlText(m[1]).replace(/\s+/g, ' ').trim() : 'Artikel ' + (arts.length + 1), r: ''}); } } // Each article's text: from its target to the next article's (within a file by the // fragment's place, across files along the spine). const split = h => { const k = h.indexOf('#'); return k >= 0 ? [h.slice(0, k), h.slice(k + 1)] : [h, '']; }; const posIn = (html, frag) => { if (!frag) return 0; const m = new RegExp('id\\s*=\\s*["\']' + frag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '["\']').exec(html); if (!m) return 0; return html.lastIndexOf('<', m.index); }; for (let i = 0; i < arts.length; i++) { const [file, frag] = split(arts[i].h); const [nFile, nFrag] = i + 1 < arts.length ? split(arts[i + 1].h) : ['', '']; let html = ''; const startIdx = spine.indexOf(file); if (nFile === file) { const whole = await fileText(file); html = whole.slice(posIn(whole, frag), nFrag ? posIn(whole, nFrag) : whole.length); } else { const whole = await fileText(file); html = whole.slice(posIn(whole, frag)); const endIdx = nFile ? spine.indexOf(nFile) : spine.length; for (let s = startIdx + 1; startIdx >= 0 && s < (endIdx < 0 ? spine.length : endIdx); s++) html += await fileText(spine[s]); if (nFile && nFrag) { const n = await fileText(nFile); html += n.slice(0, posIn(n, nFrag)); } } Object.assign(arts[i], _issueTextStats(_issueHtmlText(html), arts[i].t)); } return arts; } async function _issueIndexPdf(bytes) { if (typeof pdfjsLib === 'undefined' || typeof _parsePdfOutline !== 'function') return null; const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise; const outline = await pdf.getOutline(); if (!outline || !outline.length) return []; const arts = _issueArticlesFromToc(await _parsePdfOutline(pdf, outline)) .filter(a => /^#pdf-page-\d+$/.test(a.h)); arts.forEach(a => { a.p = parseInt(a.h.slice(10), 10); }); arts.sort((a, b) => a.p - b.p); const pageText = {}; const textOf = async n => { if (!(n in pageText)) { try { const page = await pdf.getPage(n); const c = await page.getTextContent(); pageText[n] = c.items.map(it => it.str).join(' '); } catch (e) { pageText[n] = ''; } } return pageText[n]; }; for (let i = 0; i < arts.length; i++) { const end = i + 1 < arts.length ? Math.max(arts[i].p, arts[i + 1].p - 1) : pdf.numPages; arts[i].e = end; let text = ''; for (let n = arts[i].p; n <= Math.min(end, arts[i].p + 30); n++) text += ' ' + await textOf(n); // Pages shared with the next article: counted for both (the next one starts on it). const s = _issueTextStats(text, arts[i].t); // The teaser: from where the title stands on the first page, if it does. const first = (await textOf(arts[i].p)).replace(/\s+/g, ' '); const k = first.toLowerCase().indexOf(arts[i].t.toLowerCase()); if (k >= 0) s.x = _issueTextStats(first.slice(k + arts[i].t.length), '').x; Object.assign(arts[i], s); } try { pdf.destroy(); } catch (e) {} return arts; } // The file, decrypted (the browser's cache, or — in the apps — their store). async function _issueBytes(id) { let rec = typeof _getCachedBook === 'function' ? await _getCachedBook(id) : null; if (!rec) { const r = await fetch('/books/' + id + '/data/', {credentials: 'same-origin'}); if (r.ok) rec = await r.json(); } if (!rec || !rec.data_ct) return null; return decryptBytes(await getOrCreateEncKey(), rec.data_iv, rec.data_ct); } const _issueBuilding = new Map(); // The index of an issue, built once (and kept in its meta) if it has none yet. function _issueEnsureIndex(bookId) { const b = _lastBookListData.find(x => x.id === bookId); if (!b) return Promise.resolve(null); const have = _issueIndexOf(b); if (have || (_issueMeta(b).issue && _issueMeta(b).issue.none)) return Promise.resolve(have); if (_issueBuilding.has(bookId)) return _issueBuilding.get(bookId); const job = (async () => { const bytes = await _issueBytes(bookId); if (!bytes) return null; const pdf = (_issueMeta(b).type || b.type) === 'pdf'; const arts = pdf ? await _issueIndexPdf(bytes) : await _issueIndexEpub(bytes); if (!arts) return null; // An issue without a usable table of contents stays a whole (and isn't tried again). const issue = arts.length >= 2 ? {v: 1, a: arts} : {v: 1, a: [], none: true}; try { await _updateBookMeta(bookId, {issue}); } catch (e) { bookMetaCache[bookId] = Object.assign({}, bookMetaCache[bookId] || {}, {issue}); } return issue.a.length ? issue.a : null; })().catch(() => null); _issueBuilding.set(bookId, job); job.then(() => _issueBuilding.delete(bookId)); return job; } // Issues still without an index: one after the other, when nothing else goes on (not on // an e-reader: there an issue's index is built when it's opened, or comes from elsewhere). let _issueQueueBusy = false; function _issueIndexLater(books) { if (_issueQueueBusy || (typeof _LIB_EINK === 'function' && _LIB_EINK())) return; const todo = books.filter(b => _isMagazine(b) && b.keyOk !== false && !_issueIndexOf(b) && !(_issueMeta(b).issue && _issueMeta(b).issue.none)); if (!todo.length) return; _issueQueueBusy = true; const idle = window.requestIdleCallback || (fn => setTimeout(fn, 1500)); idle(async () => { const reader = document.getElementById('reader-overlay'); if (reader && reader.style.display !== 'none') { _issueQueueBusy = false; return; } await _issueEnsureIndex(todo[0].id); _issueQueueBusy = false; if (typeof renderBookList === 'function') renderBookList(_lastBookListData); }); } // ---- the library: an issue opened (its contents) -------------------------------------------------- function _issueView(b) { const sum = _issueSummary(b); const back = `
`; const head = `
${_libCover(b, 'iss-cover')} ${_issueEsc(b.title)} ${b.author ? `${_issueEsc(b.author)}` : ''} ${sum ? `${sum.total} Artikel · ${_issueMinutes(sum.arts.reduce((n, a) => n + (a.m || 0), 0))}` : ''}
`; if (!sum) { const none = _issueMeta(b).issue && _issueMeta(b).issue.none; return back + head + (none ? `

Dieses Heft hat kein Inhaltsverzeichnis, aus dem sich Artikel ablesen lassen – es liest sich als Ganzes.

` : '

Das Inhaltsverzeichnis wird gelesen …

'); } const nx = sum.next >= 0 ? sum.arts[sum.next] : null; let h = back + head + `
${_issueSegments(sum, -1)} ${_issueCounts(sum)}${sum.left ? ' · noch ' + _issueMinutes(sum.left) : ''} ${nx ? `` : 'Alles gelesen oder übersprungen.'} ${nx ? '' : ''}
`; let rubrik = null; sum.arts.forEach((a, i) => { if (a.r !== rubrik) { rubrik = a.r; if (rubrik) h += `

${_issueEsc(rubrik)}

`; } const s = sum.st.read[a.h]; const mark = s === 'r' ? '✓' : s === 's' ? '–' : i === sum.at ? '◐' : '○'; const cls = s === 'r' ? ' iss-read' : s === 's' ? ' iss-skip' : i === sum.next ? ' iss-next' : ''; h += `
`; }); return h; } // What to open for "Im Heft weiterlesen": the article one was in (at its place), else the next one open. function _issueContinue(bookId) { const b = _lastBookListData.find(x => x.id === bookId); const sum = b && _issueSummary(b); if (!sum || sum.next < 0) { openBook(bookId); return; } if (sum.started && sum.next === sum.at) { openBook(bookId); return; } _issueOpenAt(bookId, sum.next); } async function _issueOpenAt(bookId, index) { const b = _lastBookListData.find(x => x.id === bookId); const arts = b && _issueIndexOf(b); if (!arts || !arts[index]) { openBook(bookId); return; } _issueMark(bookId, null, null, arts[index].h); await openBook(bookId); if (currentBookId !== bookId) return; _issueJump(arts[index]); } function _issueJump(a) { if (a.p && typeof currentPdfDoc !== 'undefined' && currentPdfDoc && readerSettings.pdfPaginated && typeof enterPdfPaginatedMode === 'function') { if (typeof pdfGoToPage === 'function') pdfGoToPage(a.p); else { pdfCurrentPage = a.p; enterPdfPaginatedMode(); } return; } if (typeof jumpToTocEntry === 'function') jumpToTocEntry(a.h); } function _issueSheet(bookId, i) { const b = _lastBookListData.find(x => x.id === bookId); const sum = b && _issueSummary(b); if (!sum) return; const a = sum.arts[i]; const s = sum.st.read[a.h]; const set = v => { _issueMark(bookId, a.h, v); renderBookList(_lastBookListData); }; const items = [ ['Lesen', () => _issueOpenAt(bookId, i)], s === 'r' ? ['Als ungelesen markieren', () => set('')] : ['Als gelesen markieren', () => set('r')], s === 's' ? ['Nicht mehr überspringen', () => set('')] : ['Überspringen', () => set('s')], ]; const wrap = document.createElement('div'); wrap.className = 'dfo-scrim'; wrap.innerHTML = ``; wrap.addEventListener('click', e => { const t = e.target.closest('[data-i]'); if (e.target === wrap || e.target.closest('[data-x]')) { wrap.remove(); return; } if (t) { wrap.remove(); items[Number(t.dataset.i)][1](); } }); document.body.appendChild(wrap); } function _issueFinish(bookId, reopen) { const b = _lastBookListData.find(x => x.id === bookId); const sum = b && _issueSummary(b); if (!sum) return; sum.arts.forEach(a => { const s = sum.st.read[a.h]; if (!reopen && !s) _issueMark(bookId, a.h, 's'); if (reopen && s === 's') _issueMark(bookId, a.h, ''); }); renderBookList(_lastBookListData); } (function () { const listEl = document.getElementById('book-list'); if (!listEl) return; listEl.addEventListener('click', e => { const id = _lib.issue; if (id == null) return; const act = e.target.closest('[data-issue-act]'); const mark = e.target.closest('[data-issue-mark]'); const art = e.target.closest('[data-issue-art]'); if (!act && !mark && !art) return; e.stopPropagation(); if (art) { _issueOpenAt(id, Number(art.dataset.issueArt)); return; } if (mark) { _issueSheet(id, Number(mark.dataset.issueMark)); return; } const a = act.dataset.issueAct; if (a === 'up') { _lib.issue = null; renderBookList(_lastBookListData); } else if (a === 'continue') _issueContinue(id); else if (a === 'read') openBook(id); else if (a === 'finish') _issueFinish(id, false); else if (a === 'reopen') _issueFinish(id, true); }); })(); // Opened from the library: its contents (built first if needed). function _issueOpen(bookId) { _lib.issue = bookId; renderBookList(_lastBookListData); const sc = document.querySelector('.main-content'); if (sc) sc.scrollTop = 0; window.scrollTo(0, 0); _issueEnsureIndex(bookId).then(() => { if (_lib.issue === bookId) renderBookList(_lastBookListData); }); } // ---- the reader: the running head, the article one is in, the pause at its end ---------------- let _issueR = null; // {bookId, pdf, arts, els, cur, seen} function _issueRunLabel() { if (!_issueR || _issueR.cur < 0) return ''; const a = _issueR.arts[_issueR.cur]; return `Artikel ${_issueR.cur + 1}/${_issueR.arts.length} · ${a.t}`; } window._issueRunLabel = _issueRunLabel; async function _issueReaderOpened(bookId, isPdf) { _issueReaderClosed(); const b = _lastBookListData.find(x => x.id === bookId); if (!b || !_isMagazine(b)) return; const arts = _issueIndexOf(b) || await _issueEnsureIndex(bookId); if (!arts || currentBookId !== bookId) return; const contentEl = document.getElementById('reader-content'); _issueR = {bookId, pdf: !!isPdf, arts, els: [], cur: -1, seen: new Set(), shown: new Set()}; if (!isPdf) { const target = h => { const k = h.indexOf('#'); const file = k >= 0 ? h.slice(0, k) : h; const frag = k >= 0 ? h.slice(k + 1) : ''; let el = null; if (frag) { try { el = contentEl.querySelector('#' + CSS.escape(frag)); } catch (e) {} } if (!el) el = Array.prototype.find.call(contentEl.querySelectorAll('[data-epub-src]'), c => c.getAttribute('data-epub-src') === file) || null; return el; }; _issueR.els = arts.map(a => target(a.h)); // The pauses push the text down: the place shown stays where it is. const anchor = typeof getPositionAnchor === 'function' ? getPositionAnchor(contentEl) : ''; // A pause at each article's end: before the next one starts (inside its chapter, so an // e-reader showing one chapter at a time shows it with the article). arts.forEach((a, i) => { const el = _issueR.els[i]; if (!el) return; const aside = document.createElement('aside'); aside.className = 'iss-end'; aside.dataset.issueEnd = i; aside.innerHTML = _issueEndHtml(i); const next = _issueR.els[i + 1]; if (next && !next.hasAttribute('data-epub-src')) next.parentNode.insertBefore(aside, next); else { const chapters = contentEl.querySelectorAll(':scope > [data-epub-src]'); let ch = next ? next.previousElementSibling : chapters[chapters.length - 1]; while (ch && !ch.hasAttribute('data-epub-src')) ch = ch.previousElementSibling; (ch || el.closest('[data-epub-src]') || contentEl).appendChild(aside); } }); contentEl.addEventListener('click', _issueEndClick); if (anchor && typeof restoreFromAnchor === 'function') { if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect(); restoreFromAnchor(contentEl, anchor); } } else { // Pages turned one by one (paginated PDF) don't scroll: look now and then. _issueR.poll = setInterval(_issueTrack, 1200); } _issueEnsureHead(); contentEl.addEventListener('scroll', _issueScrollLater, {passive: true}); _issueTrack(); } function _issueReaderClosed() { if (!_issueR) return; const contentEl = document.getElementById('reader-content'); if (contentEl) { contentEl.removeEventListener('scroll', _issueScrollLater); contentEl.removeEventListener('click', _issueEndClick); } const head = document.getElementById('iss-runhead'); if (head) head.remove(); const bar = document.getElementById('iss-pdf-end'); if (bar) bar.remove(); const id = _issueR.bookId; clearInterval(_issueR.poll); _issueR = null; if (_issuePending()[id]) _issueFlush(id); } function _issueEnsureHead() { let head = document.getElementById('iss-runhead'); if (!head) { head = document.createElement('div'); head.id = 'iss-runhead'; const header = document.querySelector('#reader-overlay .reader-header'); if (header && header.parentNode) header.parentNode.insertBefore(head, header.nextSibling); else return; } _issueRenderHead(); } function _issueRenderHead() { const head = document.getElementById('iss-runhead'); const b = _issueR && _lastBookListData.find(x => x.id === _issueR.bookId); const sum = b && _issueSummary(b); if (!head || !sum) return; const a = _issueR.cur >= 0 ? sum.arts[_issueR.cur] : null; head.innerHTML = `${a && a.r ? `${_issueEsc(a.r)}` : ''} ${a ? `Artikel ${_issueR.cur + 1} von ${sum.total} · ${_issueEsc(a.t)}` : `${sum.total} Artikel`} ${_issueSegments(sum, _issueR.cur, 'iss-seg-thin')}`; if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter(); } let _issueScrollTimer = 0; function _issueScrollLater() { clearTimeout(_issueScrollTimer); _issueScrollTimer = setTimeout(_issueTrack, 160); } // Which article is shown, which ends were reached: an article one was in and whose end // came into view counts as read. function _issueTrack() { if (!_issueR) return; const contentEl = document.getElementById('reader-content'); if (!contentEl) return; let cur = _issueR.cur; if (_issueR.pdf) { const page = typeof pdfCurrentPage !== 'undefined' && readerSettings.pdfPaginated ? pdfCurrentPage : _issuePdfPage(contentEl); let c = -1; _issueR.arts.forEach((a, i) => { if (a.p <= page) c = i; }); const prev = _issueR.cur; const last = _issueR.page; cur = c; // Read on from an article's last page onto the next one: that article was read // (a jump — the contents, "Weiter" — reads nothing). if (prev >= 0 && cur === prev + 1 && last != null && page === last + 1 && last >= (_issueR.arts[prev].e || last) && _issueR.seen.has(prev)) _issueEnded(prev); _issueR.page = page; } else { const r = contentEl.getBoundingClientRect(); const top = r.top + 60; let c = -1, best = -Infinity; _issueR.els.forEach((el, i) => { if (!el || !el.getClientRects().length) return; const t = el.getBoundingClientRect().top; if (t <= top && t > best) { best = t; c = i; } }); if (c >= 0) cur = c; contentEl.querySelectorAll('aside.iss-end').forEach(aside => { const i = Number(aside.dataset.issueEnd); if (!aside.getClientRects().length) return; const ar = aside.getBoundingClientRect(); if (ar.top < r.bottom - 40 && ar.bottom > r.top) { if (_issueR.seen.has(i) && !_issueR.shown.has(i)) { _issueR.shown.add(i); _issueEnded(i); } else _issueRenderEnd(aside, i); } }); } if (cur !== _issueR.cur && cur >= 0) { _issueR.cur = cur; _issueR.seen.add(cur); _issueMark(_issueR.bookId, null, null, _issueR.arts[cur].h); } _issueRenderHead(); } function _issuePdfPage(contentEl) { const top = contentEl.getBoundingClientRect().top + 20; const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); for (const w of wrappers) { if (w.getBoundingClientRect().bottom > top) return parseInt(w.id.replace('pdf-page-', ''), 10) || 1; } return 1; } // The end of article i reached: read (unless already decided otherwise). function _issueEnded(i) { const b = _lastBookListData.find(x => x.id === _issueR.bookId); const st = b && _issueState(b); const a = _issueR.arts[i]; if (st && !st.read[a.h]) _issueMark(_issueR.bookId, a.h, 'r'); if (_issueR.pdf) _issuePdfEnd(i); else { const aside = document.querySelector(`#reader-content aside.iss-end[data-issue-end="${i}"]`); if (aside) _issueRenderEnd(aside, i); } } // The next article to read after i, skipping those read or skipped. function _issueNextAfter(sum, i) { for (let k = i + 1; k < sum.arts.length; k++) if (!sum.st.read[sum.arts[k].h]) return k; for (let k = 0; k < i; k++) if (!sum.st.read[sum.arts[k].h]) return k; return -1; } function _issueEndHtml(i) { const b = _lastBookListData.find(x => x.id === _issueR.bookId); const sum = _issueSummary(b); const a = sum.arts[i]; const s = sum.st.read[a.h]; const n = _issueNextAfter(sum, i); // On a PDF the next article is usually the page one just turned to: no need to offer it. const na = n >= 0 && !(_issueR.pdf && n === _issueR.cur) ? sum.arts[n] : null; const jumped = n > i + 1 ? `„Weiter“ überspringt ${n - i - 1 === 1 ? 'Artikel ' + (i + 2) : 'die Artikel ' + (i + 2) + '–' + n} (schon gelesen oder übersprungen).` : ''; return `${s === 'r' ? '✓ ' : ''}„${_issueEsc(a.t)}“${s === 'r' ? ' gelesen' : s === 's' ? ' übersprungen' : ' – Ende'} ${_issueEsc(b.title)}: ${_issueCounts(sum)}${sum.left ? ' · noch ' + _issueMinutes(sum.left) : ''} ${na ? `${jumped}` : 'Das Heft ist durch.'} ${na && n === i + 1 ? `` : ''} ${s === 'r' ? `` : ''} `; } function _issueRenderEnd(aside, i) { const html = _issueEndHtml(i); if (aside.innerHTML !== html) aside.innerHTML = html; } function _issuePdfEnd(i) { let bar = document.getElementById('iss-pdf-end'); if (!bar) { bar = document.createElement('aside'); bar.id = 'iss-pdf-end'; bar.className = 'iss-end iss-end-float'; bar.addEventListener('click', _issueEndClick); document.body.appendChild(bar); } bar.innerHTML = `` + _issueEndHtml(i); clearTimeout(bar.timer); bar.timer = setTimeout(() => bar.remove(), 12000); } function _issueEndClick(e) { const t = e.target.closest('[data-issue-end-act]'); if (!t || !_issueR) return; e.stopPropagation(); e.preventDefault(); const id = _issueR.bookId; const act = t.dataset.issueEndAct; const bar = document.getElementById('iss-pdf-end'); if (act === 'close') { if (bar) bar.remove(); return; } if (act === 'next') { if (bar) bar.remove(); const n = Number(t.dataset.n); _issueR.seen.add(n); _issueJump(_issueR.arts[n]); } else if (act === 'toc') { if (bar) bar.remove(); closeReader(); if (window.DioraNav) DioraNav.go('magazines'); _issueOpen(id); } else if (act === 'skip') { _issueMark(id, _issueR.arts[Number(t.dataset.n)].h, 's'); } else if (act === 'unread') { _issueMark(id, _issueR.arts[Number(t.dataset.i)].h, ''); } setTimeout(() => { if (!_issueR) return; document.querySelectorAll('aside.iss-end[data-issue-end]').forEach(a => _issueRenderEnd(a, Number(a.dataset.issueEnd))); if (bar && bar.isConnected) bar.remove(); _issueRenderHead(); }, 0); } // ---- styles --------------------------------------------------------------------------------------- (function () { const style = document.createElement('style'); style.textContent = [ '.iss-seg { display: flex; gap: 2px; height: 8px; }', '.iss-seg > span { flex: 1; min-width: 2px; border-radius: 2px; background: var(--border, #2a2a2a); }', '.iss-seg > .iss-s-read { background: var(--fg, #eee); }', '.iss-seg > .iss-s-skip { background: repeating-linear-gradient(135deg, var(--muted, #9a9a9a) 0 2px, var(--border, #2a2a2a) 2px 4px); }', '.iss-seg > .iss-s-at { background: linear-gradient(90deg, var(--accent, #e63946) 45%, var(--border, #2a2a2a) 45%); }', '.iss-seg > .iss-s-now { background: var(--accent, #e63946); flex: 2.5; }', '.iss-seg-thin { height: 5px; }', '#book-list .lib-card .iss-seg, #book-list .lib-row .iss-seg, #book-list .lib-continue .iss-seg { margin-top: 4px; }', '#book-list .iss-top { display: flex; align-items: center; justify-content: space-between; margin: 0 0 8px; }', '#book-list .iss-back { min-height: 44px; padding: 0 6px; border: none; background: none; color: inherit; font: inherit; font-size: 15px; cursor: pointer; }', '#book-list .iss-top .iss-more { position: static; opacity: 1; border: none; background: none; color: inherit; font-size: 20px; width: 44px; height: 44px; }', '#book-list.iss-open .lib-head, #book-list.iss-open .lib-chips { display: none; }', '#book-list .iss-head { display: flex; gap: 14px; margin: 0 0 12px; }', '#book-list .iss-cover { flex: none; width: 96px; height: 144px; border-radius: 4px; object-fit: cover; }', '#book-list .iss-head-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }', '#book-list .iss-title { font-size: 21px; font-weight: 700; line-height: 1.25; }', '#book-list .iss-sum { display: flex; flex-direction: column; gap: 8px; margin: 0 0 8px; }', '#book-list .iss-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }', '.iss-go { min-height: 44px; padding: 0 18px; border: none; border-radius: 22px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }', '.iss-btn { min-height: 40px; padding: 0 14px; border: 1px solid var(--border-strong, #444); border-radius: 20px; background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }', '#book-list .iss-rubrik { margin: 18px 0 2px; font-size: 12px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--muted, #9a9a9a); }', '#book-list .iss-row { display: flex; align-items: center; border-bottom: 1px solid var(--border, #2a2a2a); }', '#book-list .iss-row.iss-next { background: var(--card, #0b0b0b); border: 1px solid var(--accent, #e63946); border-radius: 8px; }', '#book-list .iss-mark { flex: none; width: 40px; min-height: 48px; border: none; background: none; color: var(--muted, #9a9a9a); font: inherit; font-size: 16px; cursor: pointer; }', '#book-list .iss-read .iss-mark { color: var(--fg, #eee); }', '#book-list .iss-next .iss-mark { color: var(--accent, #e63946); }', '#book-list .iss-art { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 9px 8px 9px 0; border: none; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }', '#book-list .iss-n { flex: none; width: 22px; font-size: 12px; color: var(--muted, #9a9a9a); text-align: right; }', '#book-list .iss-art-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }', '#book-list .iss-art-title { font-size: 15px; line-height: 1.3; }', '#book-list .iss-next .iss-art-title { font-weight: 700; }', '#book-list .iss-read .iss-art-title { color: var(--muted, #9a9a9a); }', '#book-list .iss-skip .iss-art-title { color: var(--muted, #9a9a9a); text-decoration: line-through; }', '#book-list .iss-teaser { font-size: 12px; color: var(--muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }', '#book-list .iss-min { flex: none; font-size: 12px; color: var(--muted, #9a9a9a); }', '#iss-runhead { display: flex; flex-direction: column; gap: 4px; padding: 4px 16px 6px; border-bottom: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); }', 'body.reader-immersive #iss-runhead { display: none; }', '.iss-rh-text { display: flex; flex-direction: column; min-width: 0; }', '.iss-rh-rubrik { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted, #9a9a9a); }', '.iss-rh-title { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }', 'aside.iss-end { display: flex; flex-direction: column; gap: 12px; margin: 2.5em 0; padding: 16px; border: 1px solid currentColor; border-radius: 12px; font-family: system-ui, sans-serif; font-size: 15px; line-height: 1.35; text-indent: 0; }', '.iss-end-state { display: flex; flex-direction: column; gap: 3px; }', '.iss-end-next { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border: none; border-radius: 10px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font: inherit; text-align: left; cursor: pointer; }', '.iss-end-kicker { font-size: 12px; opacity: .9; }', '.iss-end-title { font-size: 17px; font-weight: 600; }', '.iss-end-hint { font-size: 12px; opacity: .75; }', '.iss-end-actions { display: flex; flex-wrap: wrap; gap: 8px; }', '.iss-end-float { position: fixed; left: 12px; right: 12px; bottom: 16px; z-index: 960; margin: 0; background: var(--panel, #161616); color: var(--fg, #eee); box-shadow: 0 4px 18px rgba(0,0,0,.4); }', '.iss-end-x { position: absolute; right: 6px; top: 6px; }', 'body.eink .iss-seg > span { background: #fff; outline: 1px solid #000; outline-offset: -1px; border-radius: 0; }', 'body.eink .iss-seg > .iss-s-read { background: #000; }', 'body.eink .iss-seg > .iss-s-skip { background: repeating-linear-gradient(135deg, #000 0 1px, #fff 1px 4px); }', 'body.eink .iss-seg > .iss-s-at, body.eink .iss-seg > .iss-s-now { background: linear-gradient(90deg, #000 50%, #fff 50%); }', 'body.eink .iss-go, body.eink .iss-end-next { background: #000; color: #fff; }', 'body.eink #book-list .iss-row.iss-next { background: #fff; border: 2px solid #000; }', 'body.eink #book-list .iss-row, body.eink #iss-runhead { border-color: #000; }', 'body.eink .iss-end-float { background: #fff; color: #000; border: 2px solid #000; box-shadow: none; }', ].join('\n'); document.head.appendChild(style); })();