- 58-pdf.js: _pdfDraw – eine Zeichnung je Canvas (die vorige wird abgebrochen und abgewartet), Pixeldichte, höchstens 16,7 MP; Seiten, die gehen, geben auch pdf.js' Seitendaten frei; ein gemeinsamer pdf.js-Worker (_pdfLoad) auch für Uploads, Seitenzahl, Hefte und Cover; Inhalts- verzeichnis parallel aufgelöst, auch mit Seitenindex als Ziel; ResizeObserver: Drehen, Breite, Leisten – neu an derselben Seite. - 66-pdf-paginated.js: Seitenmodus auf _pdfDraw, Zuschnitt in Bildschirmkoordinaten (gedrehte Seiten) und höchstens 2,5× der ganzen Seite (eine fast leere Seite wird nicht unendlich groß); nächste und vorige Seite vorgezeichnet; Tippen zum Blättern blendet die Leisten nicht mehr ein; Wischen nur deutlich seitwärts; Mausrad; Pinch setzt die Vorschau zurück; nach dem Blättern wird gespeichert und 'diora-pdf-turn' gemeldet (E-Ink-Refresh). - pdfGoToPage für beide Modi: Inhaltsverzeichnis, Seitenzahl im Kopf, Lesezeichen, Tasten (Seitenmodus jetzt auch Leertaste, Bild auf/ab). - Beim Scrollen wird die aktuelle Seite mitgeführt (Lesezeichen sagten immer „Seite 1“). - Schalter Scrollen | Seiten im PDF-Panel; Zoom-Regler zeichnet erst neu, wenn er ruht. - openBook hängt seine Listener über _readerListen an; closeReader nimmt sie ab (vorher sammelten sie sich bei jedem Öffnen an). Abbrechen und ein zweites Buch geben das alte Dokument frei; der entschlüsselte PDF-Puffer wird nicht mehr gehalten. - Invertieren dreht die Farbtöne zurück (Fotos keine Negative). - e2e/fixtures/make_test_pdf.py: Test-PDF mit gemischten Formaten, Outline, Links und einer fast leeren Seite. 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 || parseInt($('reader-progress-input')?.value, 10) || 1;
|
|
label = `Seite ${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);
|
|
}
|