513 lines
15 KiB
JavaScript
513 lines
15 KiB
JavaScript
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 TURN_SOUND_URL = '/bundles/flipbook/assets/audio/turn.mp3';
|
|
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.playTurnSoundEnabled = root.dataset.playTurnSound !== '0';
|
|
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.turnSound = this.playTurnSoundEnabled ? this.createTurnSound() : 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.playTurnSound();
|
|
this.queuePages(this.getLazyCandidates());
|
|
},
|
|
});
|
|
}
|
|
|
|
createTurnSound() {
|
|
const audio = new Audio(TURN_SOUND_URL);
|
|
audio.preload = 'auto';
|
|
|
|
return audio;
|
|
}
|
|
|
|
playTurnSound() {
|
|
if (!this.playTurnSoundEnabled || !this.turnSound) {
|
|
return;
|
|
}
|
|
|
|
this.turnSound.currentTime = 0;
|
|
|
|
this.turnSound.play().catch(() => {
|
|
});
|
|
}
|
|
|
|
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(() => {
|
|
});
|
|
}
|
|
}
|