Files
hub/public/reader.js
T

580 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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'] };
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" multiple hidden>
<div id="rdbody"></div><p class="sub" id="rdhint" style="margin-top:16px">EPUB, PDF and DOCX. 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)$/i.test(f.name));
if (!files.length) return toast('Choose EPUB, PDF or DOCX files');
let added = 0, failed = 0;
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++; toast(f.name + ': ' + e.message); await new Promise(r => setTimeout(r, 1800)); }
}
toast(added ? `Added ${added} book${added > 1 ? 's' : ''}${failed ? ', ' + failed + ' failed' : ''}` : failed ? '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;
}
return {};
}
// ======================================================= 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(b.ext);
this.A = b.ext === 'epub' ? new RdEpub(this) : b.ext === 'pdf' ? new RdPdf(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 open(stage, loc) {
const R = this.R, buf = await rdFetchBook(R.book.id, 'docx');
const res = await mammoth.convertToHtml({ arrayBuffer: buf }, { styleMap: ['p[style-name="Title"] => h1.title:fresh', 'p[style-name="Subtitle"] => p.subtitle:fresh'] });
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></head><body><article>${res.value}</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 (a.getAttribute('href').startsWith('#')) this.d.getElementById(a.getAttribute('href').slice(1))?.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();
}
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() {}
}