'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]); if (args[0] === 'store') return rdStore(); const v = $('#view'); v.innerHTML = '

Books

Loading…

'; try { await rdLibrary(); } catch (e) { if (!location.hash.startsWith('#/read')) return; v.innerHTML = `

Books

${esc(e.message)}

`; $('#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 => `${b.cover ? `` : ''}${esc(b.title)}${esc(b.ext)}${b.pct > 0 ? `` : ''}`; const card = b => `${cover(b)}${esc(b.title)}${esc(b.author || b.ext.toUpperCase())}${b.pct > 0 ? ' · ' + Math.round(b.pct * 100) + '%' : ''}`; v.innerHTML = `

Books

${icon('search')}Book store

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.

`; 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 ? `

No books yet. Add an EPUB, PDF or DOCX file to start reading.

` : `
${cont.length ? `

Continue reading

${cont.map(card).join('')}
` : ''}

${f ? 'Results' : 'Library'} (${sorted.length})

${sorted.length ? `
${sorted.map(card).join('')}
` : '

Nothing matches.

'}`; 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', '', `
`); 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); } // ======================================================= Book store: free, DRM-free books from Project Gutenberg, Standard Ebooks, the Internet Archive and any OPDS catalogue async function rdStore() { const v = $('#view'); v.innerHTML = '

Book store

Loading…

'; let sources, mine; try { [sources, mine] = await Promise.all([rdApi('/store/sources'), rdApi('/list')]); } catch (e) { v.innerHTML = `
${icon('back')}Books

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/read/store')) return; const key = b => (b.title + '|' + (b.author || '')).toLowerCase().replace(/\s+/g, ' ').trim(), have = new Map(mine.map(b => [key(b), b.id])); const S = { q: '', source: 'all', page: 1, items: [], more: false, seq: 0 }; v.innerHTML = `
${icon('back')}Books

Book store

${ME && ME.role === 'admin' ? `` : ''}

Free books without DRM, from public sources. Search them all at once, then add a book to your library in one tap.

`; const chips = () => { $('#rdchips').innerHTML = [{ id: 'all', name: 'All' }, ...sources].map(x => `${esc(x.name)}`).join(''); $('#rdchips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.source = a.dataset.s; chips(); run(true); }); }; const draw = () => { const res = $('#rdres'); if (!res) return; res.innerHTML = S.items.length ? `
${S.items.map((b, i) => `${b.cover ? `` : ''}${esc(b.title)}${esc(b.format)}${have.has(key(b)) ? 'In library' : ''}${esc(b.title)}${esc(b.author || '')}${b.author ? ' · ' : ''}${esc(b.sourceName)}`).join('')}
` : (S.busy ? '' : `

${S.q ? 'Nothing found.' : 'Search for a book or an author.'}

