diff --git a/src/index.html b/src/index.html index 8dc1eb9..0b50b12 100644 --- a/src/index.html +++ b/src/index.html @@ -28238,6 +28238,170 @@ paintSlide(); } + // Embed book mode (Phase G2). Cut-down Reader Mode for the + // chromeless iframe — no TOC sidebar, arrows + keyboard + click + // flips only. Sidecars resolve through the same disk-cached + // resolver the editor uses, so a re-embed paints from cache. + async function _paintEmbedBook(root, payload, event, route) { + const stage = root.querySelector('.embedStage'); + const footer = root.querySelector('.embedFooter'); + if (!stage || !footer) return; + // Seed editor-state shape so the canvas renderers can read + // book.spec + state.layers + state.meta without the full editor + // boot. + const incomingSpec = { ...defaultBookSpec(), ...(payload.book.spec || {}) }; + incomingSpec.textStyles = { ...defaultBookTextStyles(), ...(payload.book.spec?.textStyles || {}) }; + state.templateMode = 'book'; + state.meta = { ...(state.meta || {}), ...(payload.meta || {}) }; + state.book = { + spec: incomingSpec, + cover: payload.book.cover || null, + masters: Array.isArray(payload.book.masters) ? payload.book.masters.map(m => ({ ...m })) : [], + pages: Array.isArray(payload.book.pages) ? payload.book.pages.map(p => ({ ...p })) : [], + editingEntryId: null, + editingMasterId: null, + editingChapterId: null, + }; + state.layers = []; + _setEmbedStatus(root, 'loading', '⟳ Loading book pages…'); + // Resolve every page / master / cover / chapter ref. The disk + // cache makes re-embeds instant; fresh embeds round-trip relays. + try { await resolveBookSidecars(state.book); } + catch (err) { console.warn('[embed] book resolver failed', err); } + stage.innerHTML = ''; + footer.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'designObjectFitWrap'; + wrap.style.cssText = 'position:relative;width:100%;height:100%;overflow:hidden;display:flex;align-items:center;justify-content:center;background:#1a1a1a;cursor:pointer;user-select:none'; + stage.appendChild(wrap); + const pageBox = document.createElement('div'); + pageBox.style.cssText = 'display:flex;align-items:center;justify-content:center;max-width:92%;max-height:90%'; + wrap.appendChild(pageBox); + const params = route.params || new URLSearchParams(); + const showControls = params.get('controls') !== '0'; + const showAttr = params.get('attr') !== '0'; + const showOpen = params.get('open') !== '0'; + const seq = enumerateBookPhysicalPages(state.book); + if (!seq.length) { + _setEmbedStatus(root, 'error', 'This book has no pages yet.'); + return; + } + const requested = Math.max(1, parseInt(params.get('page') || '1', 10)) - 1; + let idx = Math.min(seq.length - 1, Math.max(0, requested)); + const canvasCache = new Map(); + const renderPage = async (i) => { + if (canvasCache.has(i)) return canvasCache.get(i); + const slot = seq[i]; + const entry = state.book.pages.find(p => p.id === slot.entryId); + if (!entry) return null; + const ctxToken = bookTokenContext(state.book, slot.entryId, slot.indexInEntry); + const dpi = 144; + let canvas; + if (slot.type === 'reflow') { + const pag = paginateChapter(entry, state.book); + canvas = await renderBookReflowPhysicalPageToCanvas(entry, pag, slot.indexInEntry, dpi, ctxToken); + } else { + canvas = await renderBookDesignedPageToCanvas(entry, dpi, ctxToken); + } + canvasCache.set(i, canvas); + return canvas; + }; + const showPage = async (i) => { + pageBox.innerHTML = '
Rendering…
'; + try { + const canvas = await renderPage(i); + if (!canvas) { pageBox.innerHTML = '
Failed to render.
'; return; } + const img = canvas.cloneNode(true); + img.getContext('2d').drawImage(canvas, 0, 0); + img.style.cssText = 'max-width:100%;max-height:100%;background:#fff;box-shadow:0 6px 24px rgba(0,0,0,.6)'; + pageBox.innerHTML = ''; + pageBox.appendChild(img); + } catch (err) { + console.warn('[embed] page render failed', err); + pageBox.innerHTML = `
Render error
`; + } + if (counter) counter.textContent = `${i + 1} / ${seq.length}`; + // Prefetch the next page in the background. + if (i + 1 < seq.length && !canvasCache.has(i + 1)) renderPage(i + 1).catch(() => {}); + }; + const go = (newIdx) => { + idx = Math.min(seq.length - 1, Math.max(0, newIdx)); + showPage(idx); + }; + // Click-to-flip: left half back, right half forward. + wrap.addEventListener('click', (e) => { + const r = wrap.getBoundingClientRect(); + const x = e.clientX - r.left; + if (x < r.width / 2) go(idx - 1); + else go(idx + 1); + }); + // Footer chips. + const left = document.createElement('div'); + left.className = 'embedSide'; + const right = document.createElement('div'); + right.className = 'embedSide'; + right.style.justifyContent = 'flex-end'; + let counter = null; + if (showControls && seq.length > 1) { + const prevBtn = document.createElement('button'); + prevBtn.type = 'button'; prevBtn.className = 'embedChip'; + prevBtn.textContent = '◂'; prevBtn.title = 'Previous page'; + prevBtn.addEventListener('click', (e) => { e.stopPropagation(); go(idx - 1); }); + counter = document.createElement('span'); + counter.className = 'embedChip embedChipCounter'; + const nextBtn = document.createElement('button'); + nextBtn.type = 'button'; nextBtn.className = 'embedChip'; + nextBtn.textContent = '▸'; nextBtn.title = 'Next page'; + nextBtn.addEventListener('click', (e) => { e.stopPropagation(); go(idx + 1); }); + left.append(prevBtn, counter, nextBtn); + } + if (showAttr && event?.pubkey && typeof _makeAuthorChip === 'function') { + left.appendChild(_makeAuthorChip(event.pubkey)); + } + if (showControls) { + const fsBtn = document.createElement('button'); + fsBtn.type = 'button'; fsBtn.className = 'embedChip'; + fsBtn.textContent = '⛶'; fsBtn.title = 'Toggle fullscreen'; + fsBtn.addEventListener('click', (e) => { e.stopPropagation(); _toggleEmbedFullscreen(root); }); + right.appendChild(fsBtn); + } + if (showOpen) { + const openLink = document.createElement('a'); + openLink.className = 'embedChip'; + openLink.textContent = 'Open in Artstr ↗'; + openLink.href = `${location.origin}/#/share/${route.id}`; + openLink.target = '_top'; + openLink.rel = 'noopener'; + right.appendChild(openLink); + } + footer.appendChild(left); + footer.appendChild(right); + footer.dataset.hidden = (!showControls && !showAttr && !showOpen) ? 'true' : 'false'; + // Keyboard nav. + if (_paintEmbedBook._kbd) { + document.removeEventListener('keydown', _paintEmbedBook._kbd); + } + const onKey = (e) => { + const tag = (e.target?.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return; + switch (e.key) { + case 'ArrowRight': case 'PageDown': case ' ': + e.preventDefault(); go(idx + 1); break; + case 'ArrowLeft': case 'PageUp': + e.preventDefault(); go(idx - 1); break; + case 'Home': + e.preventDefault(); go(0); break; + case 'End': + e.preventDefault(); go(seq.length - 1); break; + case 'f': case 'F': + e.preventDefault(); _toggleEmbedFullscreen(root); break; + } + }; + document.addEventListener('keydown', onKey); + _paintEmbedBook._kbd = onKey; + await showPage(idx); + } + function _toggleEmbedFullscreen(root) { const target = root || document.documentElement; if (document.fullscreenElement) { @@ -28259,6 +28423,13 @@ _paintEmbedDeck(root, payload, event, route); return; } + if (mode === 'book' && payload?.book) { + _paintEmbedBook(root, payload, event, route).catch(err => { + console.warn('[embed] book paint failed', err); + _setEmbedStatus(root, 'error', "Couldn't load this book."); + }); + return; + } const stage = root.querySelector('.embedStage'); const footer = root.querySelector('.embedFooter'); if (!stage || !footer) return;