524 lines
14 KiB
TypeScript
524 lines
14 KiB
TypeScript
/**
|
||
* <folk-book-reader> — 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<void> {
|
||
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<CachedBook | null> {
|
||
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<void> {
|
||
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()}
|
||
<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">
|
||
<h3>Failed to load book</h3>
|
||
<p>${this.escapeHtml(this._error || "Unknown error")}</p>
|
||
<button onclick="location.reload()">Retry</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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()}
|
||
<div class="reader-container">
|
||
<div class="reader-header">
|
||
<div class="book-info">
|
||
<span class="book-title">${this.escapeHtml(this._title)}</span>
|
||
${this._author ? `<span class="book-author">by ${this.escapeHtml(this._author)}</span>` : ""}
|
||
</div>
|
||
<div class="page-counter">
|
||
Page <span class="current-page">${this._currentPage + 1}</span> of ${this._numPages}
|
||
</div>
|
||
</div>
|
||
<div class="flipbook-wrapper">
|
||
<button class="nav-btn nav-prev" title="Previous page">‹</button>
|
||
<div class="flipbook-container" style="width:${pageW * 2}px; height:${pageH}px;"></div>
|
||
<button class="nav-btn nav-next" title="Next page">›</button>
|
||
</div>
|
||
<div class="reader-footer">
|
||
<button class="nav-text-btn" data-action="prev">← Previous</button>
|
||
<button class="nav-text-btn" data-action="next">Next →</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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<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 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 `<style>
|
||
:host {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: calc(100vh - 52px);
|
||
background: #0f172a;
|
||
color: #f1f5f9;
|
||
}
|
||
|
||
.loading {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: calc(100vh - 52px);
|
||
gap: 1rem;
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 3px solid #334155;
|
||
border-top-color: #60a5fa;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.loading-status {
|
||
color: #94a3b8;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.loading-bar {
|
||
width: 200px;
|
||
height: 4px;
|
||
background: #1e293b;
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.loading-fill {
|
||
height: 100%;
|
||
background: #60a5fa;
|
||
transition: width 0.3s;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.error {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: calc(100vh - 52px);
|
||
gap: 0.5rem;
|
||
}
|
||
.error h3 { color: #f87171; margin: 0; }
|
||
.error p { color: #94a3b8; margin: 0; }
|
||
.error button {
|
||
margin-top: 1rem;
|
||
padding: 0.5rem 1.5rem;
|
||
border: 1px solid #334155;
|
||
border-radius: 0.5rem;
|
||
background: #1e293b;
|
||
color: #f1f5f9;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.reader-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 1rem;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.reader-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
max-width: 900px;
|
||
}
|
||
|
||
.book-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.125rem;
|
||
}
|
||
|
||
.book-title {
|
||
font-weight: 600;
|
||
font-size: 1rem;
|
||
color: #f1f5f9;
|
||
}
|
||
|
||
.book-author {
|
||
font-size: 0.8rem;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.page-counter {
|
||
font-size: 0.85rem;
|
||
color: #94a3b8;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.flipbook-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.flipbook-container {
|
||
overflow: hidden;
|
||
border-radius: 4px;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||
}
|
||
|
||
.nav-btn {
|
||
width: 44px;
|
||
height: 80px;
|
||
border: 1px solid #334155;
|
||
border-radius: 0.5rem;
|
||
background: #1e293b;
|
||
color: #f1f5f9;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: background 0.15s;
|
||
}
|
||
.nav-btn:hover { background: #334155; }
|
||
|
||
.reader-footer {
|
||
display: flex;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.nav-text-btn {
|
||
padding: 0.375rem 1rem;
|
||
border: 1px solid #334155;
|
||
border-radius: 0.375rem;
|
||
background: transparent;
|
||
color: #94a3b8;
|
||
font-size: 0.8rem;
|
||
cursor: pointer;
|
||
}
|
||
.nav-text-btn:hover { border-color: #60a5fa; color: #f1f5f9; }
|
||
</style>`;
|
||
}
|
||
|
||
private escapeHtml(s: string): string {
|
||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
}
|
||
|
||
customElements.define("folk-book-reader", FolkBookReader);
|