/** * — 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 | 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()}
${this._loadingStatus}
`; } 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()}

Failed to render preview: ${this._error || "Unknown error"}

`; } 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()}
Page ${this._currentPage + 1} of ${this._numPages}
`; 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; this.updatePageInfo(); }); // 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 { 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()}
${this._pageImages.map((src, i) => `Page ${i + 1}`).join('')}
${this._numPages} pages
`; } private getStyles(): string { return ``; } } customElements.define("folk-pubs-flipbook", FolkPubsFlipbook);