diff --git a/static/js/app.js b/static/js/app.js index 7c0a628..010754f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -5491,40 +5491,59 @@ function _tocTitleForChapterSrc(chapterSrc) { return entry ? entry.label : ''; } +// Rough "how far through the book" position for a resolved range — there's no +// real page concept for reflowable EPUB text, so this is a coordinate of last +// resort: percent of total scrollable content height, good enough to roughly +// relocate a highlight/note later. +function _percentFromRange(range) { + const contentEl = $('reader-content'); + if (!contentEl || !range || !contentEl.scrollHeight) return null; + const contentRect = contentEl.getBoundingClientRect(); + const rangeRect = range.getBoundingClientRect(); + if (!rangeRect.width && !rangeRect.height) return null; + const offsetWithinScroll = (rangeRect.top - contentRect.top) + contentEl.scrollTop; + return Math.max(0, Math.min(100, (offsetWithinScroll / contentEl.scrollHeight) * 100)); +} + +function _sortedAnnotations() { + const resolved = currentHighlights.map(h => { + let range; + try { range = resolveAnchorRange(h); } catch (e) { range = null; } + return {h, range}; + }); + resolved.sort((a, b) => { + if (!a.range || !b.range) return 0; + return a.range.compareBoundaryPoints(Range.START_TO_START, b.range); + }); + return resolved; +} + function exportAnnotations() { if (!currentHighlights.length) { customAlert('No highlights or notes in this book yet.'); return; } - const resolved = []; - for (const h of currentHighlights) { - let range; - try { range = resolveAnchorRange(h); } catch (e) { range = null; } - resolved.push({h, range}); - } - resolved.sort((a, b) => { - if (!a.range || !b.range) return 0; - const pos = a.range.compareBoundaryPoints(Range.START_TO_START, b.range); - return pos; - }); + const resolved = _sortedAnnotations(); const titleEl = $('reader-title'); const bookTitle = (titleEl?.textContent || 'Book').split(' — ')[0]; const lines = [`${bookTitle} — Annotations`, `Exported: ${new Date().toISOString().slice(0, 10)}`, '']; let lastChapter = null; - for (const {h} of resolved) { + for (const {h, range} of resolved) { const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || ''; if (chapterTitle !== lastChapter) { lines.push(`── ${chapterTitle || 'Untitled section'} ──`); lastChapter = chapterTitle; } + const pct = _percentFromRange(range); + const coord = pct !== null ? ` (≈${Math.round(pct)}% im Buch)` : ''; if (h.type === 'note') { - lines.push(`[Note] near: "${(h.anchor?.quote || '').trim()}"`); + lines.push(`[Note]${coord} near: "${(h.anchor?.quote || '').trim()}"`); if (h.note) lines.push(` ${h.note}`); } else { - lines.push(`[Highlight · ${h.color || 'yellow'}] "${(h.anchor?.quote || '').trim()}"`); + lines.push(`[Highlight · ${h.color || 'yellow'}]${coord} "${(h.anchor?.quote || '').trim()}"`); if (h.note) lines.push(` Note: ${h.note}`); } lines.push(''); @@ -5541,6 +5560,66 @@ function exportAnnotations() { URL.revokeObjectURL(url); } +// List of every highlight/note in the book, in reading order, each jumpable — +// the same pattern as the bookmarks sidebar, but for annotations. Unlike the +// margin panel (which only shows what's currently on-screen), this is a full +// overview, useful once a book has annotations scattered throughout. +function openAnnotationsSidebar() { + if (!currentHighlights.length) { + openSidebar('Highlights & Notes', '

No highlights or notes yet.

'); + return; + } + + const resolved = _sortedAnnotations(); + let html = ''; + openSidebar('Highlights & Notes', html); + + const body = $('sidebar-body'); + body.addEventListener('click', function _annClick(e) { + const jumpBtn = e.target.closest('[data-jump-annotation]'); + const delBtn = e.target.closest('[data-delete-annotation]'); + if (jumpBtn) { + body.removeEventListener('click', _annClick); + jumpToAnnotation(jumpBtn.dataset.jumpAnnotation); + } + if (delBtn) { + deleteHighlight(delBtn.dataset.deleteAnnotation); + openAnnotationsSidebar(); // re-render + } + }); +} + +function jumpToAnnotation(id) { + const h = currentHighlights.find(x => x.id === id); + if (!h) return; + closeSidebar(); + _suppressScrollJumpDetect(); + setTimeout(() => { + const contentEl = $('reader-content'); + if (!contentEl) return; + let range; + try { range = resolveAnchorRange(h); } catch (e) { range = null; } + if (range) { + const top = range.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); + } + }, 50); +} + // --------------------------------------------------------------------------- // Footnote popover // --------------------------------------------------------------------------- diff --git a/static/js/sw.js b/static/js/sw.js index e637b77..1493216 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v23'; +const CACHE = 'diora-v24'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', diff --git a/templates/radio/player.html b/templates/radio/player.html index a9c65f4..f5f94b0 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -353,7 +353,8 @@