Der Lesestand wird gleich beim Öffnen erfragt (parallel zur Datei), ein PDF zeichnet zuerst die Seite, an der es weitergeht, statt Seite 1; Titel, Inhaltsverzeichnis und Seitenzahlen kommen nebenher und halten das Öffnen nicht mehr auf. Eine Zeichnung, die schon läuft, wird abgewartet statt neu begonnen. „Leserfarben“ (neben Originalfarben und Invertieren): die Seite in den Farben des Lesers (Dunkel, Sepia) per SVG-Filter, Bilder darüber wie gedruckt (ihre Lage aus den Zeichenbefehlen); ein Bild, das die halbe Seite füllt, ist ein Scan und wird mit umgefärbt. pdf.js' eigene pageColors färben die Bilder mit um. Ein anderes Design ändert nur die Farben des Filters. Seitenzahlen des Buchs (PageLabels: „iv“, „A-3“) im Kopf mit der Seite der Datei daneben, eingeben lässt sich beides; Lesezeichen und der Zurück-Knopf nennen sie. Das Test-PDF hat dafür Seitenzahlen und ein farbiges Bild bekommen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
5.2 KiB
JavaScript
158 lines
5.2 KiB
JavaScript
'use strict';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bookmarks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function loadBookmarks(bookId) {
|
|
try {
|
|
const res = await fetch(`/books/${bookId}/bookmarks/`);
|
|
const {ct, iv} = await res.json();
|
|
if (ct) {
|
|
_saveAnnotationsCache(bookId, 'bookmarks', ct, iv); // persist for offline
|
|
const key = await getOrCreateEncKey();
|
|
const plain = await decryptBytes(key, iv, ct);
|
|
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
|
|
} else {
|
|
currentBookmarks = [];
|
|
}
|
|
} catch (e) {
|
|
// offline fallback
|
|
const cached = await _loadAnnotationsCache(bookId, 'bookmarks');
|
|
if (cached?.ct) {
|
|
try {
|
|
const key = await getOrCreateEncKey();
|
|
const plain = await decryptBytes(key, cached.iv, cached.ct);
|
|
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
|
|
} catch { currentBookmarks = []; }
|
|
} else {
|
|
currentBookmarks = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
async function saveBookmarks() {
|
|
if (!currentBookId) return;
|
|
try {
|
|
const key = await getOrCreateEncKey();
|
|
const plain = new TextEncoder().encode(JSON.stringify(currentBookmarks));
|
|
const {iv, ciphertext} = await encryptBytes(key, plain);
|
|
const body = JSON.stringify({ct: ciphertext, iv});
|
|
const url = `/books/${currentBookId}/bookmarks/`;
|
|
_lastBookmarkBeacon = {url, body};
|
|
_saveAnnotationsCache(currentBookId, 'bookmarks', ciphertext, iv); // keep local cache current
|
|
await fetch(url, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
body,
|
|
});
|
|
bookmarksDirty = false;
|
|
} catch (e) {
|
|
_markPendingSync(currentBookId, 'bookmarks');
|
|
}
|
|
}
|
|
|
|
function addBookmark() {
|
|
const contentEl = $('reader-content');
|
|
if (!contentEl || !currentBookId) return;
|
|
|
|
let label, anchor, scrollFraction;
|
|
|
|
if (currentPdfDoc) {
|
|
const page = pdfCurrentPage || 1;
|
|
label = `Seite ${pdfPageLabel(page)}`;
|
|
anchor = `pdf-page-${page}`;
|
|
scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1);
|
|
} else {
|
|
// Find first visible chapter div
|
|
const chapters = contentEl.querySelectorAll('[data-epub-src]');
|
|
let visibleChapter = null;
|
|
for (const ch of chapters) {
|
|
const rect = ch.getBoundingClientRect();
|
|
if (rect.bottom > 0 && rect.top < window.innerHeight) {
|
|
visibleChapter = ch;
|
|
break;
|
|
}
|
|
}
|
|
const src = visibleChapter?.getAttribute('data-epub-src') || '';
|
|
label = src.split('/').pop().replace(/\.x?html?$/i, '') || 'Bookmark';
|
|
anchor = src;
|
|
scrollFraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
|
|
}
|
|
|
|
const bm = {
|
|
id: crypto.randomUUID(),
|
|
label,
|
|
anchor,
|
|
scrollFraction,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
currentBookmarks.unshift(bm);
|
|
bookmarksDirty = true;
|
|
saveBookmarks();
|
|
|
|
// Toast
|
|
const toast = document.createElement('div');
|
|
toast.className = 'reader-toast';
|
|
toast.textContent = `★ Lesezeichen: ${label}`;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 2200);
|
|
}
|
|
|
|
function openBookmarksSidebar() {
|
|
if (!currentBookmarks.length) {
|
|
openSidebar('Lesezeichen', '<p class="muted">Noch keine Lesezeichen. Tippe beim Lesen auf ★.</p>');
|
|
return;
|
|
}
|
|
let html = '<ul style="list-style:none;padding:0;">';
|
|
for (const bm of currentBookmarks) {
|
|
html += `<li class="bookmark-entry">
|
|
<button class="btn-link" data-jump-bookmark="${escapeHtml(bm.id)}" style="flex:1;text-align:left;">
|
|
${escapeHtml(bm.label)}
|
|
</button>
|
|
<button class="btn-icon" data-delete-bookmark="${escapeHtml(bm.id)}" title="Löschen">✕</button>
|
|
</li>`;
|
|
}
|
|
html += '</ul>';
|
|
openSidebar('Lesezeichen', html);
|
|
|
|
const body = $('sidebar-body');
|
|
body.addEventListener('click', function _bmClick(e) {
|
|
const jumpBtn = e.target.closest('[data-jump-bookmark]');
|
|
const delBtn = e.target.closest('[data-delete-bookmark]');
|
|
if (jumpBtn) {
|
|
body.removeEventListener('click', _bmClick);
|
|
jumpToBookmark(jumpBtn.dataset.jumpBookmark);
|
|
}
|
|
if (delBtn) {
|
|
const id = delBtn.dataset.deleteBookmark;
|
|
currentBookmarks = currentBookmarks.filter(b => b.id !== id);
|
|
bookmarksDirty = true;
|
|
saveBookmarks();
|
|
openBookmarksSidebar(); // re-render
|
|
}
|
|
});
|
|
}
|
|
|
|
function jumpToBookmark(id) {
|
|
const bm = currentBookmarks.find(b => b.id === id);
|
|
if (!bm) return;
|
|
closeSidebar();
|
|
_suppressScrollJumpDetect();
|
|
setTimeout(() => {
|
|
const contentEl = $('reader-content');
|
|
if (!contentEl) return;
|
|
if (bm.anchor.startsWith('pdf-page-')) {
|
|
pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1);
|
|
} else {
|
|
const target = Array.from(contentEl.querySelectorAll('[data-epub-src]'))
|
|
.find(el => el.getAttribute('data-epub-src') === bm.anchor);
|
|
if (target) {
|
|
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
|
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
|
} else {
|
|
contentEl.scrollTop = bm.scrollFraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
|
}
|
|
}
|
|
}, 50);
|
|
}
|