rspace-online/modules/rpubs/components/folk-pubs-flipbook.ts

464 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-pubs-flipbook> — Interactive page-flip PDF preview using pdf.js + StPageFlip.
*
* Ephemeral preview for generated PDFs — no IndexedDB caching needed.
* Follows the pattern from folk-book-reader.ts.
*
* Attributes:
* pdf-url — Blob URL or HTTP URL to the PDF
*/
const PDFJS_CDN = "https://unpkg.com/pdfjs-dist@4.9.155/build/pdf.min.mjs";
const PDFJS_WORKER_CDN = "https://unpkg.com/pdfjs-dist@4.9.155/build/pdf.worker.min.mjs";
const STPAGEFLIP_CDN = "https://unpkg.com/page-flip@2.0.7/dist/js/page-flip.browser.js";
export class FolkPubsFlipbook extends HTMLElement {
private _pdfUrl = "";
private _pageImages: string[] = [];
private _numPages = 0;
private _currentPage = 0;
private _aspectRatio = 1.414;
private _isLoading = true;
private _loadingProgress = 0;
private _loadingStatus = "Preparing...";
private _error: string | null = null;
private _flipBook: any = null;
private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
private _resizeTimer: ReturnType<typeof setTimeout> | null = null;
static get observedAttributes() {
return ["pdf-url"];
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === "pdf-url" && val !== _old) {
this._pdfUrl = val;
if (this.shadowRoot) this.loadPDF();
}
}
connectedCallback() {
this._pdfUrl = this.getAttribute("pdf-url") || "";
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
this.renderLoading();
if (this._pdfUrl) this.loadPDF();
}
disconnectedCallback() {
this._flipBook?.destroy();
if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler);
if (this._resizeTimer) clearTimeout(this._resizeTimer);
}
private async loadPDF() {
this._isLoading = true;
this._error = null;
this._pageImages = [];
this.renderLoading();
try {
this._loadingStatus = "Loading PDF.js...";
this.updateLoadingUI();
const pdfjsLib = await import(/* @vite-ignore */ PDFJS_CDN);
pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_CDN;
this._loadingStatus = "Rendering pages...";
this.updateLoadingUI();
const pdf = await pdfjsLib.getDocument(this._pdfUrl).promise;
this._numPages = pdf.numPages;
const firstPage = await pdf.getPage(1);
const viewport = firstPage.getViewport({ scale: 1 });
this._aspectRatio = viewport.width / viewport.height;
const scale = 2;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
for (let i = 1; i <= pdf.numPages; i++) {
this._loadingStatus = `Rendering page ${i} of ${pdf.numPages}...`;
this._loadingProgress = Math.round((i / pdf.numPages) * 100);
this.updateLoadingUI();
const page = await pdf.getPage(i);
const vp = page.getViewport({ scale });
canvas.width = vp.width;
canvas.height = vp.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, viewport: vp }).promise;
this._pageImages.push(canvas.toDataURL("image/jpeg", 0.85));
}
this._isLoading = false;
this._currentPage = 0;
this.renderReader();
} catch (e: any) {
this._error = e.message || "Failed to render PDF";
this._isLoading = false;
this.renderError();
}
}
private renderLoading() {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="loading">
<div class="loading-spinner"></div>
<div class="loading-status">${this._loadingStatus}</div>
<div class="loading-bar">
<div class="loading-fill" style="width:${this._loadingProgress}%"></div>
</div>
</div>
`;
}
private updateLoadingUI() {
if (!this.shadowRoot) return;
const status = this.shadowRoot.querySelector(".loading-status");
const fill = this.shadowRoot.querySelector(".loading-fill") as HTMLElement;
if (status) status.textContent = this._loadingStatus;
if (fill) fill.style.width = `${this._loadingProgress}%`;
}
private renderError() {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="error">
<p>Failed to render preview: ${this._error || "Unknown error"}</p>
</div>
`;
}
private renderReader() {
if (!this.shadowRoot) return;
const maxW = Math.min((this.parentElement?.clientWidth || window.innerWidth) - 40, 700);
const maxH = 500;
let pageW = Math.max(maxW / 2, 100);
let pageH = pageW / this._aspectRatio;
if (pageH > maxH) {
pageH = maxH;
pageW = pageH * this._aspectRatio;
}
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="reader">
<div class="flipbook-row">
<button class="nav-btn" data-dir="prev" title="Previous page">&#8249;</button>
<div class="flipbook-container" style="width:${Math.round(pageW * 2)}px; height:${Math.round(pageH)}px;"></div>
<button class="nav-btn" data-dir="next" title="Next page">&#8250;</button>
</div>
<div class="page-info">
Page <span class="cur">${this._currentPage + 1}</span> of ${this._numPages}
</div>
</div>
`;
this.initFlipbook(Math.round(pageW), Math.round(pageH)).catch((e) => {
console.warn('[folk-pubs-flipbook] Flipbook init failed, using fallback:', e);
this.renderFallback();
});
this.bindEvents();
}
private async initFlipbook(pageW: number, pageH: number) {
if (!this.shadowRoot) return;
const container = this.shadowRoot.querySelector(".flipbook-container") as HTMLElement;
if (!container) return;
try {
await this.loadStPageFlip();
} catch (e) {
console.warn('[folk-pubs-flipbook] StPageFlip failed to load, using fallback:', e);
this.renderFallback();
return;
}
const PageFlip = (window as any).St?.PageFlip;
if (!PageFlip) {
console.warn('[folk-pubs-flipbook] StPageFlip not available, using fallback');
this.renderFallback();
return;
}
try {
this._flipBook = new PageFlip(container, {
width: pageW,
height: pageH,
showCover: true,
maxShadowOpacity: 0.5,
mobileScrollSupport: false,
useMouseEvents: true,
swipeDistance: 30,
clickEventForward: false,
flippingTime: 600,
startPage: this._currentPage,
});
const pages: HTMLElement[] = [];
for (let i = 0; i < this._pageImages.length; i++) {
const page = document.createElement("div");
page.className = "flipbook-page";
page.style.cssText = `width:${pageW}px; height:${pageH}px; background:#fff; overflow:hidden;`;
const img = document.createElement("img");
img.src = this._pageImages[i];
img.style.cssText = "width:100%; height:100%; object-fit:cover; display:block;";
page.appendChild(img);
pages.push(page);
}
this._flipBook.loadFromHTML(pages);
this._flipBook.on("flip", (e: any) => {
this._currentPage = e.data;
this.updatePageInfo();
});
} catch (e) {
console.warn('[folk-pubs-flipbook] StPageFlip render failed, using fallback:', e);
this._flipBook = null;
this.renderFallback();
return;
}
// Verify pages actually rendered — if not, fall back after a short delay
setTimeout(() => {
if (!this.shadowRoot) return;
const items = this.shadowRoot.querySelectorAll('.stf__item');
// If StPageFlip created items but none are visible, fall back
if (items.length > 0) {
const anyVisible = Array.from(items).some(
(el) => (el as HTMLElement).style.display !== 'none'
);
if (!anyVisible) {
console.warn('[folk-pubs-flipbook] No visible pages after init, using fallback');
this._flipBook?.destroy();
this._flipBook = null;
this.renderFallback();
}
} else if (!this.shadowRoot.querySelector('.stf__parent')) {
// StPageFlip didn't create its structure at all
console.warn('[folk-pubs-flipbook] StPageFlip structure missing, using fallback');
this._flipBook = null;
this.renderFallback();
}
}, 500);
// Initial page info update for spread display
this.updatePageInfo();
}
private updatePageInfo() {
const cur = this.shadowRoot?.querySelector(".page-info");
if (!cur) return;
const p = this._currentPage;
const n = this._numPages;
// First page (cover) and last page shown solo; middle pages as spreads
if (p === 0 || p >= n - 1) {
cur.textContent = `Page ${p + 1} of ${n}`;
} else {
const right = Math.min(p + 1, n);
cur.textContent = `Pages ${p}${right} of ${n}`;
}
}
private loadStPageFlip(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as any).St?.PageFlip) { resolve(); return; }
const script = document.createElement("script");
script.src = STPAGEFLIP_CDN;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load StPageFlip"));
document.head.appendChild(script);
});
}
private bindEvents() {
if (!this.shadowRoot) return;
this.shadowRoot.querySelectorAll(".nav-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const dir = (btn as HTMLElement).dataset.dir;
if (dir === "prev") this._flipBook?.flipPrev();
else this._flipBook?.flipNext();
});
});
if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler);
this._keyHandler = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") this._flipBook?.flipPrev();
else if (e.key === "ArrowRight") this._flipBook?.flipNext();
};
document.addEventListener("keydown", this._keyHandler);
window.addEventListener("resize", () => {
if (this._resizeTimer) clearTimeout(this._resizeTimer);
this._resizeTimer = setTimeout(() => this.renderReader(), 250);
});
}
private renderFallback() {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="reader">
<div class="fallback-pages">
${this._pageImages.map((src, i) => `
<div class="fallback-page-wrap">
<span class="fallback-page-num">${i + 1}</span>
<img src="${src}" alt="Page ${i + 1}" />
</div>
`).join('')}
</div>
<div class="page-info">${this._numPages} pages (scroll view)</div>
</div>
`;
}
private getStyles(): string {
return `<style>
:host {
display: block;
position: relative;
z-index: 0;
}
/* StPageFlip injects these into document.head, but they don't
penetrate shadow DOM — replicate complete CSS here. */
.stf__parent {
position: relative;
display: block;
box-sizing: border-box;
transform: translateZ(0);
-ms-touch-action: pan-y;
touch-action: pan-y;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.stf__wrapper {
position: relative;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.stf__parent canvas {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
z-index: 2;
}
.stf__block {
position: absolute;
width: 100%;
height: 100%;
box-sizing: border-box;
perspective: 2000px;
z-index: 1;
}
.stf__item {
display: none;
position: absolute;
box-sizing: border-box;
transform-style: preserve-3d;
top: 0;
left: 0;
overflow: hidden;
backface-visibility: hidden;
}
.stf__outerShadow,
.stf__innerShadow,
.stf__hardShadow,
.stf__hardInnerShadow {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
}
/* Ensure page content fills the flipbook page */
.stf__item {
background: #fff;
}
.flipbook-page {
box-sizing: border-box;
background: #fff;
}
.flipbook-page img {
pointer-events: none;
user-select: none;
}
.loading {
display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 2rem; gap: 0.75rem;
}
.loading-spinner {
width: 32px; height: 32px;
border: 3px solid var(--rs-border-strong, #444);
border-top-color: var(--rs-accent, #14b8a6); border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-status { color: var(--rs-text-secondary, #aaa); font-size: 0.8rem; }
.loading-bar { width: 160px; height: 3px; background: var(--rs-bg-surface, #333); border-radius: 2px; overflow: hidden; }
.loading-fill { height: 100%; background: var(--rs-accent, #14b8a6); transition: width 0.3s; border-radius: 2px; }
.error { padding: 1rem; color: #f87171; font-size: 0.8rem; text-align: center; }
.reader { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
.flipbook-row { display: flex; align-items: center; gap: 0.375rem; }
.flipbook-container {
position: relative;
overflow: hidden; border-radius: 3px;
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
background: #fff;
flex-shrink: 0;
}
/* Fallback scroll view when StPageFlip fails to load */
.fallback-pages {
display: flex; flex-direction: column; align-items: center; gap: 1rem;
padding: 1rem; max-height: 500px; overflow-y: auto;
}
.fallback-page-wrap {
position: relative;
max-width: 100%;
}
.fallback-page-num {
position: absolute;
top: 4px;
right: 8px;
font-size: 0.6rem;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
background: rgba(0,0,0,0.5);
color: #fff;
}
.fallback-pages img {
max-width: 100%; border-radius: 2px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.nav-btn {
width: 32px; height: 60px;
border: 1px solid var(--rs-border-strong, #555);
border-radius: 0.375rem;
background: var(--rs-bg-surface, #2a2a2a);
color: var(--rs-text-primary, #eee);
font-size: 1.25rem; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.nav-btn:hover { background: var(--rs-border-strong, #555); }
.page-info {
font-size: 0.75rem; color: var(--rs-text-secondary, #aaa);
}
</style>`;
}
}
customElements.define("folk-pubs-flipbook", FolkPubsFlipbook);