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

386 lines
11 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 = 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;
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<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;
}
/* StPageFlip injects these into document.head, but they don't
penetrate shadow DOM — so we replicate them here. */
.stf__parent {
position: relative;
display: block;
box-sizing: border-box;
transform: translateZ(0);
-ms-touch-action: pan-y;
touch-action: pan-y;
}
.stf__wrapper {
position: relative;
width: 100%;
box-sizing: border-box;
}
.stf__parent canvas {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
}
.stf__block {
position: absolute;
width: 100%;
height: 100%;
box-sizing: border-box;
perspective: 2000px;
}
.stf__item {
display: none;
position: absolute;
transform-style: preserve-3d;
}
.stf__outerShadow,
.stf__innerShadow,
.stf__hardShadow,
.stf__hardInnerShadow {
position: absolute;
left: 0;
top: 0;
}
.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-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);