Reader: Positions-Koordinate im Export + Verzeichnis für Markierungen/Notizen (SW v24)
Echte Buchseiten sind fürs Erste zurückgestellt (kein Konzept dafür bei
reflowable EPUB-Text) — stattdessen bekommt jeder Export-Eintrag jetzt eine
grobe Prozent-Koordinate ("≈37% im Buch"), zusätzlich zur Kapitelgruppierung.
Neu: ein Verzeichnis für Markierungen/Notizen im selben Sidebar-Stil wie die
Lesezeichen (Button in der Margin-Kopfzeile), das alle Einträge in
Lesereihenfolge mit Vorschau und Koordinate auflistet und per Klick an die
jeweilige Textstelle springt — anders als die Margin-Leiste, die nur zeigt,
was gerade sichtbar ist.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
bc180daab0
commit
a2f4be8e1e
3 changed files with 96 additions and 16 deletions
107
static/js/app.js
107
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', '<p class="muted">No highlights or notes yet.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = _sortedAnnotations();
|
||||
let html = '<ul style="list-style:none;padding:0;">';
|
||||
for (const {h, range} of resolved) {
|
||||
const pct = _percentFromRange(range);
|
||||
const pctLabel = pct !== null ? `≈${Math.round(pct)}%` : '';
|
||||
const kind = h.type === 'note' ? 'Note' : `Highlight${h.color ? ' · ' + h.color : ''}`;
|
||||
const preview = h.note || h.anchor?.quote || '(no text)';
|
||||
html += `<li class="bookmark-entry">
|
||||
<button class="btn-link" data-jump-annotation="${escapeHtml(h.id)}" style="flex:1;text-align:left;">
|
||||
<span class="muted" style="font-size:11px;">${escapeHtml(kind)} ${pctLabel}</span><br>
|
||||
${escapeHtml(preview.slice(0, 80))}
|
||||
</button>
|
||||
<button class="btn-icon" data-delete-annotation="${escapeHtml(h.id)}" title="Delete">✕</button>
|
||||
</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -353,7 +353,8 @@
|
|||
<aside id="reader-margin" class="reader-margin">
|
||||
<div class="reader-margin-header">
|
||||
<span class="reader-margin-title">Notes</span>
|
||||
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file">⭳</button>
|
||||
<button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="List all highlights & notes, jump to any of them">▦</button>
|
||||
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file">⭳</button>
|
||||
</div>
|
||||
<div id="reader-margin-markers" class="reader-margin-markers"></div>
|
||||
</aside>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue