/** * — Flipbook PDF reader using pdf.js + StPageFlip. * * Renders each PDF page to canvas, converts to images, then displays * in a realistic page-flip animation. Caches rendered pages in IndexedDB. * Saves reading position to localStorage. * * Attributes: * pdf-url — URL to the PDF file * book-id — Unique ID for caching/position tracking * title — Book title (for display) * author — Book author (for display) */ // pdf.js is loaded from CDN; StPageFlip is imported from npm // (we'll load both dynamically to avoid bundling issues) 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"; interface CachedBook { images: string[]; numPages: number; aspectRatio: number; } export class FolkBookReader extends HTMLElement { private _pdfUrl = ""; private _bookId = ""; private _title = ""; private _author = ""; private _pageImages: string[] = []; private _numPages = 0; private _currentPage = 0; private _aspectRatio = 1.414; // A4 default private _isLoading = true; private _loadingProgress = 0; private _loadingStatus = "Preparing..."; private _error: string | null = null; private _flipBook: any = null; private _db: IDBDatabase | null = null; static get observedAttributes() { return ["pdf-url", "book-id", "title", "author"]; } attributeChangedCallback(name: string, _old: string, val: string) { if (name === "pdf-url") this._pdfUrl = val; else if (name === "book-id") this._bookId = val; else if (name === "title") this._title = val; else if (name === "author") this._author = val; } async connectedCallback() { this._pdfUrl = this.getAttribute("pdf-url") || ""; this._bookId = this.getAttribute("book-id") || ""; this._title = this.getAttribute("title") || ""; this._author = this.getAttribute("author") || ""; this.attachShadow({ mode: "open" }); this.renderLoading(); // Restore reading position const savedPage = localStorage.getItem(`book-position-${this._bookId}`); if (savedPage) this._currentPage = parseInt(savedPage) || 0; try { await this.openDB(); const cached = await this.loadFromCache(); if (cached) { this._pageImages = cached.images; this._numPages = cached.numPages; this._aspectRatio = cached.aspectRatio; this._isLoading = false; this.renderReader(); } else { await this.loadAndRenderPDF(); } } catch (e: any) { this._error = e.message || "Failed to load book"; this._isLoading = false; this.renderError(); } } disconnectedCallback() { // Save position localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage)); this._flipBook?.destroy(); this._db?.close(); } // ── IndexedDB cache ── private openDB(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open("rspace-books-cache", 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains("book-images")) { db.createObjectStore("book-images"); } }; req.onsuccess = () => { this._db = req.result; resolve(); }; req.onerror = () => reject(req.error); }); } private loadFromCache(): Promise { return new Promise((resolve) => { if (!this._db) { resolve(null); return; } const tx = this._db.transaction("book-images", "readonly"); const store = tx.objectStore("book-images"); const req = store.get(this._bookId); req.onsuccess = () => resolve(req.result || null); req.onerror = () => resolve(null); }); } private saveToCache(data: CachedBook): Promise { return new Promise((resolve) => { if (!this._db) { resolve(); return; } const tx = this._db.transaction("book-images", "readwrite"); const store = tx.objectStore("book-images"); store.put(data, this._bookId); tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); }); } // ── PDF rendering ── private async loadAndRenderPDF() { this._loadingStatus = "Loading PDF.js..."; this.updateLoadingUI(); // Load pdf.js const pdfjsLib = await import(/* @vite-ignore */ PDFJS_CDN); pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_CDN; this._loadingStatus = "Downloading PDF..."; this.updateLoadingUI(); const pdf = await pdfjsLib.getDocument(this._pdfUrl).promise; this._numPages = pdf.numPages; this._pageImages = []; // Get aspect ratio from first page const firstPage = await pdf.getPage(1); const viewport = firstPage.getViewport({ scale: 1 }); this._aspectRatio = viewport.width / viewport.height; const scale = 2; // 2x for quality 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)); } // Cache await this.saveToCache({ images: this._pageImages, numPages: this._numPages, aspectRatio: this._aspectRatio, }); this._isLoading = false; this.renderReader(); } // ── UI rendering ── 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 load book

${this.escapeHtml(this._error || "Unknown error")}

`; } private renderReader() { if (!this.shadowRoot) return; // Calculate dimensions const maxW = Math.min(window.innerWidth * 0.9, 800); const maxH = window.innerHeight - 160; let pageW = maxW / 2; let pageH = pageW / this._aspectRatio; if (pageH > maxH) { pageH = maxH; pageW = pageH * this._aspectRatio; } this.shadowRoot.innerHTML = ` ${this.getStyles()}
${this.escapeHtml(this._title)} ${this._author ? `by ${this.escapeHtml(this._author)}` : ""}
Page ${this._currentPage + 1} of ${this._numPages}
`; this.initFlipbook(pageW, pageH); this.bindReaderEvents(); } private async initFlipbook(pageW: number, pageH: number) { if (!this.shadowRoot) return; const container = this.shadowRoot.querySelector(".flipbook-container") as HTMLElement; if (!container) return; // Load StPageFlip await this.loadStPageFlip(); const PageFlip = (window as any).St?.PageFlip; if (!PageFlip) { console.error("StPageFlip not loaded"); 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, }); // Create page elements const pages: HTMLElement[] = []; for (let i = 0; i < this._pageImages.length; i++) { const page = document.createElement("div"); page.className = "page-content"; 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.updatePageCounter(); localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage)); }); } 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 bindReaderEvents() { if (!this.shadowRoot) return; // Nav buttons this.shadowRoot.querySelector(".nav-prev")?.addEventListener("click", () => { this._flipBook?.flipPrev(); }); this.shadowRoot.querySelector(".nav-next")?.addEventListener("click", () => { this._flipBook?.flipNext(); }); this.shadowRoot.querySelectorAll(".nav-text-btn").forEach((btn) => { btn.addEventListener("click", () => { const action = (btn as HTMLElement).dataset.action; if (action === "prev") this._flipBook?.flipPrev(); else if (action === "next") this._flipBook?.flipNext(); }); }); // Keyboard nav document.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft") this._flipBook?.flipPrev(); else if (e.key === "ArrowRight") this._flipBook?.flipNext(); }); // Resize handler let resizeTimer: number; window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer = window.setTimeout(() => this.renderReader(), 250); }); } private updatePageCounter() { if (!this.shadowRoot) return; const el = this.shadowRoot.querySelector(".current-page"); if (el) el.textContent = String(this._currentPage + 1); } private getStyles(): string { return ``; } private escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } } customElements.define("folk-book-reader", FolkBookReader);