@@ -25855,6 +25948,177 @@
}
return { blob: interior, warnings };
}
+ // ---- Reader Mode (Phase G1) ----------------------------------------
+ // Full-screen page-flip viewer driven off the manifest. Walks
+ // enumerateBookPhysicalPages and renders each via the existing
+ // export-canvas helpers (same path the PDF pipeline uses) at a
+ // moderate DPI so the canvas-as-image stays sharp at typical
+ // viewport sizes. Pages render lazily and cache during the
+ // session so re-visits are instant.
+ const _bookReaderState = {
+ active: false,
+ sequence: [],
+ currentIdx: 0,
+ canvases: new Map(), // physicalIdx → HTMLCanvasElement
+ keyHandler: null,
+ tocOpen: false,
+ };
+ async function openBookReader() {
+ const book = state.book;
+ if (!book) return;
+ const seq = enumerateBookPhysicalPages(book);
+ if (!seq.length) {
+ toastError && toastError('Nothing to read — add at least one page or chapter first.');
+ return;
+ }
+ _bookReaderState.active = true;
+ _bookReaderState.sequence = seq;
+ _bookReaderState.currentIdx = 0;
+ _bookReaderState.canvases.clear();
+ const overlay = document.getElementById('bookReaderOverlay');
+ if (!overlay) return;
+ overlay.classList.add('open');
+ overlay.setAttribute('aria-hidden', 'false');
+ const titleEl = document.getElementById('bookReaderTitle');
+ if (titleEl) titleEl.textContent = state.meta?.title || 'Book';
+ _renderBookReaderToc();
+ await _showBookReaderPage(0);
+ _bookReaderState.keyHandler = (e) => {
+ if (!_bookReaderState.active) return;
+ if (e.key === 'Escape') { closeBookReader(); e.preventDefault(); return; }
+ if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') {
+ _gotoBookReaderPage(_bookReaderState.currentIdx + 1); e.preventDefault();
+ } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
+ _gotoBookReaderPage(_bookReaderState.currentIdx - 1); e.preventDefault();
+ } else if (e.key === 'Home') {
+ _gotoBookReaderPage(0); e.preventDefault();
+ } else if (e.key === 'End') {
+ _gotoBookReaderPage(_bookReaderState.sequence.length - 1); e.preventDefault();
+ }
+ };
+ document.addEventListener('keydown', _bookReaderState.keyHandler);
+ }
+ function closeBookReader() {
+ _bookReaderState.active = false;
+ const overlay = document.getElementById('bookReaderOverlay');
+ if (overlay) {
+ overlay.classList.remove('open');
+ overlay.setAttribute('aria-hidden', 'true');
+ }
+ if (_bookReaderState.keyHandler) {
+ document.removeEventListener('keydown', _bookReaderState.keyHandler);
+ _bookReaderState.keyHandler = null;
+ }
+ _bookReaderState.canvases.clear();
+ }
+ function _renderBookReaderToc() {
+ const list = document.getElementById('bookReaderTocList');
+ if (!list) return;
+ const seq = _bookReaderState.sequence;
+ const book = state.book;
+ let lastChapter = '';
+ const fragments = [];
+ seq.forEach((slot, idx) => {
+ const entry = book.pages.find(p => p.id === slot.entryId);
+ if (slot.chapterTitle && slot.chapterTitle !== lastChapter && slot.indexInEntry === 0) {
+ fragments.push(`
${escapeHtml(slot.chapterTitle)}
`);
+ lastChapter = slot.chapterTitle;
+ } else if (entry?.type === 'design') {
+ const labelBase = entry.label || `Page ${slot.pageNumber != null ? slot.pageNumber : (idx + 1)}`;
+ fragments.push(`
${escapeHtml(labelBase)}
`);
+ }
+ });
+ list.innerHTML = fragments.join('') || '
(empty)
';
+ list.querySelectorAll('.bookReaderTocItem').forEach(el => {
+ el.addEventListener('click', () => {
+ const idx = Number(el.dataset.idx);
+ if (Number.isFinite(idx)) _gotoBookReaderPage(idx);
+ if (window.innerWidth < 900) _toggleBookReaderToc(false);
+ });
+ });
+ _updateBookReaderTocCurrent();
+ }
+ function _updateBookReaderTocCurrent() {
+ const list = document.getElementById('bookReaderTocList');
+ if (!list) return;
+ const cur = _bookReaderState.currentIdx;
+ list.querySelectorAll('.bookReaderTocItem').forEach(el => {
+ const idx = Number(el.dataset.idx);
+ el.classList.toggle('current', idx === cur);
+ });
+ }
+ function _toggleBookReaderToc(force) {
+ const aside = document.getElementById('bookReaderToc');
+ if (!aside) return;
+ const wantOpen = typeof force === 'boolean' ? force : !aside.classList.contains('open');
+ aside.classList.toggle('open', wantOpen);
+ _bookReaderState.tocOpen = wantOpen;
+ }
+ async function _gotoBookReaderPage(idx) {
+ const max = _bookReaderState.sequence.length - 1;
+ const clamped = Math.max(0, Math.min(max, idx));
+ if (clamped === _bookReaderState.currentIdx && _bookReaderState.canvases.has(clamped)) return;
+ _bookReaderState.currentIdx = clamped;
+ await _showBookReaderPage(clamped);
+ _updateBookReaderTocCurrent();
+ // Prefetch the next two pages so forward flips feel instant.
+ for (let i = 1; i <= 2; i++) {
+ const j = clamped + i;
+ if (j <= max && !_bookReaderState.canvases.has(j)) {
+ _renderBookReaderPageCanvas(j).catch(() => {});
+ }
+ }
+ }
+ async function _renderBookReaderPageCanvas(idx) {
+ if (_bookReaderState.canvases.has(idx)) return _bookReaderState.canvases.get(idx);
+ const slot = _bookReaderState.sequence[idx];
+ const book = state.book;
+ const entry = book.pages.find(p => p.id === slot.entryId);
+ if (!entry) return null;
+ const ctxToken = bookTokenContext(book, slot.entryId, slot.indexInEntry);
+ const dpi = 144; // ~1.5× CSS px; sharp at typical viewport widths.
+ let canvas;
+ if (slot.type === 'reflow') {
+ const pag = paginateChapter(entry, book);
+ canvas = await renderBookReflowPhysicalPageToCanvas(entry, pag, slot.indexInEntry, dpi, ctxToken);
+ } else {
+ canvas = await renderBookDesignedPageToCanvas(entry, dpi, ctxToken);
+ }
+ _bookReaderState.canvases.set(idx, canvas);
+ return canvas;
+ }
+ async function _showBookReaderPage(idx) {
+ const wrap = document.getElementById('bookReaderPageWrap');
+ const counter = document.getElementById('bookReaderCounter');
+ const fill = document.getElementById('bookReaderProgressFill');
+ const prevBtn = document.getElementById('bookReaderPrev');
+ const nextBtn = document.getElementById('bookReaderNext');
+ if (!wrap) return;
+ wrap.innerHTML = '
Rendering…
';
+ try {
+ const canvas = await _renderBookReaderPageCanvas(idx);
+ if (!canvas) { wrap.innerHTML = '
Failed to render this page.
'; return; }
+ const img = canvas.cloneNode(true);
+ // Browsers don't deep-clone canvas pixel data via cloneNode;
+ // copy pixels manually so the cloned canvas paints.
+ const ctx2 = img.getContext('2d');
+ ctx2.drawImage(canvas, 0, 0);
+ img.className = 'bookReaderPage';
+ wrap.innerHTML = '';
+ wrap.appendChild(img);
+ } catch (err) {
+ console.warn('reader render failed', err);
+ wrap.innerHTML = `
Render error: ${escapeHtml(String(err.message || err))}
`;
+ }
+ const total = _bookReaderState.sequence.length;
+ if (counter) counter.textContent = `${idx + 1} / ${total}`;
+ if (fill) fill.style.width = `${((idx + 1) / total) * 100}%`;
+ if (prevBtn) prevBtn.disabled = idx <= 0;
+ if (nextBtn) nextBtn.disabled = idx >= total - 1;
+ }
+
+ // ---- end Reader Mode ----------------------------------------------------
+
// "Proof one page" / "Proof one chapter" quick export (E4).
// Skips the modal — one click → PDF download scoped to a single
// manifest entry. Useful for sending a single page to a printer
@@ -35413,6 +35677,21 @@
on('bookAddTenBtn', 'click', addBookTenPages);
on('bookAddChapterBtn', 'click', addBookChapter);
on('bookImportPageBtn', 'click', importBookPageFromNostr);
+ on('bookReaderBtn', 'click', openBookReader);
+ on('bookReaderCloseBtn', 'click', closeBookReader);
+ on('bookReaderTocToggle', 'click', () => _toggleBookReaderToc());
+ on('bookReaderPrev', 'click', () => _gotoBookReaderPage(_bookReaderState.currentIdx - 1));
+ on('bookReaderNext', 'click', () => _gotoBookReaderPage(_bookReaderState.currentIdx + 1));
+ // Click on the page stage flips: left half = back, right half = forward.
+ const _bookReaderStageEl = document.getElementById('bookReaderStage');
+ if (_bookReaderStageEl) _bookReaderStageEl.addEventListener('click', (e) => {
+ // Don't flip when clicking the arrow buttons.
+ if (e.target.closest('.bookReaderArrow')) return;
+ const rect = _bookReaderStageEl.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ if (x < rect.width / 2) _gotoBookReaderPage(_bookReaderState.currentIdx - 1);
+ else _gotoBookReaderPage(_bookReaderState.currentIdx + 1);
+ });
// Chapter editor panes.
on('bookChapterEditorBack', 'click', closeBookChapter);
on('bookChapterEditorTextarea', 'input', (e) => updateBookChapterMarkdown(e.target.value));