386 lines
11 KiB
TypeScript
386 lines
11 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">‹</button>
|
||
<div class="flipbook-container" style="width:${pageW * 2}px; height:${pageH}px;"></div>
|
||
<button class="nav-btn" data-dir="next" title="Next page">›</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);
|