158 lines
6.3 KiB
JavaScript
158 lines
6.3 KiB
JavaScript
|
|
'use strict';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Export annotations — walks all highlights/notes of the current book in
|
||
|
|
// reading order and offers them as a downloadable plain-text file.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
function _tocTitleForChapterSrc(chapterSrc) {
|
||
|
|
if (!chapterSrc) return '';
|
||
|
|
const entry = currentBookToc.find(t => (t.href || '').split('#')[0] === 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) return null;
|
||
|
|
// Share of the text before the range, like the reading progress.
|
||
|
|
const block = _blockAncestor(range.startContainer);
|
||
|
|
if (block && contentEl.contains(block)) {
|
||
|
|
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
|
||
|
|
const i = blocks.indexOf(block);
|
||
|
|
if (i >= 0) {
|
||
|
|
const idx = _bookCharIndex(contentEl);
|
||
|
|
const at = idx.starts[i] + _charOffsetInBlock(block, range.startContainer, range.startOffset);
|
||
|
|
return Math.max(0, Math.min(100, (at / idx.total) * 100));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!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;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Highlight colours as the export and the list name them.
|
||
|
|
function _hlColorName(color) {
|
||
|
|
return ({yellow: 'gelb', green: 'grün', blue: 'blau', red: 'rot'})[color] || color;
|
||
|
|
}
|
||
|
|
|
||
|
|
function exportAnnotations() {
|
||
|
|
if (!currentHighlights.length) {
|
||
|
|
customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const resolved = _sortedAnnotations();
|
||
|
|
|
||
|
|
const titleEl = $('reader-title');
|
||
|
|
const bookTitle = (titleEl?.textContent || 'Buch').split(' — ')[0];
|
||
|
|
const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, ''];
|
||
|
|
|
||
|
|
let lastChapter = null;
|
||
|
|
for (const {h, range} of resolved) {
|
||
|
|
const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || '';
|
||
|
|
if (chapterTitle !== lastChapter) {
|
||
|
|
lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`);
|
||
|
|
lastChapter = chapterTitle;
|
||
|
|
}
|
||
|
|
const pct = _percentFromRange(range);
|
||
|
|
const coord = pct !== null ? ` (≈${_formatBookPercent(pct / 100)}% im Buch)` : '';
|
||
|
|
if (h.type === 'note') {
|
||
|
|
lines.push(`[Notiz]${coord} bei: „${(h.anchor?.quote || '').trim()}“`);
|
||
|
|
if (h.note) lines.push(` ${h.note}`);
|
||
|
|
} else {
|
||
|
|
lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}]${coord} „${(h.anchor?.quote || '').trim()}“`);
|
||
|
|
if (h.note) lines.push(` Notiz: ${h.note}`);
|
||
|
|
}
|
||
|
|
lines.push('');
|
||
|
|
}
|
||
|
|
|
||
|
|
const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'});
|
||
|
|
const url = URL.createObjectURL(blob);
|
||
|
|
const a = document.createElement('a');
|
||
|
|
a.href = url;
|
||
|
|
a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`;
|
||
|
|
document.body.appendChild(a);
|
||
|
|
a.click();
|
||
|
|
a.remove();
|
||
|
|
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('Markierungen & Notizen', '<p class="muted">Noch keine Markierungen oder Notizen.</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 ? `≈${_formatBookPercent(pct / 100)}%` : '';
|
||
|
|
const kind = h.type === 'note' ? 'Notiz' : `Markierung${h.color ? ' · ' + _hlColorName(h.color) : ''}`;
|
||
|
|
const preview = h.note || h.anchor?.quote || '(kein 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="Löschen">✕</button>
|
||
|
|
</li>`;
|
||
|
|
}
|
||
|
|
html += '</ul>';
|
||
|
|
openSidebar('Markierungen & Notizen', 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);
|
||
|
|
}
|