`); res.querySelectorAll('.vcard').forEach(a => a.onclick = e => { e.preventDefault(); detail(S.items[a.dataset.i]); }); $('#rdmore').innerHTML = S.more && !S.busy ? '' : S.busy ? 'Searching…' : ''; if ($('#rdmb')) $('#rdmb').onclick = () => run(false); }; const run = async reset => { const my = ++S.seq; if (reset) { S.page = 1; S.items = []; } else S.page++; S.busy = true; $('#rdnote').innerHTML = ''; draw(); try { const r = await rdApi(`/store/search?q=${encodeURIComponent(S.q)}&source=${encodeURIComponent(S.source)}&page=${S.page}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; const errs = Object.values(r.errors || {}).map(esc); $('#rdnote').innerHTML = errs.length ? `

${errs.join('
')}

` : ''; } catch (e) { if (my === S.seq) $('#rdnote').innerHTML = `

${esc(e.message)}

`; } if (my === S.seq) { S.busy = false; draw(); } }; const detail = b => { const el = openSheet(b.title, b.author || '', `
${b.cover ? `` : ''}${esc(b.title)}

${esc(b.sourceName)} · ${esc(b.format.toUpperCase())}

${b.summary ? `

${esc(b.summary)}

` : ''}
`); el.querySelector('#sx').onclick = closeSheet; const btn = el.querySelector('#rdaa2'); btn.onclick = async () => { if (have.has(key(b))) { closeSheet(); location.hash = '#/read/open/' + have.get(key(b)); return; } btn.disabled = true; btn.textContent = 'Adding…'; el.querySelector('#rdae').textContent = ''; try { const r = await rdApi('/store/add', rdJson('POST', { source: b.source, ref: b.ref, title: b.title, author: b.author, cover: b.coverUrl, format: b.format })); have.set(key(b), r.id); draw(); btn.disabled = false; btn.textContent = 'Open'; toast(r.duplicate ? 'Already in your library' : 'Added to your library'); } catch (e) { el.querySelector('#rdae').textContent = e.message; btn.disabled = false; btn.innerHTML = icon('plus') + 'Try again'; } }; }; const catalogues = () => { const el = openSheet('Catalogues', 'Anything that speaks OPDS: Calibre-Web, Kavita, Komga, Standard Ebooks and others', '
'); const list = async () => { sources = await rdApi('/store/sources'); chips(); el.querySelector('#rdcl').innerHTML = `
${sources.map(x => `
${esc(x.name)}${x.builtin ? 'Built in' : 'Added by you'}${x.builtin ? '' : ``}
`).join('')}
`; el.querySelectorAll('[data-d]').forEach(b => b.onclick = async () => { try { await rdApi('/store/sources/' + b.dataset.d, { method: 'DELETE' }); if (S.source === b.dataset.d) S.source = 'all'; await list(); } catch (e) { toast(e.message); } }); el.querySelector('#rdcf').onsubmit = async e => { e.preventDefault(); const err = el.querySelector('#rdce'), btn = e.submitter; err.textContent = ''; if (btn) btn.disabled = true; try { await rdApi('/store/sources', rdJson('POST', { name: el.querySelector('#rdcn').value, url: el.querySelector('#rdcu').value, user: el.querySelector('#rdcuser').value, pass: el.querySelector('#rdcp').value })); toast('Catalogue added'); await list(); } catch (x) { err.textContent = x.message; if (btn) btn.disabled = false; } }; }; list(); }; chips(); let t; $('#rdsq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); run(true); }, 450); }; $('#rdsq').onkeydown = e => { if (e.key === 'Enter') { clearTimeout(t); S.q = e.target.value.trim(); run(true); } }; if ($('#rdcat')) $('#rdcat').onclick = catalogues; run(true); } // ======================================================= 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 = '

Opening…

'; 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 = `
${icon('back')}Books

${esc(e.message)}

`; 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 = `
${icon('back')}${esc(b.title)}${esc(b.author || '')}

Opening…

`; 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 = `${esc(e.message || 'This book could not be opened')}
Back to library`; } } 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]) => ``).join('') + ``; 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 ? `
${esc(((n || s).text || '').slice(0, 400))}
` : ''}
${Object.entries(RD_COLORS).map(([c, hex]) => ``).join('')}
${n ? '' : ''}
`); 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', '', '

Loading…

'); let items = []; try { items = await this.A.toc(); } catch {} el.querySelector('.empty').outerHTML = items.length ? `
${items.map((t, i) => ``).join('')}
` : '

This book has no table of contents.

'; el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(items[b.dataset.i].loc); }); } showSearch() { const el = openSheet('Search in book', '', `
`); 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 = '

Searching…

'; try { const out = await this.A.search(term, () => my !== seq); if (my !== seq) return; res.innerHTML = out.length ? out.map((r, i) => ``).join('') : '

Nothing found.

'; 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 = `

${esc(e.message)}

`; } }; 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', '', '
'); 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 ? `
${l.map(n => `
${n.type === 'mark' ? '' : ``}
`).join('')}
` : '

Nothing yet. Select text to highlight it or add a note; tap the bookmark button to mark a place.

'; 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) => `
${opts.map(([v, l]) => ``).join('')}
`; const el = openSheet('Appearance', '', `${seg('theme', [['auto', 'Auto'], ['light', 'Light'], ['sepia', 'Sepia'], ['dark', 'Dark']], P.theme)} ${pdf ? `
${Math.round(P.zoom * 100)}%
` : `
${P.size}
${seg('font', Object.entries(RD_FONTS).filter(([k]) => !(docx && !k)).map(([k, l]) => [k, l]), P.font)} ${seg('lh', [[1.3, 'Tight'], [1.55, 'Normal'], [1.85, 'Loose']], P.lh)} ${seg('margin', [[0, 'Narrow'], [2, 'Normal'], [4, 'Wide']], P.margin)} ${docx ? '' : `${seg('flow', [['paged', 'Pages'], ['scroll', 'Scroll']], P.flow)}`}`}
`); 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)) + '' + esc(text.slice(i, i + len)) + '' + 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; if (this.R.P.flow !== 'paged') return R.toggleBars(); const x = this.tapX(contents, e.clientX); if (x < 0.22) this.prev(); else if (x > 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); } /** Where a tap landed as a fraction of the reading area. The page frame is wider than the screen (the pages sit side by side inside it), so its own coordinates say nothing about the screen. */ tapX(contents, clientX) { const f = contents.window.frameElement.getBoundingClientRect(), s = this.stage.getBoundingClientRect(); return (f.left + clientX - s.left) / s.width; } 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 = '
'; 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 = `${res.head}
${res.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 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(`
${c.html}
`); } if (!parts.length) throw new Error('This book has no readable text. It may have DRM.'); return { html: parts.join(''), head: [...css].map(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 {} } }