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.public { color: #15803d; border-color: #86efac; }
|
||||||
.bookVisibilityChip.private { color: #6b21a8; border-color: #c084fc; background: #faf5ff; }
|
.bookVisibilityChip.private { color: #6b21a8; border-color: #c084fc; background: #faf5ff; }
|
||||||
.bookVisibilityChip:disabled { opacity: .5; cursor: not-allowed; }
|
.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 {
|
.bookStarterPanel {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
padding: 18px 22px; border: 1px dashed var(--line); border-radius: 8px;
|
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="bookAddTenBtn">+ Add 10 pages</button>
|
||||||
<button type="button" id="bookAddChapterBtn">+ Add chapter</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="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>
|
<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>
|
<span id="bookExportPdfStatus" class="hint" style="margin-left:6px;font-size:.74rem"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -5343,6 +5413,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="modal" id="bookExportPdfModal" aria-hidden="true" style="display:none">
|
||||||
<div class="modalInner" style="max-width:520px;position:relative">
|
<div class="modalInner" style="max-width:520px;position:relative">
|
||||||
<button type="button" id="bookExportPdfCloseBtn" class="welcomeCloseBtn" aria-label="Close">×</button>
|
<button type="button" id="bookExportPdfCloseBtn" class="welcomeCloseBtn" aria-label="Close">×</button>
|
||||||
@@ -25855,6 +25948,177 @@
|
|||||||
}
|
}
|
||||||
return { blob: interior, warnings };
|
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).
|
// "Proof one page" / "Proof one chapter" quick export (E4).
|
||||||
// Skips the modal — one click → PDF download scoped to a single
|
// Skips the modal — one click → PDF download scoped to a single
|
||||||
// manifest entry. Useful for sending a single page to a printer
|
// manifest entry. Useful for sending a single page to a printer
|
||||||
@@ -35413,6 +35677,21 @@
|
|||||||
on('bookAddTenBtn', 'click', addBookTenPages);
|
on('bookAddTenBtn', 'click', addBookTenPages);
|
||||||
on('bookAddChapterBtn', 'click', addBookChapter);
|
on('bookAddChapterBtn', 'click', addBookChapter);
|
||||||
on('bookImportPageBtn', 'click', importBookPageFromNostr);
|
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.
|
// Chapter editor panes.
|
||||||
on('bookChapterEditorBack', 'click', closeBookChapter);
|
on('bookChapterEditorBack', 'click', closeBookChapter);
|
||||||
on('bookChapterEditorTextarea', 'input', (e) => updateBookChapterMarkdown(e.target.value));
|
on('bookChapterEditorTextarea', 'input', (e) => updateBookChapterMarkdown(e.target.value));
|
||||||
|
|||||||
Reference in new issue
Block a user