rspace-online/modules/books/components/folk-book-reader.ts

524 lines
14 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-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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
}
customElements.define("folk-book-reader", FolkBookReader);