feat: refine flipbook start modes and UI behavior
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
const MODULE_SELECTOR = '[data-pdf-flipbook-element="1"]';
|
||||
const PDF_MODULE_URL = '/bundles/flipbook/assets/vendor/pdf.min.mjs';
|
||||
const PDF_WORKER_URL = '/bundles/flipbook/assets/vendor/pdf.worker.min.mjs';
|
||||
const FLIPBOOK_MODULE_URL = '/bundles/flipbook/assets/vendor/flipbook.esm.min.js';
|
||||
const INIT_MARKER = 'pdfFlipbookInitialized';
|
||||
const BOOTSTRAP_MARKER = '__mummertPdfFlipbookBootstrapBound';
|
||||
|
||||
let dependenciesPromise;
|
||||
let moduleCounter = 0;
|
||||
|
||||
const loadDependencies = async () => {
|
||||
if (dependenciesPromise) {
|
||||
return dependenciesPromise;
|
||||
}
|
||||
|
||||
dependenciesPromise = Promise.all([
|
||||
import(PDF_MODULE_URL),
|
||||
import(FLIPBOOK_MODULE_URL),
|
||||
]).then(([pdfjsLib, flipbookModule]) => {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = PDF_WORKER_URL;
|
||||
|
||||
return {
|
||||
pdfjsLib,
|
||||
FlipBook: flipbookModule.default,
|
||||
};
|
||||
});
|
||||
|
||||
return dependenciesPromise;
|
||||
};
|
||||
|
||||
class PdfFlipbookModule {
|
||||
constructor(root, dependencies) {
|
||||
const parsedInitialPages = Number.parseInt(root.dataset.initialPages || '4', 10);
|
||||
|
||||
this.root = root;
|
||||
this.dependencies = dependencies;
|
||||
this.pdfUrl = (root.dataset.pdfUrl || '').trim();
|
||||
this.initialPages = Number.isFinite(parsedInitialPages)
|
||||
? Math.min(4, Math.max(2, parsedInitialPages))
|
||||
: 4;
|
||||
this.showNavigation = root.dataset.showNavigation === '1';
|
||||
this.startMode = (root.dataset.startMode === 'spread' || root.dataset.startMode === 'cover')
|
||||
? 'spread'
|
||||
: 'center';
|
||||
this.loader = root.querySelector('[data-flipbook-loader="1"]');
|
||||
this.stage = root.querySelector('[data-flipbook-stage="1"]');
|
||||
this.bookElement = root.querySelector('[data-flipbook-book="1"]');
|
||||
this.nextButton = this.showNavigation ? root.querySelector('[data-flipbook-next="1"]') : null;
|
||||
this.prevButton = this.showNavigation ? root.querySelector('[data-flipbook-prev="1"]') : null;
|
||||
|
||||
this.pdf = null;
|
||||
this.flipbook = null;
|
||||
this.pageElements = new Map();
|
||||
this.renderedPages = new Set();
|
||||
this.pendingPages = new Set();
|
||||
this.renderQueue = [];
|
||||
this.renderInProgress = false;
|
||||
this.resizeTimer = null;
|
||||
this.touchStart = null;
|
||||
this.aspectRatio = 1.4142;
|
||||
this.pageWidth = 0;
|
||||
this.pageHeight = 0;
|
||||
this.pageGap = 2;
|
||||
this.totalPages = 0;
|
||||
|
||||
this.instanceId = `pdf-flipbook-${++moduleCounter}`;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.bookElement || !this.stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.pdfUrl) {
|
||||
this.setStatus('Keine PDF-Datei gefunden.', true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookElement.id = this.instanceId;
|
||||
|
||||
try {
|
||||
this.setStatus('PDF wird geladen ...');
|
||||
await this.loadPdf();
|
||||
await this.resolveInitialAspectRatio();
|
||||
this.buildPageSkeleton();
|
||||
this.updateLayout();
|
||||
await this.renderInitialPages();
|
||||
this.initializeFlipbook();
|
||||
this.bindKeyboard();
|
||||
this.bindTouchSwipe();
|
||||
this.bindResize();
|
||||
this.queuePages(this.getLazyCandidates());
|
||||
this.setStatus('');
|
||||
} catch {
|
||||
this.setStatus('PDF konnte nicht geladen werden.', true);
|
||||
}
|
||||
}
|
||||
|
||||
async loadPdf() {
|
||||
const loadingTask = this.dependencies.pdfjsLib.getDocument({
|
||||
url: this.pdfUrl,
|
||||
useWorkerFetch: true,
|
||||
isEvalSupported: false,
|
||||
});
|
||||
|
||||
this.pdf = await loadingTask.promise;
|
||||
this.totalPages = Number(this.pdf.numPages || 0);
|
||||
|
||||
if (this.totalPages <= 0) {
|
||||
throw new Error('The selected PDF has no pages.');
|
||||
}
|
||||
}
|
||||
|
||||
async resolveInitialAspectRatio() {
|
||||
const firstPage = await this.pdf.getPage(1);
|
||||
const viewport = firstPage.getViewport({ scale: 1 });
|
||||
|
||||
if (viewport.width > 0 && viewport.height > 0) {
|
||||
this.aspectRatio = viewport.height / viewport.width;
|
||||
}
|
||||
}
|
||||
|
||||
buildPageSkeleton() {
|
||||
const visualPageCount = this.totalPages % 2 === 0 ? this.totalPages : this.totalPages + 1;
|
||||
|
||||
this.bookElement.innerHTML = '';
|
||||
this.pageElements.clear();
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= visualPageCount; pageNumber += 1) {
|
||||
const page = document.createElement('div');
|
||||
const loader = document.createElement('div');
|
||||
|
||||
page.className = 'c-flipbook__page';
|
||||
page.dataset.pageNumber = String(pageNumber);
|
||||
|
||||
loader.className = 'mod-pdf-flipbook__page-loader';
|
||||
loader.textContent = pageNumber <= this.totalPages ? `Seite ${pageNumber} wird gerendert ...` : 'Leere Seite';
|
||||
page.appendChild(loader);
|
||||
|
||||
if (pageNumber > this.totalPages) {
|
||||
page.dataset.empty = '1';
|
||||
}
|
||||
|
||||
this.bookElement.appendChild(page);
|
||||
|
||||
if (pageNumber <= this.totalPages) {
|
||||
this.pageElements.set(pageNumber, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLayout() {
|
||||
const stageWidth = Math.max(this.stage.clientWidth || 0, 282);
|
||||
this.pageWidth = Math.max(Math.floor((stageWidth - this.pageGap) / 2), 140);
|
||||
this.pageHeight = Math.max(Math.floor(this.pageWidth * this.aspectRatio), 180);
|
||||
|
||||
this.stage.style.height = `${this.pageHeight}px`;
|
||||
this.bookElement.style.height = `${this.pageHeight}px`;
|
||||
|
||||
this.pageElements.forEach((pageElement) => {
|
||||
pageElement.style.width = `${this.pageWidth}px`;
|
||||
pageElement.style.height = `${this.pageHeight}px`;
|
||||
});
|
||||
}
|
||||
|
||||
async renderInitialPages() {
|
||||
const limit = Math.min(this.initialPages, this.totalPages);
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= limit; pageNumber += 1) {
|
||||
await this.renderPage(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
initializeFlipbook() {
|
||||
const startsWithDoubleSpread = this.startMode === 'spread';
|
||||
|
||||
this.flipbook = new this.dependencies.FlipBook(this.bookElement, {
|
||||
nextButton: this.nextButton,
|
||||
previousButton: this.prevButton,
|
||||
canClose: !startsWithDoubleSpread,
|
||||
arrowKeys: false,
|
||||
initialActivePage: 0,
|
||||
initialCall: false,
|
||||
width: '100%',
|
||||
height: `${this.pageHeight}px`,
|
||||
onPageTurn: () => {
|
||||
this.queuePages(this.getLazyCandidates());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
bindKeyboard() {
|
||||
this.root.addEventListener('pointerdown', () => {
|
||||
this.root.focus();
|
||||
});
|
||||
|
||||
this.root.addEventListener('keydown', (event) => {
|
||||
if (!this.flipbook) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
this.flipbook.turnPage('forward');
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
this.flipbook.turnPage('back');
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindTouchSwipe() {
|
||||
this.bookElement.addEventListener('touchstart', (event) => {
|
||||
const touch = event.changedTouches && event.changedTouches[0];
|
||||
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.touchStart = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
};
|
||||
}, { passive: true });
|
||||
|
||||
this.bookElement.addEventListener('touchend', (event) => {
|
||||
if (!this.flipbook || !this.touchStart) {
|
||||
this.touchStart = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.changedTouches && event.changedTouches[0];
|
||||
|
||||
if (!touch) {
|
||||
this.touchStart = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.clientX - this.touchStart.x;
|
||||
const deltaY = touch.clientY - this.touchStart.y;
|
||||
const horizontalThreshold = 40;
|
||||
const verticalLimit = 60;
|
||||
|
||||
if (Math.abs(deltaX) >= horizontalThreshold && Math.abs(deltaY) < verticalLimit) {
|
||||
if (deltaX < 0) {
|
||||
this.flipbook.turnPage('forward');
|
||||
} else {
|
||||
this.flipbook.turnPage('back');
|
||||
}
|
||||
}
|
||||
|
||||
this.touchStart = null;
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
bindResize() {
|
||||
const onResize = () => {
|
||||
window.clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = window.setTimeout(() => {
|
||||
if (!this.pdf) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousHeight = this.pageHeight;
|
||||
this.updateLayout();
|
||||
|
||||
if (Math.abs(previousHeight - this.pageHeight) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookElement.style.height = `${this.pageHeight}px`;
|
||||
|
||||
const rerender = Array.from(this.renderedPages);
|
||||
this.renderedPages.clear();
|
||||
|
||||
rerender.forEach((pageNumber) => {
|
||||
const page = this.pageElements.get(pageNumber);
|
||||
|
||||
if (page) {
|
||||
const oldCanvas = page.querySelector('canvas');
|
||||
|
||||
if (oldCanvas) {
|
||||
oldCanvas.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.queuePages(rerender, true);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
if ('ResizeObserver' in window) {
|
||||
const observer = new ResizeObserver(onResize);
|
||||
observer.observe(this.stage);
|
||||
} else {
|
||||
window.addEventListener('resize', onResize, { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
getLazyCandidates() {
|
||||
const activePages = Array.from(this.bookElement.querySelectorAll('.c-flipbook__page.is-active'));
|
||||
const candidates = new Set();
|
||||
|
||||
if (activePages.length === 0) {
|
||||
const fallbackFrom = Math.min(this.initialPages + 1, this.totalPages);
|
||||
const fallbackTo = Math.min(fallbackFrom + 2, this.totalPages);
|
||||
|
||||
for (let pageNumber = fallbackFrom; pageNumber <= fallbackTo; pageNumber += 1) {
|
||||
candidates.add(pageNumber);
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
activePages.forEach((activePage) => {
|
||||
const currentPage = Number(activePage.dataset.pageNumber || 0);
|
||||
|
||||
for (let offset = -1; offset <= 3; offset += 1) {
|
||||
const pageNumber = currentPage + offset;
|
||||
|
||||
if (pageNumber >= 1 && pageNumber <= this.totalPages) {
|
||||
candidates.add(pageNumber);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
queuePages(pageNumbers, prioritize = false) {
|
||||
for (const pageNumber of pageNumbers) {
|
||||
if (!Number.isInteger(pageNumber) || pageNumber < 1 || pageNumber > this.totalPages) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.renderedPages.has(pageNumber) || this.pendingPages.has(pageNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.pendingPages.add(pageNumber);
|
||||
|
||||
if (prioritize) {
|
||||
this.renderQueue.unshift(pageNumber);
|
||||
} else {
|
||||
this.renderQueue.push(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
this.processRenderQueue();
|
||||
}
|
||||
|
||||
async processRenderQueue() {
|
||||
if (this.renderInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderInProgress = true;
|
||||
|
||||
while (this.renderQueue.length > 0) {
|
||||
const pageNumber = this.renderQueue.shift();
|
||||
|
||||
if (!pageNumber) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.pendingPages.delete(pageNumber);
|
||||
|
||||
try {
|
||||
await this.renderPage(pageNumber);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.renderInProgress = false;
|
||||
}
|
||||
|
||||
async renderPage(pageNumber) {
|
||||
if (this.renderedPages.has(pageNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageElement = this.pageElements.get(pageNumber);
|
||||
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await this.pdf.getPage(pageNumber);
|
||||
const viewportAtScale1 = page.getViewport({ scale: 1 });
|
||||
const scale = this.pageWidth / viewportAtScale1.width;
|
||||
const viewport = page.getViewport({ scale });
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const outputScale = Math.max(1, devicePixelRatio);
|
||||
const existingCanvas = pageElement.querySelector('canvas');
|
||||
|
||||
if (existingCanvas) {
|
||||
existingCanvas.remove();
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d', { alpha: false });
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Could not create canvas 2D context.');
|
||||
}
|
||||
|
||||
canvas.width = Math.floor(viewport.width * outputScale);
|
||||
canvas.height = Math.floor(viewport.height * outputScale);
|
||||
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
||||
canvas.style.height = `${Math.floor(viewport.height)}px`;
|
||||
|
||||
const renderTask = page.render({
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0],
|
||||
intent: 'display',
|
||||
});
|
||||
|
||||
await renderTask.promise;
|
||||
|
||||
const loader = pageElement.querySelector('.mod-pdf-flipbook__page-loader');
|
||||
|
||||
if (loader) {
|
||||
loader.remove();
|
||||
}
|
||||
|
||||
pageElement.appendChild(canvas);
|
||||
this.renderedPages.add(pageNumber);
|
||||
}
|
||||
|
||||
setStatus(message, isError = false) {
|
||||
if (!this.loader) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
this.loader.classList.add('is-hidden');
|
||||
this.loader.classList.remove('is-error');
|
||||
this.loader.textContent = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.loader.classList.remove('is-hidden');
|
||||
this.loader.classList.toggle('is-error', isError);
|
||||
this.loader.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const modules = Array.from(document.querySelectorAll(MODULE_SELECTOR))
|
||||
.filter((moduleElement) => moduleElement.dataset[INIT_MARKER] !== '1');
|
||||
|
||||
if (modules.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dependencies = await loadDependencies();
|
||||
|
||||
await Promise.all(modules.map(async (moduleElement) => {
|
||||
moduleElement.dataset[INIT_MARKER] = '1';
|
||||
|
||||
const module = new PdfFlipbookModule(moduleElement, dependencies);
|
||||
await module.init();
|
||||
}));
|
||||
};
|
||||
|
||||
if (window[BOOTSTRAP_MARKER]) {
|
||||
run().catch(() => {
|
||||
});
|
||||
|
||||
} else {
|
||||
window[BOOTSTRAP_MARKER] = true;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
run().catch(() => {
|
||||
});
|
||||
}, { once: true });
|
||||
} else {
|
||||
run().catch(() => {
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user