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

327 lines
10 KiB
TypeScript

/**
* <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 = maxW / 2;
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:${pageW * 2}px; height:${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(pageW, pageH);
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;
}
this._flipBook = new PageFlip(container, {
width: Math.round(pageW),
height: Math.round(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.style.cssText = `
width: 100%; height: 100%;
background-image: url(${this._pageImages[i]});
background-size: cover;
background-position: center;
`;
pages.push(page);
}
this._flipBook.loadFromHTML(pages);
this._flipBook.on("flip", (e: any) => {
this._currentPage = e.data;
const cur = this.shadowRoot?.querySelector(".cur");
if (cur) cur.textContent = String(this._currentPage + 1);
});
}
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) => `<img src="${src}" alt="Page ${i + 1}" />`).join('')}
</div>
<div class="page-info">${this._numPages} pages</div>
</div>
`;
}
private getStyles(): string {
return `<style>
:host {
display: block;
position: relative;
z-index: 0; /* stacking context — keeps StPageFlip elements above backgrounds */
}
.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;
}
/* 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-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);