Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
621 lines
57 KiB
JavaScript
621 lines
57 KiB
JavaScript
'use strict';
|
||
// ---- Books: an EPUB, PDF and DOCX reader. The library, covers, reading position, bookmarks, notes and highlights live on the server (per person), so
|
||
// they follow you between devices. Reading uses epub.js, pdf.js and mammoth from /vendor/reader, loaded only when a book is opened or added.
|
||
const rdApi = (p, o) => jfetch('/api/read' + p, o);
|
||
const rdJson = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||
const RD_LIBS = { epub: ['jszip.min.js', 'epub.min.js'], pdf: ['pdf.min.js'], docx: ['mammoth.browser.min.js'], mobi: ['mobi-parser.min.js'] };
|
||
const rdKind = ext => ['mobi', 'azw', 'azw3'].includes(ext) ? 'mobi' : ext;
|
||
const rdLoaded = {};
|
||
async function rdLibs(kind) {
|
||
for (const f of RD_LIBS[kind]) {
|
||
rdLoaded[f] = rdLoaded[f] || new Promise((ok, no) => { const s = document.createElement('script'); s.src = '/vendor/reader/' + f; s.onload = ok; s.onerror = () => { delete rdLoaded[f]; no(new Error('Could not load the ' + kind.toUpperCase() + ' reader')); }; document.head.appendChild(s); });
|
||
await rdLoaded[f];
|
||
}
|
||
if (kind === 'pdf') pdfjsLib.GlobalWorkerOptions.workerSrc = '/vendor/reader/pdf.worker.min.js';
|
||
}
|
||
const RD_THEMES = { light: { bg: '#ffffff', fg: '#1b1b1b', link: '#1a5fd0' }, sepia: { bg: '#f4ecd8', fg: '#4b3a2a', link: '#8a4b14' }, dark: { bg: '#15171b', fg: '#d5d8de', link: '#8fb0ff' } };
|
||
const RD_FONTS = { '': 'Publisher default', serif: 'Serif', sans: 'Sans-serif', mono: 'Monospace' };
|
||
const RD_FAMILY = { serif: 'Georgia, "Times New Roman", serif', sans: '-apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', mono: 'ui-monospace, Menlo, Consolas, monospace' };
|
||
const RD_COLORS = { yellow: '#ffd43b', green: '#69db7c', blue: '#74c0fc', pink: '#f783ac' };
|
||
const rdDefaults = { theme: 'auto', size: 19, font: '', lh: 1.55, margin: 2, flow: 'paged', zoom: 1 };
|
||
const rdPrefs = () => { try { return { ...rdDefaults, ...JSON.parse(localStorage.getItem('rd-prefs') || '{}') }; } catch { return { ...rdDefaults }; } };
|
||
const rdSavePrefs = p => { try { localStorage.setItem('rd-prefs', JSON.stringify(p)); } catch {} };
|
||
const rdTheme = p => RD_THEMES[p.theme] || (matchMedia('(prefers-color-scheme: dark)').matches ? RD_THEMES.dark : RD_THEMES.light);
|
||
const rdUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||
const rdTitleOf = f => f.replace(/\.[^.]+$/, '').replace(/[_]+/g, ' ');
|
||
|
||
// ======================================================= Library
|
||
async function viewReader(args = []) {
|
||
if (args[0] === 'open' && args[1]) return rdOpen(args[1]);
|
||
const v = $('#view');
|
||
v.innerHTML = '<div class="wrap"><h1>Books</h1><p class="empty">Loading…</p></div>';
|
||
try { await rdLibrary(); } catch (e) {
|
||
if (!location.hash.startsWith('#/read')) return;
|
||
v.innerHTML = `<div class="wrap"><h1>Books</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rdretry">Try again</button></div></div>`;
|
||
$('#rdretry').onclick = () => route();
|
||
}
|
||
}
|
||
|
||
async function rdLibrary() {
|
||
const books = await rdApi('/list');
|
||
if (!location.hash.startsWith('#/read')) return;
|
||
let q = '', sort = localStorage.getItem('rd-sort') || 'recent';
|
||
const v = $('#view');
|
||
const cover = b => `<span class="vimg rdcover" style="aspect-ratio:2/3">${b.cover ? `<img loading="lazy" alt="" src="/api/read/book/${b.id}/cover" onerror="this.remove()">` : ''}<em>${esc(b.title)}</em><span class="rdext">${esc(b.ext)}</span>${b.pct > 0 ? `<u class="rdprog"><s style="width:${Math.round(b.pct * 100)}%"></s></u>` : ''}</span>`;
|
||
const card = b => `<a class="vcard" href="#/read/open/${b.id}" data-id="${b.id}">${cover(b)}<b>${esc(b.title)}</b><small>${esc(b.author || b.ext.toUpperCase())}${b.pct > 0 ? ' · ' + Math.round(b.pct * 100) + '%' : ''}</small></a>`;
|
||
v.innerHTML = `<div class="wrap" id="rdlib"><div class="ptop"><h1>Books</h1><span class="pspace grow"></span><button class="btn" id="rdadd">${icon('plus')}Add books</button></div>
|
||
<input type="file" id="rdfile" accept=".epub,.pdf,.docx,.mobi,.azw,.azw3" multiple hidden>
|
||
<div id="rdbody"></div><p class="sub" id="rdhint" style="margin-top:16px">EPUB, PDF, DOCX, MOBI and AZW3 (without DRM). Add files with the button or drop them on this page. Your place, bookmarks and highlights follow you to every device.</p></div>`;
|
||
const draw = () => {
|
||
const f = q.trim().toLowerCase(), mine = books.filter(b => !f || (b.title + ' ' + b.author).toLowerCase().includes(f));
|
||
const sorted = [...mine].sort(sort === 'title' ? (a, b) => a.title.localeCompare(b.title) : sort === 'author' ? (a, b) => (a.author || '~').localeCompare(b.author || '~') || a.title.localeCompare(b.title) : sort === 'added' ? (a, b) => b.added - a.added : (a, b) => (b.read || b.added) - (a.read || a.added));
|
||
const cont = !f ? books.filter(b => b.read && b.pct < 0.98).sort((a, b) => b.read - a.read).slice(0, 8) : [];
|
||
$('#rdbody').innerHTML = !books.length ? `<p class="empty">No books yet. Add an EPUB, PDF or DOCX file to start reading.</p>` :
|
||
`<div class="bar" style="align-items:center"><input type="search" id="rdq" placeholder="Search your books" value="${esc(q)}" style="flex:1 1 200px;width:auto" autocomplete="off">
|
||
<select id="rdsort" aria-label="Sort"><option value="recent">Recently read</option><option value="added">Recently added</option><option value="title">Title</option><option value="author">Author</option></select></div>
|
||
${cont.length ? `<h2>Continue reading</h2><div class="vgrid">${cont.map(card).join('')}</div>` : ''}
|
||
<h2>${f ? 'Results' : 'Library'} <small style="text-transform:none;letter-spacing:0">(${sorted.length})</small></h2>
|
||
${sorted.length ? `<div class="vgrid">${sorted.map(card).join('')}</div>` : '<p class="empty">Nothing matches.</p>'}`;
|
||
const qi = $('#rdq'); if (qi) { qi.oninput = () => { q = qi.value; const pos = qi.selectionStart; draw(); const n = $('#rdq'); n.focus(); n.setSelectionRange(pos, pos); }; $('#rdsort').value = sort; $('#rdsort').onchange = e => { sort = e.target.value; try { localStorage.setItem('rd-sort', sort); } catch {} draw(); }; }
|
||
document.querySelectorAll('#rdbody .vcard').forEach(a => {
|
||
const open = e => { e.preventDefault(); rdBookMenu(books.find(b => b.id === a.dataset.id)); };
|
||
a.addEventListener('contextmenu', open);
|
||
let t; a.addEventListener('touchstart', () => { t = setTimeout(() => { t = 0; a.dataset.lp = '1'; open({ preventDefault() {} }); }, 600); }, { passive: true });
|
||
a.addEventListener('touchend', e => { clearTimeout(t); if (a.dataset.lp) { delete a.dataset.lp; e.preventDefault(); } });
|
||
a.addEventListener('touchmove', () => clearTimeout(t), { passive: true });
|
||
});
|
||
};
|
||
draw();
|
||
const input = $('#rdfile');
|
||
$('#rdadd').onclick = () => input.click();
|
||
input.onchange = () => { rdAddAll([...input.files]); input.value = ''; };
|
||
const wrap = $('#rdlib');
|
||
wrap.ondragover = e => { if ([...(e.dataTransfer.types || [])].includes('Files')) { e.preventDefault(); wrap.classList.add('drop'); } };
|
||
wrap.ondragleave = () => wrap.classList.remove('drop');
|
||
wrap.ondrop = e => { e.preventDefault(); wrap.classList.remove('drop'); rdAddAll([...e.dataTransfer.files]); };
|
||
$('#rdhint').insertAdjacentHTML('beforeend', ' Press and hold, or right-click, a book for more.');
|
||
}
|
||
|
||
function rdBookMenu(b) {
|
||
if (!b) return;
|
||
actionSheet(b.title, b.author || b.ext.toUpperCase(), [
|
||
{ icon: 'book', label: 'Open', run: () => { location.hash = '#/read/open/' + b.id; } },
|
||
{ icon: 'edit', label: 'Edit title and author', run: () => rdEditBook(b) },
|
||
{ icon: 'dl', label: 'Download', run: () => { const a = document.createElement('a'); a.href = `/api/read/book/${b.id}/file`; a.download = b.title + '.' + b.ext; document.body.appendChild(a); a.click(); a.remove(); } },
|
||
{ icon: 'trash', label: 'Delete from library', danger: true, run: () => confirmSheet('Delete this book?', `"${b.title}" and your bookmarks, notes and highlights in it are removed.`, 'Delete', async () => {
|
||
try { await rdApi('/book/' + b.id, { method: 'DELETE' }); toast('Deleted'); route(); } catch (e) { toast(e.message); } }) },
|
||
]);
|
||
}
|
||
function rdEditBook(b) {
|
||
const el = openSheet('Edit book', '', `<form><label>Title</label><input id="rdt" value="${esc(b.title)}" maxlength="200"><label>Author</label><input id="rda" value="${esc(b.author || '')}" maxlength="200">
|
||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||
el.querySelector('#sx').onclick = closeSheet;
|
||
el.querySelector('form').onsubmit = async e => { e.preventDefault(); try { await rdApi('/book/' + b.id, rdJson('PATCH', { title: el.querySelector('#rdt').value, author: el.querySelector('#rda').value })); closeSheet(); route(); } catch (err) { toast(err.message); } };
|
||
}
|
||
|
||
// ---- Adding books: the file goes up as it is; the browser reads the title, author and cover out of it afterwards.
|
||
async function rdAddAll(files) {
|
||
files = files.filter(f => /\.(epub|pdf|docx|mobi|azw3?)$/i.test(f.name));
|
||
if (!files.length) return toast('Choose EPUB, PDF, DOCX, MOBI or AZW3 files');
|
||
let added = 0, failed = 0, why = '';
|
||
for (const [i, f] of files.entries()) {
|
||
toast(`Adding ${i + 1} of ${files.length}: ${f.name}`);
|
||
try { if (!(await rdAddOne(f)).duplicate) added++; } catch (e) { failed++; why = f.name + ': ' + e.message; toast(why); await new Promise(r => setTimeout(r, 1800)); }
|
||
}
|
||
toast(added ? `Added ${added} book${added > 1 ? 's' : ''}${failed ? ', ' + failed + ' failed' : ''}` : failed ? (files.length === 1 ? why : 'Nothing was added') : 'Already in your library');
|
||
if (location.hash.startsWith('#/read') && !location.hash.startsWith('#/read/open')) route();
|
||
}
|
||
async function rdAddOne(file) {
|
||
const buf = await file.arrayBuffer(), ext = file.name.split('.').pop().toLowerCase();
|
||
const book = await rdApi('/upload?name=' + encodeURIComponent(file.name), { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: buf });
|
||
if (book.duplicate) return book;
|
||
try {
|
||
const meta = await rdMeta(ext, buf);
|
||
if (meta.title || meta.author) await rdApi('/book/' + book.id, rdJson('PATCH', { title: meta.title || undefined, author: meta.author || undefined }));
|
||
if (meta.cover) await rdApi('/book/' + book.id + '/cover', { method: 'PUT', headers: { 'content-type': meta.cover.type }, body: meta.cover });
|
||
} catch { /* the book is added anyway, with its file name as the title */ }
|
||
return book;
|
||
}
|
||
async function rdMeta(ext, buf) {
|
||
if (ext === 'epub') {
|
||
await rdLibs('epub'); const b = ePub(buf.slice(0)); const out = {};
|
||
try {
|
||
const md = await b.loaded.metadata; out.title = md.title; out.author = md.creator;
|
||
const cu = await b.coverUrl(); if (cu) { const blob = await (await fetch(cu)).blob(); if (/^image\/(jpeg|png|gif|webp)$/.test(blob.type)) out.cover = blob; }
|
||
} finally { try { b.destroy(); } catch {} }
|
||
return out;
|
||
}
|
||
if (ext === 'pdf') {
|
||
await rdLibs('pdf'); const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(buf.slice(0)) }).promise, out = {};
|
||
try {
|
||
const info = (await pdf.getMetadata().catch(() => ({}))).info || {}; out.title = info.Title; out.author = info.Author;
|
||
const page = await pdf.getPage(1), vp0 = page.getViewport({ scale: 1 }), vp = page.getViewport({ scale: 360 / vp0.width }), c = document.createElement('canvas');
|
||
c.width = vp.width; c.height = vp.height; await page.render({ canvasContext: c.getContext('2d'), viewport: vp, background: '#fff' }).promise;
|
||
out.cover = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.8));
|
||
} finally { pdf.destroy(); }
|
||
return out;
|
||
}
|
||
if (rdKind(ext) === 'mobi') {
|
||
await rdLibs('mobi'); const m = await rdMobiOpen(new Uint8Array(buf.slice(0))), out = {};
|
||
try { const md = m.getMetadata(); out.title = md.title; out.author = (md.author || []).join(', '); const cu = m.getCoverImage(); if (cu) { const blob = await (await fetch(cu)).blob(); if (/^image\/(jpeg|png|gif|webp)$/.test(blob.type)) out.cover = blob; } } finally { try { m.destroy(); } catch {} }
|
||
return out;
|
||
}
|
||
return {};
|
||
}
|
||
/** MOBI 6 and KF8 (AZW3) need different readers: try the newer format first, since the older reader can "open" a KF8 file and show nonsense. */
|
||
async function rdMobiOpen(u8) {
|
||
try { const k = await MobiParser.initKf8File(u8); if (k.getSpine().length) return k; try { k.destroy(); } catch {} } catch {}
|
||
return MobiParser.initMobiFile(u8);
|
||
}
|
||
|
||
// ======================================================= Reader
|
||
let rdCur = null; // the open book's reader, so leaving the page can close it
|
||
async function rdOpen(id) {
|
||
const v = $('#view');
|
||
document.body.classList.add('editing');
|
||
v.innerHTML = '<div class="rd"><p class="empty" style="margin:auto">Opening…</p></div>';
|
||
let book, notes;
|
||
try {
|
||
const list = await rdApi('/list'); book = list.find(b => b.id === id); if (!book) throw new Error('That book is no longer in your library');
|
||
notes = await rdApi('/book/' + id + '/notes');
|
||
} catch (e) { if (!location.hash.startsWith('#/read')) return; document.body.classList.remove('editing'); v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
||
if (!location.hash.startsWith('#/read/open')) return;
|
||
new RdReader(book, notes).start();
|
||
}
|
||
|
||
class RdReader {
|
||
constructor(book, notes) {
|
||
this.book = book; this.notes = notes || []; this.P = rdPrefs(); this.loc = book.loc; this.pct = book.pct || 0; this.bars = true; this.q = [];
|
||
rdCur?.destroy(); rdCur = this;
|
||
}
|
||
$(s) { return this.root.querySelector(s); }
|
||
async start() {
|
||
const b = this.book, v = $('#view');
|
||
v.innerHTML = `<div class="rd" id="rdroot"><header class="rdbar"><a class="ibtn" href="#/read" aria-label="Back to library" title="Back to library">${icon('back')}</a><span class="rdtitle grow"><b>${esc(b.title)}</b><small>${esc(b.author || '')}</small></span>
|
||
<button class="ibtn" id="rdtoc" aria-label="Contents" title="Contents">${icon('list')}</button><button class="ibtn" id="rdsrch" aria-label="Search in book" title="Search in book">${icon('search')}</button>
|
||
<button class="ibtn" id="rdbm" aria-label="Bookmark" title="Bookmark this place">${icon('bookmark')}</button><button class="ibtn" id="rdnotes" aria-label="Bookmarks and notes" title="Bookmarks, notes and highlights">${icon('edit')}</button>
|
||
<button class="ibtn" id="rdaa" aria-label="Appearance" title="Appearance">${icon('font')}</button></header>
|
||
<div class="rdmain"><div id="rdstage" class="rdstage ${b.ext}"></div><button class="rdzone l" id="rdprev" aria-label="Previous page"></button><button class="rdzone r" id="rdnext" aria-label="Next page"></button><div class="rdsel" id="rdsel" hidden></div><p class="rdload" id="rdload">Opening…</p></div>
|
||
<footer class="rdfoot"><span class="rdlabel" id="rdlabel"></span><input type="range" id="rdseek" min="0" max="1000" value="${Math.round(this.pct * 1000)}" aria-label="Position in book"><span class="rdpct" id="rdpct">${Math.round(this.pct * 100)}%</span></footer></div>`;
|
||
this.root = $('#rdroot'); this.applyChrome();
|
||
this.hookUi();
|
||
try {
|
||
await rdLibs(rdKind(b.ext));
|
||
this.A = b.ext === 'epub' ? new RdEpub(this) : b.ext === 'pdf' ? new RdPdf(this) : rdKind(b.ext) === 'mobi' ? new RdMobi(this) : new RdDocx(this);
|
||
await this.A.open(this.$('#rdstage'), this.loc);
|
||
this.$('#rdload').remove();
|
||
} catch (e) { if (this.dead) return; this.$('#rdload').innerHTML = `<span class="err">${esc(e.message || 'This book could not be opened')}</span><br><a class="btn ghost" href="#/read" style="margin-top:12px">Back to library</a>`; }
|
||
}
|
||
destroy() { if (this.dead) return; this.dead = true; this.save(true); try { this.A?.destroy(); } catch {} removeEventListener('keydown', this.keys, true); removeEventListener('pagehide', this.hide); removeEventListener('resize', this.onResize); if (rdCur === this) rdCur = null; }
|
||
applyChrome() { const t = rdTheme(this.P); this.root.style.setProperty('--rd-bg', t.bg); this.root.style.setProperty('--rd-fg', t.fg); this.root.style.setProperty('--rd-link', t.link); this.root.classList.toggle('dark', t === RD_THEMES.dark); this.root.classList.toggle('hidebars', !this.bars); }
|
||
hookUi() {
|
||
const A = () => this.A;
|
||
this.$('#rdprev').onclick = () => A()?.prev(); this.$('#rdnext').onclick = () => A()?.next();
|
||
this.$('#rdseek').oninput = e => { this.$('#rdpct').textContent = Math.round(e.target.value / 10) + '%'; clearTimeout(this.seekT); this.seekT = setTimeout(() => A()?.seek(e.target.value / 1000), 250); };
|
||
this.$('#rdtoc').onclick = () => this.showToc(); this.$('#rdsrch').onclick = () => this.showSearch(); this.$('#rdnotes').onclick = () => this.showNotes(); this.$('#rdaa').onclick = () => this.showAppearance();
|
||
this.$('#rdbm').onclick = () => this.toggleBookmark();
|
||
this.keys = e => {
|
||
if ((e.target.closest && e.target.closest('input,textarea,select,#sheet')) || document.getElementById('sheet')) return;
|
||
if (e.key === 'ArrowRight' || e.key === 'PageDown' || (e.key === ' ' && !e.shiftKey && this.book.ext !== 'pdf' && this.book.ext !== 'docx')) { A()?.next(); e.preventDefault(); }
|
||
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { A()?.prev(); e.preventDefault(); }
|
||
else if (e.key === 'Escape') location.hash = '#/read';
|
||
else if (e.key === 't') this.showToc(); else if (e.key === 'b') this.toggleBookmark(); else if (e.key === '/' ) { e.preventDefault(); this.showSearch(); }
|
||
};
|
||
addEventListener('keydown', this.keys, true);
|
||
this.hide = () => this.save(true); addEventListener('pagehide', this.hide);
|
||
this.onResize = () => this.A?.resize?.(); addEventListener('resize', this.onResize);
|
||
document.addEventListener('mousedown', e => { if (!e.target.closest('#rdsel')) this.hideSel(); }, { once: false, capture: true });
|
||
}
|
||
toggleBars() { this.bars = !this.bars; this.root.classList.toggle('hidebars', !this.bars); setTimeout(() => this.A?.resize?.(), 60); }
|
||
// ---- where you are: reported by the format reader, saved to the server a moment later
|
||
relocated({ loc, pct, label }) {
|
||
this.loc = loc; this.pct = Math.min(1, Math.max(0, pct || 0)); this.label = label || '';
|
||
if (this.root.isConnected) { this.$('#rdseek').value = Math.round(this.pct * 1000); this.$('#rdpct').textContent = Math.round(this.pct * 100) + '%'; this.$('#rdlabel').textContent = this.label; }
|
||
clearTimeout(this.saveT); this.saveT = setTimeout(() => this.save(), 1500);
|
||
}
|
||
save(keepalive) {
|
||
clearTimeout(this.saveT); if (!this.loc || this.savedLoc === this.loc + this.pct) return; this.savedLoc = this.loc + this.pct;
|
||
fetch(`/api/read/book/${this.book.id}/pos`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ loc: this.loc, pct: this.pct, t: Date.now() }), keepalive: !!keepalive }).catch(() => { this.savedLoc = ''; });
|
||
}
|
||
// ---- bookmarks, notes and highlights
|
||
async pushNote(n) {
|
||
n.t = Date.now(); const i = this.notes.findIndex(x => x.id === n.id); if (i >= 0) this.notes[i] = n; else this.notes.push(n);
|
||
try { this.notes = await rdApi(`/book/${this.book.id}/notes`, rdJson('POST', [n])); } catch (e) { toast('Could not save: ' + e.message); }
|
||
}
|
||
isMarked() { return this.notes.find(n => n.type === 'mark' && !n.del && this.A?.sameSpot?.(n)); }
|
||
async toggleBookmark() {
|
||
if (!this.A) return;
|
||
const have = this.isMarked();
|
||
if (have) { await this.pushNote({ ...have, del: true }); this.notes = this.notes.filter(n => !n.del); toast('Bookmark removed'); }
|
||
else { const here = this.A.here(); await this.pushNote({ id: rdUid(), type: 'mark', loc: here.loc, pct: this.pct, text: here.text || '', page: here.page || 0, color: 'yellow' }); toast('Bookmarked'); }
|
||
this.$('#rdbm').classList.toggle('on', !!this.isMarked());
|
||
}
|
||
// The selection bar: pick a colour to highlight, add a note, or copy.
|
||
showSel(sel) { // sel = { loc, text, page, clear() }
|
||
this.sel = sel; const el = this.$('#rdsel');
|
||
el.innerHTML = Object.entries(RD_COLORS).map(([c, hex]) => `<button class="rdcol" data-c="${c}" style="background:${hex}" aria-label="Highlight ${c}"></button>`).join('') + `<button class="rdsb" data-a="note">${icon('edit')}Note</button><button class="rdsb" data-a="copy">${icon('copy')}Copy</button>`;
|
||
el.hidden = false;
|
||
el.querySelectorAll('.rdcol').forEach(b => b.onclick = async () => { await this.addHighlight(b.dataset.c, ''); });
|
||
el.querySelector('[data-a=note]').onclick = () => this.noteDialog(null);
|
||
el.querySelector('[data-a=copy]').onclick = () => { navigator.clipboard?.writeText(sel.text).then(() => toast('Copied'), () => toast('Could not copy')); this.hideSel(true); };
|
||
}
|
||
hideSel(clear) { const el = this.$('#rdsel'); if (el) el.hidden = true; if (clear && this.sel) { try { this.sel.clear(); } catch {} } if (clear) this.sel = null; }
|
||
async addHighlight(color, note) {
|
||
const s = this.sel; if (!s) return; this.hideSel(true);
|
||
const n = { id: rdUid(), type: note ? 'note' : 'hl', loc: s.loc, pct: this.pct, text: s.text.slice(0, 2000), note, color, page: s.page || 0 };
|
||
await this.pushNote(n); this.A.drawNote?.(n);
|
||
}
|
||
noteDialog(existing) { // a new note on the current selection, or edit one that exists
|
||
const s = this.sel, n = existing, color = n ? n.color : 'yellow';
|
||
const el = openSheet(n ? 'Highlight' : 'Add a note', '', `${(n || s).text ? `<blockquote class="rdq">${esc(((n || s).text || '').slice(0, 400))}</blockquote>` : ''}<label>Note</label><textarea id="rdnt" rows="4" maxlength="5000" placeholder="Write a note">${esc(n ? n.note : '')}</textarea>
|
||
<div class="rdcolors" id="rdcols">${Object.entries(RD_COLORS).map(([c, hex]) => `<button type="button" class="rdcol${c === color ? ' on' : ''}" data-c="${c}" style="background:${hex}" aria-label="${c}"></button>`).join('')}</div>
|
||
<div class="bar">${n ? '<button class="btn danger" id="rdnd" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" id="rdns" style="flex:1;justify-content:center">Save</button></div>`);
|
||
let col = color; el.querySelectorAll('#rdcols .rdcol').forEach(b => b.onclick = () => { col = b.dataset.c; el.querySelectorAll('#rdcols .rdcol').forEach(x => x.classList.toggle('on', x === b)); });
|
||
if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet;
|
||
el.querySelector('#rdns').onclick = async () => {
|
||
const text = el.querySelector('#rdnt').value.trim(); closeSheet();
|
||
if (n) { const upd = { ...n, note: text, color: col, type: text ? 'note' : 'hl' }; this.A.eraseNote?.(n); await this.pushNote(upd); this.A.drawNote?.(upd); this.refreshNotes?.(); }
|
||
else if (this.sel) await this.addHighlight(col, text);
|
||
};
|
||
if (n) el.querySelector('#rdnd').onclick = () => { closeSheet(); this.deleteNote(n); };
|
||
}
|
||
async deleteNote(n) { this.A.eraseNote?.(n); await this.pushNote({ ...n, del: true }); this.notes = this.notes.filter(x => !x.del); this.$('#rdbm').classList.toggle('on', !!this.isMarked()); this.refreshNotes?.(); }
|
||
// ---- panels
|
||
async showToc() {
|
||
const el = openSheet('Contents', '', '<p class="empty">Loading…</p>'); let items = [];
|
||
try { items = await this.A.toc(); } catch {}
|
||
el.querySelector('.empty').outerHTML = items.length ? `<div class="list rdlist">${items.map((t, i) => `<button class="row" data-i="${i}" style="padding-left:${16 + t.depth * 18}px"><span class="grow"><b>${esc(t.label)}</b></span></button>`).join('')}</div>` : '<p class="empty">This book has no table of contents.</p>';
|
||
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(items[b.dataset.i].loc); });
|
||
}
|
||
showSearch() {
|
||
const el = openSheet('Search in book', '', `<form><input type="search" id="rdq" placeholder="Search for a word or phrase" autocomplete="off" value="${esc(this.lastQ || '')}"></form><div id="rdres" class="list rdlist" style="margin-top:10px"></div>`);
|
||
const res = el.querySelector('#rdres'), inp = el.querySelector('#rdq'); let seq = 0;
|
||
const run = async () => {
|
||
const term = inp.value.trim(), my = ++seq; this.lastQ = term; if (term.length < 2) { res.innerHTML = ''; return; }
|
||
res.innerHTML = '<p class="empty">Searching…</p>';
|
||
try {
|
||
const out = await this.A.search(term, () => my !== seq); if (my !== seq) return;
|
||
res.innerHTML = out.length ? out.map((r, i) => `<button class="row" data-i="${i}"><span class="grow"><b style="white-space:normal;font-weight:400">${r.excerpt}</b><small>${esc(r.where || '')}</small></span></button>`).join('') : '<p class="empty">Nothing found.</p>';
|
||
res.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(out[b.dataset.i].loc, term); });
|
||
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||
};
|
||
el.querySelector('form').onsubmit = e => { e.preventDefault(); run(); }; let t; inp.oninput = () => { clearTimeout(t); t = setTimeout(run, 450); }; inp.focus(); if (inp.value) run();
|
||
}
|
||
showNotes() {
|
||
const el = openSheet('Bookmarks and notes', '', '<div id="rdnl"></div>');
|
||
const draw = () => {
|
||
const l = this.notes.filter(n => !n.del).sort((a, b) => a.pct - b.pct), box = el.querySelector('#rdnl');
|
||
box.innerHTML = l.length ? `<div class="list rdlist">${l.map(n => `<div class="row rdnote" data-id="${n.id}"><button class="grow rdnj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0">
|
||
<b style="white-space:normal">${n.type === 'mark' ? icon('bookmark') + ' Bookmark' : `<span class="rddot" style="background:${RD_COLORS[n.color] || RD_COLORS.yellow}"></span>${n.type === 'note' ? 'Note' : 'Highlight'}`} <small style="display:inline">· ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}</small></b>
|
||
${n.text ? `<small style="white-space:normal;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden">${esc(n.text)}</small>` : ''}${n.note ? `<span style="white-space:pre-wrap;display:block;margin-top:2px">${esc(n.note)}</span>` : ''}</button>
|
||
${n.type === 'mark' ? '' : `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>`}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>
|
||
<div class="bar"><button class="btn ghost" id="rdexp">${icon('copy')}Copy as text</button></div>` : '<p class="empty">Nothing yet. Select text to highlight it or add a note; tap the bookmark button to mark a place.</p>';
|
||
box.querySelectorAll('.rdnote').forEach(r => { const n = this.notes.find(x => x.id === r.dataset.id);
|
||
r.querySelector('.rdnj').onclick = () => { closeSheet(); this.A.goto(n.loc, null, n); };
|
||
const e = r.querySelector('[data-e]'); if (e) e.onclick = () => { closeSheet(); this.noteDialog(n); };
|
||
r.querySelector('[data-d]').onclick = async () => { await this.deleteNote(n); draw(); }; });
|
||
const ex = box.querySelector('#rdexp'); if (ex) ex.onclick = () => {
|
||
const txt = `${this.book.title}${this.book.author ? ' - ' + this.book.author : ''}\n\n` + l.map(n => (n.type === 'mark' ? '[Bookmark] ' + (n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%') : `"${n.text}"${n.note ? '\n Note: ' + n.note : ''}`)).join('\n\n');
|
||
navigator.clipboard?.writeText(txt).then(() => toast('Copied'), () => toast('Could not copy'));
|
||
};
|
||
};
|
||
this.refreshNotes = () => { if (document.body.contains(el)) draw(); }; draw();
|
||
}
|
||
showAppearance() {
|
||
const P = this.P, pdf = this.book.ext === 'pdf', docx = this.book.ext === 'docx';
|
||
const seg = (name, opts, cur) => `<div class="rdseg" data-k="${name}">${opts.map(([v, l]) => `<button type="button" data-v="${v}" class="${String(v) === String(cur) ? 'on' : ''}">${l}</button>`).join('')}</div>`;
|
||
const el = openSheet('Appearance', '', `<label>Theme</label>${seg('theme', [['auto', 'Auto'], ['light', 'Light'], ['sepia', 'Sepia'], ['dark', 'Dark']], P.theme)}
|
||
${pdf ? `<label>Zoom</label><div class="rdstep"><button type="button" class="btn ghost" data-z="-0.15">−</button><span id="rdzv">${Math.round(P.zoom * 100)}%</span><button type="button" class="btn ghost" data-z="0.15">+</button></div>` : `
|
||
<label>Text size</label><div class="rdstep"><button type="button" class="btn ghost" data-s="-1">A−</button><span id="rdsv">${P.size}</span><button type="button" class="btn ghost" data-s="1">A+</button></div>
|
||
<label>Font</label>${seg('font', Object.entries(RD_FONTS).filter(([k]) => !(docx && !k)).map(([k, l]) => [k, l]), P.font)}
|
||
<label>Line spacing</label>${seg('lh', [[1.3, 'Tight'], [1.55, 'Normal'], [1.85, 'Loose']], P.lh)}
|
||
<label>Margins</label>${seg('margin', [[0, 'Narrow'], [2, 'Normal'], [4, 'Wide']], P.margin)}
|
||
${docx ? '' : `<label>Layout</label>${seg('flow', [['paged', 'Pages'], ['scroll', 'Scroll']], P.flow)}`}`}
|
||
<div class="bar"><button class="btn" id="rdok" style="flex:1;justify-content:center">Done</button></div>`);
|
||
const apply = (k, v) => { P[k] = v; rdSavePrefs(P); this.applyChrome(); this.A?.restyle(k); };
|
||
el.querySelectorAll('.rdseg').forEach(g => g.querySelectorAll('button').forEach(b => b.onclick = () => { g.querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b)); const k = g.dataset.k; apply(k, ['lh', 'margin'].includes(k) ? +b.dataset.v : b.dataset.v); }));
|
||
el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { apply('size', Math.min(40, Math.max(12, P.size + +b.dataset.s))); el.querySelector('#rdsv').textContent = P.size; });
|
||
el.querySelectorAll('[data-z]').forEach(b => b.onclick = () => { apply('zoom', Math.min(3, Math.max(0.5, Math.round((P.zoom + +b.dataset.z) * 100) / 100))); el.querySelector('#rdzv').textContent = Math.round(P.zoom * 100) + '%'; });
|
||
el.querySelector('#rdok').onclick = closeSheet;
|
||
}
|
||
}
|
||
|
||
// Shared helpers for the format readers
|
||
const rdEsc = esc;
|
||
function rdExcerpt(text, i, len) { const a = Math.max(0, i - 40), b = Math.min(text.length, i + len + 60); return (a ? '…' : '') + esc(text.slice(a, i)) + '<mark>' + esc(text.slice(i, i + len)) + '</mark>' + esc(text.slice(i + len, b)) + (b < text.length ? '…' : ''); }
|
||
const rdFetchBook = async (id, ext) => { const r = await fetch(`/api/read/book/${id}/file?inline=1`); if (!r.ok) throw new Error('Could not download the book (' + r.status + ')'); return r.arrayBuffer(); };
|
||
const rdCss = (P, t) => { const fam = RD_FAMILY[P.font]; return `html,body{background:${t.bg}!important;color:${t.fg}!important}body{${fam ? `font-family:${fam}!important;` : ''}font-size:${P.size}px;line-height:${P.lh};padding:0 ${P.margin * 3}%!important}a{color:${t.link}!important}p,li,div,span,td,h1,h2,h3,h4,h5,h6,blockquote{color:inherit!important;${fam ? 'font-family:inherit!important;' : ''}}p,li{line-height:${P.lh}!important}img,svg{max-width:100%!important;height:auto}${P.theme === 'dark' || t === RD_THEMES.dark ? 'img{filter:brightness(.85)}' : ''}`; };
|
||
|
||
// ======================================================= EPUB
|
||
class RdEpub {
|
||
constructor(R) { this.R = R; this.hl = new Map(); }
|
||
async open(stage, loc) {
|
||
const R = this.R; this.stage = stage;
|
||
this.b = ePub(await rdFetchBook(R.book.id, 'epub'));
|
||
await this.b.ready;
|
||
const cached = (() => { try { return localStorage.getItem('rd-loc-' + R.book.id); } catch {} })();
|
||
this.locs = new Promise(ok => { const done = () => { this.locsReady = true; ok(); this.refreshPct(); }; if (cached) { try { this.b.locations.load(cached); return done(); } catch {} } this.b.locations.generate(1200).then(() => { try { localStorage.setItem('rd-loc-' + R.book.id, this.b.locations.save()); } catch {} done(); }).catch(ok); });
|
||
await this.build(loc);
|
||
R.$('#rdbm').classList.toggle('on', !!R.isMarked());
|
||
}
|
||
async build(loc) {
|
||
const R = this.R, P = R.P; if (this.rend) { try { this.rend.destroy(); } catch {} this.stage.innerHTML = ''; this.hl.clear(); }
|
||
const scroll = P.flow === 'scroll';
|
||
this.rend = this.b.renderTo(this.stage, scroll ? { width: '100%', height: '100%', flow: 'scrolled', manager: 'continuous', allowScriptedContent: false } : { width: '100%', height: '100%', flow: 'paginated', spread: 'auto', minSpreadWidth: 900, allowScriptedContent: false });
|
||
this.restyle();
|
||
this.rend.on('relocated', l => { this.at = l; this.refreshPct(); R.$('#rdbm').classList.toggle('on', !!R.isMarked()); });
|
||
this.rend.on('rendered', (sec) => { const c = this.rend.getContents()[0]; if (!c || c.__rd) return; });
|
||
this.rend.on('selected', async (range, contents) => {
|
||
try {
|
||
const r = await this.b.getRange(range), text = r.toString().trim(); if (!text) return;
|
||
R.showSel({ loc: range, text, clear: () => { for (const c of this.rend.getContents()) c.window.getSelection().removeAllRanges(); } });
|
||
} catch {}
|
||
});
|
||
this.rend.hooks.content.register(contents => {
|
||
const doc = contents.document; let x0 = 0, y0 = 0, t0 = 0;
|
||
doc.addEventListener('keydown', e => R.keys(e));
|
||
doc.addEventListener('touchstart', e => { x0 = e.changedTouches[0].screenX; y0 = e.changedTouches[0].screenY; t0 = Date.now(); }, { passive: true });
|
||
doc.addEventListener('touchend', e => { const dx = e.changedTouches[0].screenX - x0, dy = e.changedTouches[0].screenY - y0; if (this.R.P.flow === 'paged' && Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - t0 < 700) dx < 0 ? this.next() : this.prev(); });
|
||
doc.addEventListener('click', e => { if (e.target.closest && e.target.closest('a[href]')) return; const sel = contents.window.getSelection(); if (sel && !sel.isCollapsed) return; const w = contents.window.innerWidth; if (this.R.P.flow !== 'paged') return R.toggleBars(); if (e.clientX < w * 0.22) this.prev(); else if (e.clientX > w * 0.78) this.next(); else R.toggleBars(); });
|
||
doc.addEventListener('mousedown', () => R.hideSel());
|
||
});
|
||
const start = loc && !/^\d+$/.test(loc) ? loc : undefined;
|
||
try { await this.rend.display(start); } catch { await this.rend.display(); }
|
||
for (const n of R.notes) this.drawNote(n);
|
||
}
|
||
restyle(k) {
|
||
const R = this.R, P = R.P, t = rdTheme(P);
|
||
if (k === 'flow') { const loc = this.at && this.at.start.cfi; return this.build(loc); }
|
||
const th = this.rend.themes; th.override('color', t.fg); th.override('background', t.bg);
|
||
th.fontSize(P.size + 'px'); th.override('line-height', String(P.lh)); th.override('padding', `0 ${P.margin * 3}%`);
|
||
if (P.font) th.font(RD_FAMILY[P.font]); else th.override('font-family', 'inherit');
|
||
th.default({ a: { color: t.link + ' !important' }, 'p,li,div,span,td,h1,h2,h3,h4,blockquote': { color: 'inherit !important' }, img: { 'max-width': '100% !important' } });
|
||
R.root.style.setProperty('--rd-bg', t.bg);
|
||
if (k === 'theme') this.R.applyChrome();
|
||
if (this.at && ['size', 'font', 'lh', 'margin'].includes(k)) setTimeout(() => { try { this.rend.display(this.at.start.cfi); } catch {} }, 80);
|
||
}
|
||
refreshPct() {
|
||
const l = this.at; if (!l) return; let pct;
|
||
if (this.locsReady) pct = this.b.locations.percentageFromCfi(l.start.cfi); else pct = this.b.spine.length ? (l.start.index || 0) / this.b.spine.length : 0;
|
||
if (l.atEnd) pct = 1;
|
||
const sec = this.b.navigation && this.b.navigation.get && l.start.href ? this.b.navigation.get(l.start.href) : null;
|
||
this.R.relocated({ loc: l.start.cfi, pct, label: sec && sec.label ? sec.label.trim() : '' });
|
||
}
|
||
next() { return this.rend.next(); } prev() { return this.rend.prev(); }
|
||
async seek(f) { if (!this.locsReady) await this.locs; const cfi = this.b.locations.cfiFromPercentage(Math.min(0.9999, f)); if (cfi) this.rend.display(cfi); }
|
||
here() { const l = this.at; return { loc: l ? l.start.cfi : '', text: '' }; }
|
||
sameSpot(n) { const l = this.at; if (!l) return false; try { const c = new ePub.CFI(); return c.compare(n.loc, l.start.cfi) >= 0 && c.compare(n.loc, l.end.cfi) <= 0; } catch { return n.loc === l.start.cfi; } }
|
||
async toc() { const nav = await this.b.loaded.navigation, out = []; const walk = (items, d) => items.forEach(i => { out.push({ label: (i.label || '').trim() || 'Untitled', loc: i.href, depth: d }); if (i.subitems) walk(i.subitems, d + 1); }); walk(nav.toc, 0); return out; }
|
||
async goto(loc, term, note) {
|
||
try { await this.rend.display(loc); } catch { return toast('Could not go there'); }
|
||
const flash = note && note.type !== 'mark' ? note.loc : term ? loc : null;
|
||
if (flash && /^epubcfi/.test(flash) && !note) { try { this.rend.annotations.highlight(flash, {}, null, 'rd-flash', { fill: '#ffd43b', 'fill-opacity': '.5' }); setTimeout(() => { try { this.rend.annotations.remove(flash, 'highlight'); } catch {} }, 2500); } catch {} }
|
||
}
|
||
async search(q, stale) {
|
||
const out = [], re = q.toLowerCase();
|
||
for (const item of this.b.spine.spineItems) {
|
||
if (stale()) return out;
|
||
try {
|
||
await item.load(this.b.load.bind(this.b));
|
||
const found = item.find(q) || []; // [{cfi, excerpt}]
|
||
const nav = this.b.navigation && this.b.navigation.get ? this.b.navigation.get(item.href) : null;
|
||
for (const f of found) { const ex = String(f.excerpt || ''), i = ex.toLowerCase().indexOf(re); out.push({ loc: f.cfi, excerpt: i >= 0 ? rdExcerpt(ex, i, q.length) : esc(ex), where: nav && nav.label ? nav.label.trim() : '' }); if (out.length >= 150) { item.unload(); return out; } }
|
||
item.unload();
|
||
} catch { try { item.unload(); } catch {} }
|
||
}
|
||
return out;
|
||
}
|
||
drawNote(n) {
|
||
if (n.type === 'mark' || n.del || !/^epubcfi/.test(n.loc || '') || this.hl.has(n.id)) return;
|
||
const hex = RD_COLORS[n.color] || RD_COLORS.yellow;
|
||
try { this.rend.annotations.highlight(n.loc, { id: n.id }, () => { const cur = this.R.notes.find(x => x.id === n.id); if (cur) this.R.noteDialog(cur); }, 'rd-hl', { fill: hex, 'fill-opacity': '.4', 'mix-blend-mode': 'multiply', stroke: 'none' }); this.hl.set(n.id, n.loc); } catch {}
|
||
}
|
||
eraseNote(n) { const loc = this.hl.get(n.id); if (loc) { try { this.rend.annotations.remove(loc, 'highlight'); } catch {} this.hl.delete(n.id); } }
|
||
resize() { try { this.rend.resize(); } catch {} }
|
||
destroy() { try { this.b.destroy(); } catch {} }
|
||
}
|
||
|
||
// ======================================================= PDF
|
||
class RdPdf {
|
||
constructor(R) { this.R = R; this.texts = {}; this.pages = []; }
|
||
async open(stage, loc) {
|
||
const R = this.R; this.stage = stage;
|
||
this.pdf = await pdfjsLib.getDocument({ url: `/api/read/book/${R.book.id}/file?inline=1` }).promise;
|
||
this.n = this.pdf.numPages; const p1 = await this.pdf.getPage(1); this.v1 = p1.getViewport({ scale: 1 });
|
||
stage.innerHTML = '<div class="rdpages"></div>'; this.box = stage.firstChild;
|
||
this.layout();
|
||
stage.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.onScroll(); }); });
|
||
this.io = new IntersectionObserver(es => es.forEach(e => e.isIntersecting ? this.render(+e.target.dataset.n) : this.release(+e.target.dataset.n)), { root: stage, rootMargin: '150% 0px' });
|
||
this.pages.forEach(p => this.io.observe(p.el));
|
||
const sel = () => setTimeout(() => this.checkSel(), 30);
|
||
stage.addEventListener('mouseup', sel); stage.addEventListener('touchend', sel); stage.addEventListener('keyup', sel);
|
||
stage.addEventListener('click', e => { const s = getSelection(); if (s && !s.isCollapsed) return; const r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width; if (x < 0.15 && this.R.P.zoom <= 1.01) this.prev(); else if (x > 0.85 && this.R.P.zoom <= 1.01) this.next(); else R.toggleBars(); });
|
||
const page = /^pg:(\d+)/.exec(loc || '');
|
||
this.goto(page ? 'pg:' + page[1] : 'pg:1'); this.onScroll();
|
||
R.$('#rdbm').classList.toggle('on', !!R.isMarked());
|
||
}
|
||
scale() { return Math.max(0.3, (this.stage.clientWidth - 16) / this.v1.width * this.R.P.zoom); }
|
||
layout() {
|
||
const s = this.scale(), cur = this.page || 1; this.s = s; this.box.innerHTML = ''; this.pages = [];
|
||
for (let i = 1; i <= this.n; i++) {
|
||
const el = document.createElement('div'); el.className = 'pg'; el.dataset.n = i; el.style.width = this.v1.width * s + 'px'; el.style.height = this.v1.height * s + 'px'; this.box.appendChild(el); this.pages.push({ el, rendered: false });
|
||
}
|
||
if (this.io) this.pages.forEach(p => this.io.observe(p.el));
|
||
this.stage.scrollTop = this.pages[cur - 1].el.offsetTop - 8;
|
||
this.restyle('theme');
|
||
}
|
||
async render(n) {
|
||
const p = this.pages[n - 1]; if (!p || p.rendered || p.busy) return; p.busy = true;
|
||
try {
|
||
const page = await this.pdf.getPage(n), vp = page.getViewport({ scale: this.s }), dpr = Math.min(2, devicePixelRatio || 1);
|
||
if (p.gone) { p.busy = false; return; }
|
||
const c = document.createElement('canvas'); c.width = Math.floor(vp.width * dpr); c.height = Math.floor(vp.height * dpr); c.style.width = vp.width + 'px'; c.style.height = vp.height + 'px';
|
||
p.el.style.width = vp.width + 'px'; p.el.style.height = vp.height + 'px';
|
||
await page.render({ canvasContext: c.getContext('2d'), viewport: vp, transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null, background: 'rgba(255,255,255,1)' }).promise;
|
||
const tl = document.createElement('div'); tl.className = 'textLayer'; tl.style.setProperty('--scale-factor', vp.scale);
|
||
const hl = document.createElement('div'); hl.className = 'rdhls';
|
||
p.el.replaceChildren(c, hl, tl); p.rendered = true; p.vp = vp;
|
||
pdfjsLib.renderTextLayer({ textContentSource: page.streamTextContent(), container: tl, viewport: vp, textDivs: [] });
|
||
this.R.notes.forEach(nt => { if (nt.page === n) this.drawNote(nt); });
|
||
} catch { /* a page that fails to draw stays blank */ }
|
||
p.busy = false;
|
||
}
|
||
release(n) { const p = this.pages[n - 1]; if (!p) return; p.gone = true; if (p.rendered) { p.el.replaceChildren(); p.rendered = false; } setTimeout(() => { p.gone = false; }, 0); }
|
||
onScroll() {
|
||
const top = this.stage.scrollTop + this.stage.clientHeight * 0.3; let lo = 0, hi = this.n - 1;
|
||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (this.pages[mid].el.offsetTop <= top) lo = mid; else hi = mid - 1; }
|
||
const n = lo + 1; this.page = n; this.R.relocated({ loc: 'pg:' + n, pct: this.n > 1 ? n / this.n : 1, label: `Page ${n} of ${this.n}` });
|
||
this.R.$('#rdbm').classList.toggle('on', !!this.R.isMarked());
|
||
}
|
||
goto(loc, term, note) {
|
||
const m = /^pg:(\d+)/.exec(String(loc)) || /^r:(\d+):/.exec(String(loc)); if (!m) return;
|
||
const n = Math.min(this.n, Math.max(1, +m[1])), el = this.pages[n - 1].el; this.stage.scrollTop = el.offsetTop - 8; this.render(n);
|
||
if (note && note.loc.startsWith('r:')) { const l = el.querySelector('.rdhls'); if (l) setTimeout(() => { el.querySelectorAll(`[data-nid="${note.id}"]`).forEach(d => { d.classList.add('flash'); setTimeout(() => d.classList.remove('flash'), 2000); }); }, 400); }
|
||
if (term) this.flashTerm = { n, term };
|
||
}
|
||
next() { this.stage.scrollBy({ top: this.stage.clientHeight * 0.9, behavior: 'smooth' }); } prev() { this.stage.scrollBy({ top: -this.stage.clientHeight * 0.9, behavior: 'smooth' }); }
|
||
seek(f) { this.goto('pg:' + Math.max(1, Math.min(this.n, Math.round(f * this.n) || 1))); }
|
||
here() { return { loc: 'pg:' + (this.page || 1), page: this.page || 1, text: '' }; }
|
||
sameSpot(n) { return n.type === 'mark' && n.loc === 'pg:' + this.page; }
|
||
restyle(k) {
|
||
if (k === 'zoom') { this.layout(); this.pages.forEach(p => this.io.observe(p.el)); return; }
|
||
const R = this.R, t = rdTheme(R.P), dark = t === RD_THEMES.dark, sepia = t === RD_THEMES.sepia;
|
||
this.stage.style.background = t.bg; this.box.style.filter = ''; this.box.classList.toggle('pdark', dark); this.box.classList.toggle('psepia', sepia);
|
||
}
|
||
resize() { const w = this.stage.clientWidth; if (this.lastW && Math.abs(w - this.lastW) < 4) return; this.lastW = w; this.layout(); }
|
||
async toc() {
|
||
const out = [], ol = await this.pdf.getOutline().catch(() => null); if (!ol) return out;
|
||
const walk = async (items, d) => { for (const i of items) {
|
||
let page = 0; try { let dest = i.dest; if (typeof dest === 'string') dest = await this.pdf.getDestination(dest); if (Array.isArray(dest)) page = (await this.pdf.getPageIndex(dest[0])) + 1; } catch {}
|
||
if (page) out.push({ label: i.title.trim() || 'Untitled', loc: 'pg:' + page, depth: d }); if (i.items && i.items.length) await walk(i.items, d + 1); } };
|
||
await walk(ol, 0); return out;
|
||
}
|
||
async pageText(n) { if (this.texts[n] == null) { const tc = await (await this.pdf.getPage(n)).getTextContent(); this.texts[n] = tc.items.map(i => i.str).join(' ').replace(/\s+/g, ' '); } return this.texts[n]; }
|
||
async search(q, stale) {
|
||
const out = [], lq = q.toLowerCase();
|
||
for (let n = 1; n <= this.n; n++) {
|
||
if (stale()) return out; const t = await this.pageText(n), low = t.toLowerCase(); let i = -1, c = 0;
|
||
while ((i = low.indexOf(lq, i + 1)) >= 0 && c++ < 5) { out.push({ loc: 'pg:' + n, excerpt: rdExcerpt(t, i, q.length), where: 'Page ' + n }); if (out.length >= 200) return out; }
|
||
}
|
||
return out;
|
||
}
|
||
// ---- highlights are drawn as rectangles on the page; they are stored as fractions of the page size so they hold at any zoom
|
||
checkSel() {
|
||
const s = getSelection(); if (!s || s.isCollapsed || !s.rangeCount) return;
|
||
const r = s.getRangeAt(0), host = (r.commonAncestorContainer.nodeType === 1 ? r.commonAncestorContainer : r.commonAncestorContainer.parentElement);
|
||
if (!host || !host.closest || !host.closest('.textLayer') && !host.closest('.pg')) return;
|
||
const pg = (host.closest('.pg') || r.startContainer.parentElement.closest('.pg')); if (!pg) return;
|
||
const text = s.toString().replace(/\s+/g, ' ').trim(); if (!text) return;
|
||
const n = +pg.dataset.n, box = pg.getBoundingClientRect();
|
||
let rects = [...r.getClientRects()].filter(x => x.width > 1 && x.height > 1).map(x => ({ x: (x.left - box.left) / box.width, y: (x.top - box.top) / box.height, w: x.width / box.width, h: x.height / box.height }));
|
||
rects.sort((a, b) => a.y - b.y || a.x - b.x); const merged = [];
|
||
for (const q of rects) { const l = merged[merged.length - 1]; if (l && Math.abs(l.y - q.y) < 0.006 && q.x <= l.x + l.w + 0.01) { const right = Math.max(l.x + l.w, q.x + q.w); l.x = Math.min(l.x, q.x); l.w = right - l.x; l.h = Math.max(l.h, q.h); } else merged.push({ ...q }); }
|
||
let loc = ''; for (const q of merged) { const part = [q.x, q.y, q.w, q.h].map(v => Math.round(v * 10000)).join(','); if ((loc + part).length > 2900) break; loc += (loc ? ';' : '') + part; }
|
||
this.R.showSel({ loc: `r:${n}:` + loc, text, page: n, clear: () => s.removeAllRanges() });
|
||
}
|
||
drawNote(n) {
|
||
const m = /^r:(\d+):(.+)$/.exec(n.loc || ''); if (!m || n.del) return; const p = this.pages[+m[1] - 1]; if (!p || !p.rendered) return;
|
||
const layer = p.el.querySelector('.rdhls'); if (!layer || layer.querySelector(`[data-nid="${n.id}"]`)) return;
|
||
for (const part of m[2].split(';')) { const [x, y, w, h] = part.split(',').map(v => +v / 10000); const d = document.createElement('div'); d.className = 'rdhl'; d.dataset.nid = n.id; d.style.cssText = `left:${x * 100}%;top:${y * 100}%;width:${w * 100}%;height:${h * 100}%;background:${RD_COLORS[n.color] || RD_COLORS.yellow}`; layer.appendChild(d); }
|
||
}
|
||
eraseNote(n) { this.box.querySelectorAll(`[data-nid="${n.id}"]`).forEach(d => d.remove()); }
|
||
destroy() { try { this.io && this.io.disconnect(); this.pdf && this.pdf.destroy(); } catch {} }
|
||
}
|
||
|
||
// ======================================================= DOCX (converted to HTML in the browser; shown in a frame that cannot run scripts)
|
||
class RdDocx {
|
||
constructor(R) { this.R = R; }
|
||
async convert() {
|
||
const buf = await rdFetchBook(this.R.book.id, 'docx');
|
||
return { html: (await mammoth.convertToHtml({ arrayBuffer: buf }, { styleMap: ['p[style-name="Title"] => h1.title:fresh', 'p[style-name="Subtitle"] => p.subtitle:fresh'] })).value, head: '' };
|
||
}
|
||
async open(stage, loc) {
|
||
const R = this.R, res = await this.convert();
|
||
this.f = document.createElement('iframe'); this.f.setAttribute('sandbox', 'allow-same-origin'); this.f.title = R.book.title; this.f.className = 'rdframe';
|
||
stage.appendChild(this.f);
|
||
this.f.srcdoc = `<!doctype html><html><head><meta charset="utf-8"><style id="base">body{max-width:46em;margin:0 auto;padding:1.2em 6%;overflow-wrap:break-word}table{border-collapse:collapse}td,th{border:1px solid #8888;padding:.3em .5em}h1,h2,h3{line-height:1.25}mark.rdh{border-radius:2px;color:inherit;padding:0}mark.rdh.flash{outline:2px solid #e8590c}mark.rdq{background:#ffd43b88;color:inherit}</style><style id="theme"></style>${res.head}</head><body><article>${res.html}</article></body></html>`;
|
||
await new Promise(ok => { this.f.onload = ok; });
|
||
this.d = this.f.contentDocument; this.w = this.f.contentWindow; this.root = this.d.documentElement;
|
||
this.restyle();
|
||
this.d.addEventListener('keydown', e => R.keys(e));
|
||
this.d.addEventListener('mousedown', () => R.hideSel());
|
||
const sel = () => setTimeout(() => this.checkSel(), 30);
|
||
this.d.addEventListener('mouseup', sel); this.d.addEventListener('touchend', sel); this.d.addEventListener('keyup', sel);
|
||
this.d.addEventListener('click', e => { const s = this.w.getSelection(); if (s && !s.isCollapsed) return; if (e.target.closest('a[href]')) { e.preventDefault(); const a = e.target.closest('a[href]'); if (!this.link(a)) this.d.getElementById((a.getAttribute('href') || '').replace(/^#/, ''))?.scrollIntoView(); return; } R.toggleBars(); });
|
||
this.d.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.report(); }); }, { passive: true });
|
||
this.w.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.report(); }); }, { passive: true });
|
||
for (const n of R.notes) this.drawNote(n);
|
||
const m = /^o:(\d+)/.exec(loc || ''); if (m) this.gotoOffset(+m[1]); this.report();
|
||
}
|
||
link() { return false; }
|
||
scroller() { return this.d.scrollingElement || this.root; }
|
||
restyle(k) {
|
||
const R = this.R, t = rdTheme(R.P); this.d.getElementById('theme').textContent = rdCss(R.P, t); R.root.style.setProperty('--rd-bg', t.bg);
|
||
if (k === 'theme') R.applyChrome();
|
||
if (this.at != null && k !== 'theme') setTimeout(() => this.gotoOffset(this.at), 50);
|
||
}
|
||
textNodes() { const w = this.d.createTreeWalker(this.d.body, NodeFilter.SHOW_TEXT), out = []; let n; while ((n = w.nextNode())) out.push(n); return out; }
|
||
offsetTo(node, off) { let total = 0; for (const t of this.textNodes()) { if (t === node) return total + off; total += t.nodeValue.length; } return total; }
|
||
nodeAt(offset) { let total = 0; const all = this.textNodes(); for (const t of all) { const len = t.nodeValue.length; if (offset <= total + len) return [t, Math.max(0, offset - total)]; total += len; } const l = all[all.length - 1]; return l ? [l, l.nodeValue.length] : [this.d.body, 0]; }
|
||
gotoOffset(o) { const [n, off] = this.nodeAt(o), r = this.d.createRange(); r.setStart(n, Math.min(off, n.nodeValue ? n.nodeValue.length : 0)); r.collapse(true); const rc = r.getBoundingClientRect(); this.scroller().scrollTop += rc.top - 24; }
|
||
topOffset() {
|
||
const cp = this.d.caretRangeFromPoint ? this.d.caretRangeFromPoint(this.w.innerWidth / 2, 16) : (this.d.caretPositionFromPoint ? (p => p && { startContainer: p.offsetNode, startOffset: p.offset })(this.d.caretPositionFromPoint(this.w.innerWidth / 2, 16)) : null);
|
||
return cp && cp.startContainer ? this.offsetTo(cp.startContainer, cp.startOffset) : 0;
|
||
}
|
||
report() { const s = this.scroller(), max = s.scrollHeight - s.clientHeight, off = this.topOffset(); this.at = off; this.R.relocated({ loc: 'o:' + off, pct: max > 0 ? Math.min(1, s.scrollTop / max) : 1, label: '' }); this.R.$('#rdbm').classList.toggle('on', !!this.R.isMarked()); }
|
||
next() { this.scroller().scrollBy({ top: this.w.innerHeight * 0.9, behavior: 'smooth' }); } prev() { this.scroller().scrollBy({ top: -this.w.innerHeight * 0.9, behavior: 'smooth' }); }
|
||
seek(f) { const s = this.scroller(); s.scrollTop = f * (s.scrollHeight - s.clientHeight); }
|
||
here() { const o = this.topOffset(), t = this.d.body.textContent.slice(o, o + 120).replace(/\s+/g, ' ').trim(); return { loc: 'o:' + o, text: t }; }
|
||
sameSpot(n) { if (n.type !== 'mark') return false; const m = /^o:(\d+)/.exec(n.loc), o = this.topOffset(); return !!m && Math.abs(+m[1] - o) < 400 && this.w.innerHeight > 0 && Math.abs(+m[1] - o) < this.d.body.textContent.length; }
|
||
async toc() { const out = []; this.d.querySelectorAll('h1,h2,h3,h4').forEach(h => { const t = h.textContent.trim(); if (!t) return; const w = this.d.createTreeWalker(h, NodeFilter.SHOW_TEXT), first = w.nextNode(); out.push({ label: t.slice(0, 120), loc: 'o:' + (first ? this.offsetTo(first, 0) : 0), depth: Math.min(3, +h.tagName[1] - 1) }); }); return out; }
|
||
async search(q, stale) {
|
||
const text = this.d.body.textContent, low = text.toLowerCase(), lq = q.toLowerCase(), out = []; let i = -1;
|
||
while ((i = low.indexOf(lq, i + 1)) >= 0 && out.length < 200) out.push({ loc: 'o:' + i, excerpt: rdExcerpt(text.replace(/\s/g, ' '), i, q.length), where: Math.round(i / Math.max(1, text.length) * 100) + '% into the document' });
|
||
return out;
|
||
}
|
||
goto(loc, term, note) {
|
||
const m = /^o:(\d+)(?:-(\d+))?$/.exec(loc); if (!m) return; this.gotoOffset(+m[1]);
|
||
if (term || note) { const a = +m[1], b = m[2] ? +m[2] : a + (term ? term.length : 0); this.wrap(a, b, 'rdq flash', null); setTimeout(() => this.d.querySelectorAll('mark.rdq').forEach(k => { k.replaceWith(...k.childNodes); }), 2200); this.d.body.normalize(); }
|
||
}
|
||
checkSel() {
|
||
const s = this.w.getSelection(); if (!s || s.isCollapsed || !s.rangeCount) return; const r = s.getRangeAt(0), a = this.offsetTo(r.startContainer, r.startOffset), b = this.offsetTo(r.endContainer, r.endOffset);
|
||
const text = s.toString().replace(/\s+/g, ' ').trim(); if (!text || b <= a) return;
|
||
this.R.showSel({ loc: `o:${a}-${b}`, text, clear: () => s.removeAllRanges() });
|
||
}
|
||
wrap(a, b, cls, id, color) { // wrap the text between two offsets in <mark> elements, one per text node it touches
|
||
let total = 0; const todo = [];
|
||
for (const t of this.textNodes()) { const len = t.nodeValue.length, s = Math.max(a, total), e = Math.min(b, total + len); if (e > s) todo.push([t, s - total, e - total]); total += len; if (total >= b) break; }
|
||
for (const [t, s, e] of todo) { const mid = t.splitText(s); mid.splitText(e - s); const k = this.d.createElement('mark'); k.className = cls; if (id) { k.dataset.nid = id; k.style.background = (RD_COLORS[color] || RD_COLORS.yellow) + 'a8'; k.style.cursor = 'pointer'; k.onclick = () => { const cur = this.R.notes.find(x => x.id === id); if (cur && this.w.getSelection().isCollapsed) this.R.noteDialog(cur); }; } mid.parentNode.replaceChild(k, mid); k.appendChild(mid); }
|
||
}
|
||
drawNote(n) { const m = /^o:(\d+)-(\d+)$/.exec(n.loc || ''); if (!m || n.del || n.type === 'mark' || this.d.querySelector(`mark[data-nid="${n.id}"]`)) return; this.wrap(+m[1], +m[2], 'rdh', n.id, n.color); }
|
||
eraseNote(n) { this.d.querySelectorAll(`mark[data-nid="${n.id}"]`).forEach(k => k.replaceWith(...k.childNodes)); this.d.body.normalize(); }
|
||
resize() {}
|
||
destroy() {}
|
||
}
|
||
|
||
// ======================================================= MOBI / AZW / AZW3 (DRM-free): every chapter is joined into one page, then read like a DOCX
|
||
class RdMobi extends RdDocx {
|
||
async convert() {
|
||
const buf = new Uint8Array(await rdFetchBook(this.R.book.id, 'mobi'));
|
||
this.m = await rdMobiOpen(buf); const spine = this.m.getSpine(), css = new Set(), parts = [];
|
||
for (const ch of spine) { const c = this.m.loadChapter(ch.id); if (!c) continue; (c.css || []).forEach(x => css.add(x.href)); parts.push(`<section class="rdch" id="rdch-${esc(ch.id)}" data-ch="${esc(ch.id)}">${c.html}</section>`); }
|
||
if (!parts.length) throw new Error('This book has no readable text. It may have DRM.');
|
||
return { html: parts.join(''), head: [...css].map(h => `<link rel="stylesheet" href="${esc(h)}">`).join('') };
|
||
}
|
||
/** a link inside the book: the parser says which chapter (and where in it) it points to */
|
||
link(a) {
|
||
const href = a.getAttribute('href') || ''; if (/^(https?:|mailto:)/i.test(href)) { window.open(href, '_blank', 'noopener'); return true; }
|
||
let r; try { r = this.m.resolveHref(href); } catch {} if (!r) return false;
|
||
const sec = this.d.getElementById('rdch-' + r.id); let el = sec; if (sec && r.selector) { try { el = sec.querySelector(r.selector) || sec; } catch {} }
|
||
if (el) { el.scrollIntoView(); return true; } return false;
|
||
}
|
||
async toc() {
|
||
const out = [], walk = (items, d) => (items || []).forEach(t => {
|
||
let loc = null; try { const r = this.m.resolveHref(t.href); const sec = r && this.d.getElementById('rdch-' + r.id); let el = sec; if (sec && r.selector) { try { el = sec.querySelector(r.selector) || sec; } catch {} } if (el) { const w = this.d.createTreeWalker(el, NodeFilter.SHOW_TEXT), first = w.nextNode(); if (first) loc = 'o:' + this.offsetTo(first, 0); } } catch {}
|
||
if (loc) out.push({ label: String(t.label || '').trim().slice(0, 120) || 'Untitled', loc, depth: d }); walk(t.children, d + 1); });
|
||
walk(this.m.getToc(), 0);
|
||
return out.length ? out : super.toc();
|
||
}
|
||
destroy() { try { this.m && this.m.destroy(); } catch {} }
|
||
}
|