370 lines
11 KiB
TypeScript
370 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;
|
|
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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
/* 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);
|