Files
flipbook-bundle/public/assets/flipbook-module.js
T
Jürgen MummertandClaude Opus 5 204fee73f5 feat: optional fullscreen mode
New content element option "Vollbild-Schaltfläche anzeigen" (on by default).
The button only appears once the browser confirms it exposes the Fullscreen API
for ordinary elements -- iOS Safari offers it for video only, so there it stays
hidden rather than presenting a control that cannot work.

Fitting the book to the screen needed the layout to become height-aware. Page
size was derived from width alone, which at screen width puts an A4-proportioned
page well below the bottom of the viewport. In fullscreen the page now fits to
the available height and the narrower book is centred; pages re-render at the
new size through the existing generation-stamped path.

The height fit writes a max-width onto the stage, so in fullscreen the available
width is measured on the parent instead -- measuring the element we just
constrained would oscillate. Verified stable across repeated samples.

The resize handler body moved into relayout(), shared with the fullscreen
toggle. destroy() leaves fullscreen first so the browser is not left holding a
node that is about to go away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:12:10 +02:00

1083 lines
35 KiB
JavaScript

const assetUrl = (relativePath) => new URL(relativePath, import.meta.url).href;
const PDF_MODULE_URL = assetUrl('./vendor/pdf.min.js');
const PDF_WORKER_URL = assetUrl('./vendor/pdf.worker.min.js');
const FLIPBOOK_MODULE_URL = assetUrl('./vendor/flipbook.esm.min.js');
const CMAP_URL = assetUrl('./vendor/cmaps/');
const STANDARD_FONT_URL = assetUrl('./vendor/standard_fonts/');
const TURN_SOUND_URL = assetUrl('./audio/turn.mp3');
const MODULE_SELECTOR = '[data-pdf-flipbook-element="1"]';
const INIT_MARKER = 'pdfFlipbookInitialized';
const BOOTSTRAP_MARKER = '__mummertPdfFlipbookBootstrapBound';
// Supersampling factor for the page canvas. The canvas is stretched to the page
// box via CSS, so rendering above 1x keeps text crisp. Capped at 2 because the
// memory cost grows quadratically -- 2x already costs four times a 1x canvas.
const MIN_OUTPUT_SCALE = 1.5;
const MAX_OUTPUT_SCALE = 2;
// Pages further than this from the current spread get their canvas released.
// Must stay above the lazy-render window (see getLazyCandidates) so pages are
// not evicted and re-rendered in a loop.
const EVICTION_RADIUS = 6;
// Number of leading pages sampled to determine the layout aspect ratio.
const ASPECT_SAMPLE_SIZE = 8;
const TURN_SOUND_POOL_SIZE = 3;
const DEFAULT_TEXTS = {
loading: 'PDF wird geladen ...',
error: 'PDF konnte nicht geladen werden.',
missing: 'Keine PDF-Datei gefunden.',
rendering: 'Seite {page} wird gerendert ...',
blank: 'Leere Seite',
pageLabel: 'Seite {page} von {total}',
fullscreenEnter: 'Vollbild',
fullscreenExit: 'Vollbild beenden',
};
let dependenciesPromise;
let moduleCounter = 0;
const instances = new Set();
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;
};
const interpolate = (template, replacements) => Object.entries(replacements)
.reduce((carry, [key, value]) => carry.replaceAll(`{${key}}`, String(value)), template);
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.root.classList.add('is-loading');
this.root.classList.remove('is-ready');
this.initialPages = Number.isFinite(parsedInitialPages)
? Math.min(4, Math.max(2, parsedInitialPages))
: 4;
this.splitSpreads = root.dataset.splitSpreads === '1';
this.showNavigation = root.dataset.showNavigation === '1';
this.showFullscreen = root.dataset.showFullscreen === '1';
this.playTurnSoundEnabled = root.dataset.playTurnSound !== '0';
this.startMode = (root.dataset.startMode === 'spread' || root.dataset.startMode === 'cover')
? 'spread'
: 'center';
this.texts = {
loading: root.dataset.i18nLoading || DEFAULT_TEXTS.loading,
error: root.dataset.i18nError || DEFAULT_TEXTS.error,
missing: root.dataset.i18nMissing || DEFAULT_TEXTS.missing,
rendering: root.dataset.i18nRendering || DEFAULT_TEXTS.rendering,
blank: root.dataset.i18nBlank || DEFAULT_TEXTS.blank,
pageLabel: root.dataset.i18nPageLabel || DEFAULT_TEXTS.pageLabel,
fullscreenEnter: root.dataset.i18nFullscreenEnter || DEFAULT_TEXTS.fullscreenEnter,
fullscreenExit: root.dataset.i18nFullscreenExit || DEFAULT_TEXTS.fullscreenExit,
};
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.controlsElement = root.querySelector('[data-flipbook-controls="1"]');
this.nextButton = this.showNavigation ? root.querySelector('[data-flipbook-next="1"]') : null;
this.prevButton = this.showNavigation ? root.querySelector('[data-flipbook-prev="1"]') : null;
this.fullscreenButton = this.showFullscreen ? root.querySelector('[data-flipbook-fullscreen="1"]') : null;
this.pdf = null;
this.flipbook = null;
this.pageElements = new Map();
this.pageLoaders = new Map();
this.renderedPages = new Set();
this.pendingPages = new Set();
this.renderQueue = [];
this.renderInProgress = false;
this.resizeTimer = null;
this.touchStart = null;
this.turnSounds = this.playTurnSoundEnabled ? this.createTurnSounds() : [];
this.turnSoundIndex = 0;
this.aspectRatio = 1.4142;
this.pageWidth = 0;
this.pageHeight = 0;
this.pageGap = 2;
this.sourcePageCount = 0;
this.totalPages = 0;
this.pageDescriptors = [];
// Bumped whenever the layout changes. Renders that finish after a bump
// were produced for a stale page size and must be discarded.
this.renderGeneration = 0;
this.activeRenderTask = null;
this.resizeObserver = null;
this.abortController = new AbortController();
this.destroyed = false;
this.instanceId = `pdf-flipbook-${++moduleCounter}`;
}
async init() {
if (!this.bookElement || !this.stage) {
return;
}
if (!this.pdfUrl) {
this.setStatus(this.texts.missing, true);
this.root.classList.remove('is-loading');
return;
}
this.bookElement.id = this.instanceId;
try {
this.setStatus(this.texts.loading);
await this.loadPdf();
await this.resolveAspectRatio();
this.buildPageSkeleton();
this.updateLayout();
await this.renderInitialPages();
this.initializeFlipbook();
this.bindKeyboard();
this.bindTouchSwipe();
this.bindResize();
this.setupFullscreen();
this.queuePages(this.getLazyCandidates());
await this.showReadyState();
this.setStatus('');
} catch (error) {
if (this.destroyed) {
return;
}
console.error('[flipbook] Initialisierung fehlgeschlagen:', error);
this.setStatus(this.texts.error, true);
this.root.classList.remove('is-loading');
}
}
async showReadyState() {
await new Promise((resolve) => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(resolve);
});
});
if (this.bookElement) {
this.bookElement.classList.remove('is-booting');
}
this.root.classList.remove('is-loading');
this.root.classList.add('is-ready');
if (this.stage) {
this.stage.style.visibility = 'visible';
}
if (this.controlsElement) {
this.controlsElement.style.visibility = 'visible';
}
window.requestAnimationFrame(() => {
if (this.stage) {
this.stage.style.opacity = '1';
}
if (this.controlsElement) {
this.controlsElement.style.opacity = '1';
}
this.updateNavigationState();
});
}
async loadPdf() {
const loadingTask = this.dependencies.pdfjsLib.getDocument({
url: this.pdfUrl,
useWorkerFetch: true,
isEvalSupported: false,
// Without these, PDFs relying on CID encodings or on the 14 standard
// fonts (rather than embedding them) render blank or with fallbacks.
cMapUrl: CMAP_URL,
cMapPacked: true,
standardFontDataUrl: STANDARD_FONT_URL,
});
this.pdf = await loadingTask.promise;
this.sourcePageCount = Number(this.pdf.numPages || 0);
if (this.sourcePageCount <= 0) {
throw new Error('The selected PDF has no pages.');
}
await this.buildPageDescriptors();
this.totalPages = this.pageDescriptors.length;
}
async buildPageDescriptors() {
// Splitting is the only reason to inspect every page up front. With it
// disabled, walking the whole document would stall the first paint on
// long PDFs for no gain.
if (!this.splitSpreads) {
this.pageDescriptors = Array.from({ length: this.sourcePageCount }, (unused, index) => ({
sourcePageNumber: index + 1,
segment: 'full',
aspectRatio: 0,
}));
return;
}
const descriptors = [];
for (let sourcePageNumber = 1; sourcePageNumber <= this.sourcePageCount; sourcePageNumber += 1) {
const page = await this.pdf.getPage(sourcePageNumber);
const viewport = page.getViewport({ scale: 1 });
const width = Number(viewport.width || 0);
const height = Number(viewport.height || 0);
if (this.shouldSplitSpread(sourcePageNumber, viewport)) {
const aspectRatio = width > 0 ? height / (width / 2) : 0;
descriptors.push({ sourcePageNumber, segment: 'left', aspectRatio });
descriptors.push({ sourcePageNumber, segment: 'right', aspectRatio });
} else {
descriptors.push({
sourcePageNumber,
segment: 'full',
aspectRatio: width > 0 ? height / width : 0,
});
}
}
this.pageDescriptors = descriptors;
}
shouldSplitSpread(sourcePageNumber, viewport) {
if (!this.splitSpreads) {
return false;
}
if (sourcePageNumber <= 1) {
return false;
}
const width = Number(viewport.width || 0);
const height = Number(viewport.height || 0);
if (width <= 0 || height <= 0) {
return false;
}
return width >= (height * 1.2);
}
getDescriptor(pageNumber) {
if (!Number.isInteger(pageNumber) || pageNumber < 1) {
return null;
}
return this.pageDescriptors[pageNumber - 1] || null;
}
async resolveAspectRatio() {
// The flip engine needs one uniform page box, so a document with mixed
// page formats gets the median of a leading sample rather than whatever
// the cover page happens to be. Odd pages out are letterboxed by CSS.
const sampleSize = Math.min(ASPECT_SAMPLE_SIZE, this.pageDescriptors.length);
const ratios = [];
for (let pageNumber = 1; pageNumber <= sampleSize; pageNumber += 1) {
const descriptor = this.getDescriptor(pageNumber);
if (!descriptor) {
continue;
}
if (descriptor.aspectRatio > 0) {
ratios.push(descriptor.aspectRatio);
continue;
}
const page = await this.pdf.getPage(descriptor.sourcePageNumber);
const viewport = page.getViewport({ scale: 1 });
const divisor = descriptor.segment === 'full' ? 1 : 2;
const width = viewport.width / divisor;
if (width > 0 && viewport.height > 0) {
descriptor.aspectRatio = viewport.height / width;
ratios.push(descriptor.aspectRatio);
}
}
if (ratios.length === 0) {
return;
}
ratios.sort((a, b) => a - b);
this.aspectRatio = ratios[Math.floor(ratios.length / 2)];
}
buildPageSkeleton() {
const visualPageCount = this.totalPages % 2 === 0 ? this.totalPages : this.totalPages + 1;
this.bookElement.innerHTML = '';
this.pageElements.clear();
this.pageLoaders.clear();
for (let pageNumber = 1; pageNumber <= visualPageCount; pageNumber += 1) {
const page = document.createElement('div');
const loader = document.createElement('div');
const isContentPage = pageNumber <= this.totalPages;
page.className = 'c-flipbook__page';
page.dataset.pageNumber = String(pageNumber);
loader.className = 'mod-pdf-flipbook__page-loader';
loader.textContent = isContentPage
? interpolate(this.texts.rendering, { page: pageNumber })
: this.texts.blank;
page.appendChild(loader);
if (isContentPage) {
page.setAttribute('role', 'img');
page.setAttribute('aria-label', interpolate(this.texts.pageLabel, {
page: pageNumber,
total: this.totalPages,
}));
} else {
page.dataset.empty = '1';
page.setAttribute('aria-hidden', 'true');
}
this.bookElement.appendChild(page);
if (isContentPage) {
this.pageElements.set(pageNumber, page);
this.pageLoaders.set(pageNumber, loader);
}
}
}
updateLayout() {
const fullscreen = this.isFullscreen();
// In fullscreen the width is measured on the parent, not on the stage:
// the height fit below writes a max-width onto the stage, and measuring
// the element we just constrained would oscillate.
const availableWidth = fullscreen
? this.getContentWidth(this.stage.parentElement)
: (this.stage.clientWidth || 0);
const stageWidth = Math.max(availableWidth, 282);
this.pageWidth = Math.max(Math.floor((stageWidth - this.pageGap) / 2), 140);
this.pageHeight = Math.max(Math.floor(this.pageWidth * this.aspectRatio), 180);
if (fullscreen) {
// A page in A4 proportions at screen width would run well past the
// bottom of the viewport, so fit to height and centre the narrower
// book instead.
const heightLimit = this.getFullscreenHeightLimit();
if (this.pageHeight > heightLimit) {
this.pageHeight = Math.max(Math.floor(heightLimit), 180);
this.pageWidth = Math.max(Math.floor(this.pageHeight / this.aspectRatio), 140);
}
this.stage.style.maxWidth = `${this.pageWidth * 2 + this.pageGap}px`;
} else if (this.stage.style.maxWidth) {
this.stage.style.maxWidth = '';
}
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.bookElement.classList.add('is-booting');
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());
this.evictDistantPages();
this.updateNavigationState();
},
});
window.requestAnimationFrame(() => {
this.updateNavigationState();
});
}
getActivePageNumbers() {
return Array.from(this.bookElement.querySelectorAll('.c-flipbook__page.is-active'))
.map((element) => Number(element.dataset.pageNumber || 0))
.filter((pageNumber) => Number.isInteger(pageNumber) && pageNumber > 0);
}
updateNavigationState() {
if (!this.showNavigation || !this.prevButton || !this.nextButton) {
return;
}
const activePages = this.getActivePageNumbers();
let disablePrev = false;
let disableNext = false;
if (activePages.length === 0) {
disablePrev = true;
disableNext = this.totalPages <= 1;
} else {
const minActivePage = Math.min(...activePages);
const maxActivePage = Math.max(...activePages);
disablePrev = minActivePage <= 1;
disableNext = maxActivePage >= this.totalPages;
}
this.prevButton.disabled = disablePrev;
this.nextButton.disabled = disableNext;
this.prevButton.setAttribute('aria-disabled', disablePrev ? 'true' : 'false');
this.nextButton.setAttribute('aria-disabled', disableNext ? 'true' : 'false');
}
createTurnSounds() {
// A single element would cut itself off when pages are flipped quickly.
return Array.from({ length: TURN_SOUND_POOL_SIZE }, () => {
const audio = new Audio(TURN_SOUND_URL);
audio.preload = 'auto';
return audio;
});
}
playTurnSound() {
if (!this.playTurnSoundEnabled || this.turnSounds.length === 0) {
return;
}
const audio = this.turnSounds[this.turnSoundIndex];
this.turnSoundIndex = (this.turnSoundIndex + 1) % this.turnSounds.length;
audio.currentTime = 0;
audio.play().catch(() => {
});
}
bindKeyboard() {
const { signal } = this.abortController;
this.root.addEventListener('pointerdown', (event) => {
// Buttons and links must keep the focus they just received.
if (event.target instanceof Element && event.target.closest('button, a, input, select, textarea')) {
return;
}
this.root.focus();
}, { signal });
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();
}
}, { signal });
}
bindTouchSwipe() {
const { signal } = this.abortController;
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, signal });
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, signal });
}
bindResize() {
const onResize = () => {
window.clearTimeout(this.resizeTimer);
this.resizeTimer = window.setTimeout(() => this.relayout(), 120);
};
if ('ResizeObserver' in window) {
this.resizeObserver = new ResizeObserver(onResize);
this.resizeObserver.observe(this.stage);
} else {
window.addEventListener('resize', onResize, { passive: true, signal: this.abortController.signal });
}
}
/**
* Recomputes the page box and, if it actually moved, re-renders everything
* for the new size. Shared by the resize observer and the fullscreen toggle.
*/
relayout() {
if (!this.pdf || this.destroyed) {
return;
}
const previousWidth = this.pageWidth;
const previousHeight = this.pageHeight;
this.updateLayout();
if (Math.abs(previousHeight - this.pageHeight) < 2 && Math.abs(previousWidth - this.pageWidth) < 2) {
return;
}
// Invalidate everything rendered for the old page size, including a
// render that may still be in flight.
this.renderGeneration += 1;
this.cancelActiveRender();
this.bookElement.style.height = `${this.pageHeight}px`;
const rerender = Array.from(this.renderedPages);
this.renderedPages.clear();
rerender.forEach((pageNumber) => {
this.releasePageCanvas(pageNumber);
});
this.queuePages(rerender, true);
}
getContentWidth(element) {
if (!element) {
return 0;
}
const styles = window.getComputedStyle(element);
return element.clientWidth
- parseFloat(styles.paddingLeft || '0')
- parseFloat(styles.paddingRight || '0');
}
/**
* Height the book may occupy in fullscreen: the viewport minus the element's
* own padding and every sibling of the stage (status line, controls, the
* download link), plus a little slack for the margins those carry.
*/
getFullscreenHeightLimit() {
const styles = window.getComputedStyle(this.root);
let reserved = parseFloat(styles.paddingTop || '0') + parseFloat(styles.paddingBottom || '0');
Array.from(this.root.children).forEach((child) => {
if (child !== this.stage) {
reserved += child.offsetHeight;
}
});
return Math.max(window.innerHeight - reserved - 32, 180);
}
isFullscreenSupported() {
return !!(this.root.requestFullscreen || this.root.webkitRequestFullscreen);
}
isFullscreen() {
const current = document.fullscreenElement || document.webkitFullscreenElement || null;
return current === this.root;
}
setupFullscreen() {
// iOS Safari exposes the API for video only, so the button stays hidden
// there rather than offering something that cannot work.
if (!this.fullscreenButton || !this.isFullscreenSupported()) {
return;
}
const { signal } = this.abortController;
this.fullscreenButton.hidden = false;
this.fullscreenButton.addEventListener('click', () => this.toggleFullscreen(), { signal });
['fullscreenchange', 'webkitfullscreenchange'].forEach((eventName) => {
document.addEventListener(eventName, () => this.onFullscreenChange(), { signal });
});
}
async toggleFullscreen() {
try {
if (this.isFullscreen()) {
await (document.exitFullscreen?.() ?? document.webkitExitFullscreen?.());
return;
}
await (this.root.requestFullscreen?.() ?? this.root.webkitRequestFullscreen?.());
} catch (error) {
console.error('[flipbook] Vollbild nicht möglich:', error);
}
}
onFullscreenChange() {
if (this.destroyed) {
return;
}
const active = this.isFullscreen();
this.root.classList.toggle('is-fullscreen', active);
if (this.fullscreenButton) {
this.fullscreenButton.textContent = active ? this.texts.fullscreenExit : this.texts.fullscreenEnter;
this.fullscreenButton.setAttribute('aria-pressed', active ? 'true' : 'false');
}
if (!active) {
this.stage.style.maxWidth = '';
}
this.relayout();
}
cancelActiveRender() {
if (!this.activeRenderTask) {
return;
}
try {
this.activeRenderTask.cancel();
} catch {
// A task that already settled cannot be cancelled -- nothing to do.
}
this.activeRenderTask = null;
}
getLazyCandidates() {
const activePages = this.getActivePageNumbers();
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((currentPage) => {
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);
}
getEvictionAnchor() {
const activePages = this.getActivePageNumbers();
if (activePages.length === 0) {
return 1;
}
return Math.min(...activePages);
}
/**
* Releases canvases outside the current reading window. Without this the
* rendered pages accumulate for the lifetime of the page: a few hundred
* supersampled canvases are enough to exhaust the memory budget of a mobile
* browser and have the tab killed.
*/
evictDistantPages() {
const anchor = this.getEvictionAnchor();
Array.from(this.renderedPages).forEach((pageNumber) => {
if (Math.abs(pageNumber - anchor) <= EVICTION_RADIUS) {
return;
}
this.releasePageCanvas(pageNumber);
this.renderedPages.delete(pageNumber);
});
}
releasePageCanvas(pageNumber) {
const pageElement = this.pageElements.get(pageNumber);
if (!pageElement) {
return;
}
const canvas = pageElement.querySelector('canvas');
if (canvas) {
// Zeroing the dimensions frees the backing store immediately instead
// of waiting for the element to be collected.
canvas.width = 0;
canvas.height = 0;
canvas.remove();
}
const loader = this.pageLoaders.get(pageNumber);
if (loader) {
loader.classList.remove('is-hidden');
}
}
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 && !this.destroyed) {
const pageNumber = this.renderQueue.shift();
if (!pageNumber) {
continue;
}
this.pendingPages.delete(pageNumber);
try {
await this.renderPage(pageNumber);
} catch (error) {
console.error(`[flipbook] Seite ${pageNumber} konnte nicht gerendert werden:`, error);
}
}
this.renderInProgress = false;
}
async renderPage(pageNumber) {
if (this.renderedPages.has(pageNumber) || this.destroyed) {
return;
}
const pageElement = this.pageElements.get(pageNumber);
const descriptor = this.getDescriptor(pageNumber);
if (!pageElement || !descriptor) {
return;
}
const generation = this.renderGeneration;
const page = await this.pdf.getPage(descriptor.sourcePageNumber);
if (generation !== this.renderGeneration || this.destroyed) {
return;
}
const viewportAtScale1 = page.getViewport({ scale: 1 });
const renderWidth = descriptor.segment === 'full'
? viewportAtScale1.width
: viewportAtScale1.width / 2;
const scale = this.pageWidth / renderWidth;
const viewport = page.getViewport({ scale });
const devicePixelRatio = window.devicePixelRatio || 1;
const outputScale = Math.min(MAX_OUTPUT_SCALE, Math.max(MIN_OUTPUT_SCALE, devicePixelRatio));
const existingCanvas = pageElement.querySelector('canvas');
if (existingCanvas) {
existingCanvas.width = 0;
existingCanvas.height = 0;
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.');
}
const canvasCssWidth = descriptor.segment === 'full'
? Math.floor(viewport.width)
: Math.floor(viewport.width / 2);
canvas.width = Math.floor(canvasCssWidth * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = `${canvasCssWidth}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
let transform = [outputScale, 0, 0, outputScale, 0, 0];
if (descriptor.segment !== 'full') {
const segmentOffsetX = descriptor.segment === 'left'
? 0
: -Math.floor((viewport.width * outputScale) / 2);
transform = [outputScale, 0, 0, outputScale, segmentOffsetX, 0];
}
const renderTask = page.render({
canvasContext: context,
viewport,
transform,
intent: 'display',
});
this.activeRenderTask = renderTask;
try {
await renderTask.promise;
} catch (error) {
canvas.width = 0;
canvas.height = 0;
if (error && error.name === 'RenderingCancelledException') {
return;
}
throw error;
} finally {
if (this.activeRenderTask === renderTask) {
this.activeRenderTask = null;
}
}
// The layout may have changed while this page was rasterising. A canvas
// for the previous page size would be stretched by the CSS and stay that
// way, because the page counts as rendered from here on.
if (generation !== this.renderGeneration || this.destroyed) {
canvas.width = 0;
canvas.height = 0;
return;
}
const loader = this.pageLoaders.get(pageNumber);
if (loader) {
loader.classList.add('is-hidden');
}
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;
}
destroy() {
if (this.destroyed) {
return;
}
// Leaving the element in fullscreen would strand the browser on a node
// that is about to be torn down.
if (this.isFullscreen()) {
(document.exitFullscreen?.() ?? document.webkitExitFullscreen?.())?.catch?.(() => {
});
}
this.destroyed = true;
this.renderGeneration += 1;
this.cancelActiveRender();
window.clearTimeout(this.resizeTimer);
this.abortController.abort();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this.renderQueue.length = 0;
this.pendingPages.clear();
Array.from(this.renderedPages).forEach((pageNumber) => this.releasePageCanvas(pageNumber));
this.renderedPages.clear();
this.pageElements.clear();
this.pageLoaders.clear();
this.turnSounds = [];
if (this.pdf) {
this.pdf.destroy().catch(() => {
});
this.pdf = null;
}
this.flipbook = null;
delete this.root.dataset[INIT_MARKER];
instances.delete(this);
}
}
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);
instances.add(module);
await module.init();
}));
};
const destroyAll = () => {
Array.from(instances).forEach((module) => module.destroy());
};
// Exposed so AJAX- or Turbo-driven pages can re-scan for new elements and tear
// down the ones they are about to discard.
window.MummertPdfFlipbook = { scan: run, destroyAll };
const bootstrap = () => {
run().catch((error) => {
console.error('[flipbook] Bootstrap fehlgeschlagen:', error);
});
};
if (window[BOOTSTRAP_MARKER]) {
bootstrap();
} else {
window[BOOTSTRAP_MARKER] = true;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
} else {
bootstrap();
}
}