mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
book(G1): Reader Mode — full-screen page-flip viewer
Full-screen reader launched from the Pages overview. Walks enumerateBookPhysicalPages and renders each via the existing export-canvas helpers (the same path the PDF pipeline uses) at ~144 DPI; pages cache in a session Map for instant revisits. Affordances: - Header: ☰ TOC toggle, book title, ✕ Close. - TOC sidebar: chapter headings + designed-page labels with current-page highlight; click jumps. Collapsible (closed by default; auto-closes on mobile-width jumps). - Stage: current page centered in the dark viewport. Click the left half to go back, right half to advance. Hover-revealed arrows on each side as a backup affordance. - Footer: "N / total" counter, progress bar, keyboard-hint line. Keyboard nav: → / Space / PageDown advance; ← / PageUp back; Home / End jump to first / last; Esc exits. Handler is registered on open and removed on close so the editor's shortcuts stay live behind the modal. Prefetch: after a page renders, the next two pages render in the background (best-effort, errors silenced) so forward flips feel instant once the user gets going. Premium / encrypted handling: the underlying renderers already decrypt via _decryptBookSidecarEvent and substitute tokens via bookTokenContext, so encrypted-private books and chapter-token masters Just Work in Reader Mode too. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
ea3fa6f060
commit
0d0c89da6a
1 file changed
+279
+279
@@ -1663,6 +1663,75 @@
|
||||
.bookVisibilityChip.public { color: #15803d; border-color: #86efac; }
|
||||
.bookVisibilityChip.private { color: #6b21a8; border-color: #c084fc; background: #faf5ff; }
|
||||
.bookVisibilityChip:disabled { opacity: .5; cursor: not-allowed; }
|
||||
/* Reader Mode — full-screen book viewer launched from the
|
||||
Pages overview. Mirrors Presenter Mode's pattern for decks
|
||||
but with a TOC sidebar instead of a slide strip. */
|
||||
#bookReaderOverlay {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
background: #1a1a1a; color: #fafafa;
|
||||
display: none; flex-direction: column;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
#bookReaderOverlay.open { display: flex; }
|
||||
.bookReaderHeader {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 14px; border-bottom: 1px solid #2a2a2a;
|
||||
background: #0f0f0f;
|
||||
}
|
||||
.bookReaderTitle { flex: 1; font-size: .95rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookReaderTocToggle, .bookReaderCloseBtn {
|
||||
background: transparent; color: #fafafa; border: 1px solid #444;
|
||||
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: .82rem;
|
||||
}
|
||||
.bookReaderTocToggle:hover, .bookReaderCloseBtn:hover { background: #2a2a2a; }
|
||||
.bookReaderBody {
|
||||
flex: 1; display: flex; min-height: 0; position: relative;
|
||||
}
|
||||
.bookReaderToc {
|
||||
width: 0; overflow: hidden; transition: width .18s ease;
|
||||
background: #0f0f0f; border-right: 1px solid #2a2a2a;
|
||||
}
|
||||
.bookReaderToc.open { width: 240px; overflow-y: auto; }
|
||||
.bookReaderTocList { padding: 6px 0; }
|
||||
.bookReaderTocItem {
|
||||
padding: 6px 14px; font-size: .82rem; color: #c0c0c0;
|
||||
cursor: pointer; border-left: 3px solid transparent;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.bookReaderTocItem:hover { background: #1a1a1a; color: #fff; }
|
||||
.bookReaderTocItem.current { background: #1a1a1a; color: #fff; border-left-color: var(--accent, #f59e0b); }
|
||||
.bookReaderTocItem.chapterHead { color: #fde68a; font-weight: 600; padding-top: 10px; }
|
||||
.bookReaderStage {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
position: relative; min-width: 0; cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.bookReaderPage {
|
||||
max-width: 92%; max-height: 90%;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,.6);
|
||||
background: #fff;
|
||||
}
|
||||
.bookReaderPageWrap {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
.bookReaderArrow {
|
||||
position: absolute; top: 50%; transform: translateY(-50%);
|
||||
width: 44px; height: 60px; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,.4); border: none; color: #fafafa;
|
||||
font-size: 1.6rem; cursor: pointer; opacity: 0; transition: opacity .12s;
|
||||
}
|
||||
.bookReaderStage:hover .bookReaderArrow { opacity: 1; }
|
||||
.bookReaderArrow.prev { left: 14px; }
|
||||
.bookReaderArrow.next { right: 14px; }
|
||||
.bookReaderArrow:disabled { opacity: 0; pointer-events: none; }
|
||||
.bookReaderFooter {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 8px 16px; background: #0f0f0f; border-top: 1px solid #2a2a2a;
|
||||
font-size: .78rem; color: #c0c0c0;
|
||||
}
|
||||
.bookReaderProgress { flex: 1; height: 4px; background: #2a2a2a; border-radius: 2px; overflow: hidden; }
|
||||
.bookReaderProgressFill { height: 100%; background: var(--accent, #f59e0b); width: 0%; transition: width .18s; }
|
||||
.bookStarterPanel {
|
||||
grid-column: 1 / -1;
|
||||
padding: 18px 22px; border: 1px dashed var(--line); border-radius: 8px;
|
||||
@@ -4445,6 +4514,7 @@
|
||||
<button type="button" id="bookAddTenBtn">+ Add 10 pages</button>
|
||||
<button type="button" id="bookAddChapterBtn">+ Add chapter</button>
|
||||
<button type="button" id="bookImportPageBtn" title="Import a page from another author's book by naddr / nevent">+ Import page</button>
|
||||
<button type="button" id="bookReaderBtn" title="Open the book in Reader Mode (full-screen page-flip)">▶ Read</button>
|
||||
<button type="button" id="bookExportPdfBtn" title="Export this book as a PDF (interior pages + cover spread)">Export PDF…</button>
|
||||
<span id="bookExportPdfStatus" class="hint" style="margin-left:6px;font-size:.74rem"></span>
|
||||
</div>
|
||||
@@ -5343,6 +5413,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bookReaderOverlay" aria-hidden="true">
|
||||
<div class="bookReaderHeader">
|
||||
<button type="button" class="bookReaderTocToggle" id="bookReaderTocToggle" title="Toggle table of contents">☰ TOC</button>
|
||||
<div class="bookReaderTitle" id="bookReaderTitle">Book</div>
|
||||
<button type="button" class="bookReaderCloseBtn" id="bookReaderCloseBtn" title="Exit Reader (Esc)">✕ Close</button>
|
||||
</div>
|
||||
<div class="bookReaderBody">
|
||||
<aside class="bookReaderToc" id="bookReaderToc">
|
||||
<div class="bookReaderTocList" id="bookReaderTocList"></div>
|
||||
</aside>
|
||||
<div class="bookReaderStage" id="bookReaderStage">
|
||||
<button type="button" class="bookReaderArrow prev" id="bookReaderPrev" title="Previous page (←)">‹</button>
|
||||
<div class="bookReaderPageWrap" id="bookReaderPageWrap"></div>
|
||||
<button type="button" class="bookReaderArrow next" id="bookReaderNext" title="Next page (→)">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bookReaderFooter">
|
||||
<span id="bookReaderCounter">0 / 0</span>
|
||||
<div class="bookReaderProgress"><div class="bookReaderProgressFill" id="bookReaderProgressFill"></div></div>
|
||||
<span class="hint" style="font-size:.7rem;color:#888">← / → to flip · Space advances · Esc exits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="bookExportPdfModal" aria-hidden="true" style="display:none">
|
||||
<div class="modalInner" style="max-width:520px;position:relative">
|
||||
<button type="button" id="bookExportPdfCloseBtn" class="welcomeCloseBtn" aria-label="Close">×</button>
|
||||
@@ -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(`<div class="bookReaderTocItem chapterHead" data-idx="${idx}">${escapeHtml(slot.chapterTitle)}</div>`);
|
||||
lastChapter = slot.chapterTitle;
|
||||
} else if (entry?.type === 'design') {
|
||||
const labelBase = entry.label || `Page ${slot.pageNumber != null ? slot.pageNumber : (idx + 1)}`;
|
||||
fragments.push(`<div class="bookReaderTocItem" data-idx="${idx}">${escapeHtml(labelBase)}</div>`);
|
||||
}
|
||||
});
|
||||
list.innerHTML = fragments.join('') || '<div class="bookReaderTocItem">(empty)</div>';
|
||||
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 = '<div style="color:#888;font-size:.85rem">Rendering…</div>';
|
||||
try {
|
||||
const canvas = await _renderBookReaderPageCanvas(idx);
|
||||
if (!canvas) { wrap.innerHTML = '<div style="color:#b91c1c">Failed to render this page.</div>'; 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 = `<div style="color:#b91c1c">Render error: ${escapeHtml(String(err.message || err))}</div>`;
|
||||
}
|
||||
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));
|
||||
|
||||
Reference in new issue
Block a user