'use strict'; // ---- Books: an EPUB, PDF and DOCX reader. The library, covers, reading position, bookmarks, notes and highlights live on the server (per person), so // they follow you between devices. Reading uses epub.js, pdf.js and mammoth from /vendor/reader, loaded only when a book is opened or added. const rdApi = (p, o) => jfetch('/api/read' + p, o); const rdJson = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) }); const RD_LIBS = { epub: ['jszip.min.js', 'epub.min.js'], pdf: ['pdf.min.js'], docx: ['mammoth.browser.min.js'], mobi: ['mobi-parser.min.js'] }; const rdKind = ext => ['mobi', 'azw', 'azw3'].includes(ext) ? 'mobi' : ext; const rdLoaded = {}; async function rdLibs(kind) { for (const f of RD_LIBS[kind]) { rdLoaded[f] = rdLoaded[f] || new Promise((ok, no) => { const s = document.createElement('script'); s.src = '/vendor/reader/' + f; s.onload = ok; s.onerror = () => { delete rdLoaded[f]; no(new Error('Could not load the ' + kind.toUpperCase() + ' reader')); }; document.head.appendChild(s); }); await rdLoaded[f]; } if (kind === 'pdf') pdfjsLib.GlobalWorkerOptions.workerSrc = '/vendor/reader/pdf.worker.min.js'; } const RD_THEMES = { light: { bg: '#ffffff', fg: '#1b1b1b', link: '#1a5fd0' }, sepia: { bg: '#f4ecd8', fg: '#4b3a2a', link: '#8a4b14' }, dark: { bg: '#15171b', fg: '#d5d8de', link: '#8fb0ff' } }; const RD_FONTS = { '': 'Publisher default', serif: 'Serif', sans: 'Sans-serif', mono: 'Monospace' }; const RD_FAMILY = { serif: 'Georgia, "Times New Roman", serif', sans: '-apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', mono: 'ui-monospace, Menlo, Consolas, monospace' }; const RD_COLORS = { yellow: '#ffd43b', green: '#69db7c', blue: '#74c0fc', pink: '#f783ac' }; const rdDefaults = { theme: 'auto', size: 19, font: '', lh: 1.55, margin: 2, flow: 'paged', zoom: 1 }; const rdPrefs = () => { try { return { ...rdDefaults, ...JSON.parse(localStorage.getItem('rd-prefs') || '{}') }; } catch { return { ...rdDefaults }; } }; const rdSavePrefs = p => { try { localStorage.setItem('rd-prefs', JSON.stringify(p)); } catch {} }; const rdTheme = p => RD_THEMES[p.theme] || (matchMedia('(prefers-color-scheme: dark)').matches ? RD_THEMES.dark : RD_THEMES.light); const rdUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8); const rdTitleOf = f => f.replace(/\.[^.]+$/, '').replace(/[_]+/g, ' '); // ======================================================= Library async function viewReader(args = []) { if (args[0] === 'open' && args[1]) return rdOpen(args[1]); const v = $('#view'); v.innerHTML = '

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

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); } // ======================================================= 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 {} } }