PageSourceSearch

https://www.meraner.eu/js/page-flip.meraner.js?2026-09-07-3

js meraner.eu collected 2026-09-25 01:19:59 UTC 46,427 bytes, 1,761 lines download raw bytes

1(function (window, document) {
2	"use strict";
3
4	const CONFIG = {
5		pageWidth: 1000,
6		pageHeight: 1404,
7		minZoom: 0.75,
8		/*
9		 * Layout-Zoom nutzt die Vollauflösung (~2082px).
10		 * Darüber hinaus wäre nur noch leere Vergrößerung.
11		 */
12		maxZoom: 3,
13		zoomStep: 0.25,
14		/* Obere Grenze für StPageFlip-Seitenbreite ≈ Quell-JPG */
15		maxPageRenderWidth: 2100,
16		showCover: false,
17		flippingTime: 650
18	};
19
20	const ICON_CLOSE = `
21		<svg viewBox="0 0 24 24" aria-hidden="true">
22			<path fill="currentColor" d="M6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12 19 6.4 17.6 5 12 10.6 6.4 5Z"/>
23		</svg>
24	`;
25
26	function clamp(value, minimum, maximum) {
27		return Math.min(Math.max(value, minimum), maximum);
28	}
29
30	function createViewerMarkup(options = {}) {
31		const showArchive = Boolean(options.showArchiveSlot);
32		const archiveSlot = showArchive
33			? `<div class="toolbar__group" data-pageflip-archive></div>`
34			: "";
35		const pageInputId = `pageflip-page-${Math.random().toString(36).slice(2, 9)}`;
36
37		return `
38			<div class="viewer" data-pageflip-root>
39				<div class="toolbar" aria-label="Zeitungsnavigation">
40					${archiveSlot}
41					<div class="toolbar__group">
42						<button type="button" class="toolbar__button" data-action="toggleSidebar" title="Seitenübersicht" aria-label="Seitenübersicht öffnen oder schließen" aria-expanded="true">
43							<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 5h4v4H4V5Zm6 0h10v4H10V5ZM4 10h4v4H4v-4Zm6 0h10v4H10v-4ZM4 15h4v4H4v-4Zm6 0h10v4H10v-4Z"/></svg>
44							<span class="toolbar__label">Seiten</span>
45						</button>
46					</div>
47					<span class="toolbar__separator"></span>
48					<div class="toolbar__group">
49						<button type="button" class="toolbar__button toolbar__desktop-only" data-action="firstPage" title="Erste Seite" aria-label="Erste Seite">
50							<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M6 5h2v14H6V5Zm11.5 1.5L12 12l5.5 5.5-1.4 1.4L9.2 12l6.9-6.9 1.4 1.4Z"/></svg>
51						</button>
52						<button type="button" class="toolbar__button" data-action="previousPage" title="Vorherige Seite" aria-label="Vorherige Seite">
53							<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M15.4 5.4 8.8 12l6.6 6.6L14 20l-8-8 8-8 1.4 1.4Z"/></svg>
54						</button>
55					</div>
56					<form class="page-control" data-page-form>
57						<label class="page-control__label" for="${pageInputId}">Seite</label>
58						<input class="page-number" id="${pageInputId}" data-page-number type="number" inputmode="numeric" min="1" value="1" autocomplete="off" aria-label="Zielseite eingeben">
59						<span class="page-count" data-page-count>/ –</span>
60					</form>
61					<div class="toolbar__group">
62						<button type="button" class="toolbar__button" data-action="nextPage" title="Nächste Seite" aria-label="Nächste Seite">
63							<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="m8.6 18.6 6.6-6.6-6.6-6.6L10 4l8 8-8 8-1.4-1.4Z"/></svg>
64						</button>
65						<button type="button" class="toolbar__button toolbar__desktop-only" data-action="lastPage" title="Letzte Seite" aria-label="Letzte Seite">
66							<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M16 5h2v14h-2V5ZM6.5 6.5 12 12l-5.5 5.5 1.4 1.4 6.9-6.9-6.9-6.9-1.4 1.4Z"/></svg>
67						</button>
68					</div>
69					<span class="toolbar__separator"></span>
70					<div class="toolbar__group toolbar__desktop-only">
71						<button type="button" class="toolbar__button" data-action="zoomOut" title="Verkleinern" aria-label="Verkleinern">
72							<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M9.5 4a5.5 5.5 0 1 0 3.45 9.78L18.17 19 19.6 17.6l-5.22-5.22A5.5 5.5 0 0 0 9.5 4Zm-3 5h6v2h-6V9Z"/></svg>
73						</button>
74						<button type="button" class="toolbar__button" data-action="resetZoom" title="Zoom zurücksetzen" aria-label="Zoom zurücksetzen">
75							<span data-zoom-value>100 %</span>
76						</button>
77						<button type="button" class="toolbar__button" data-action="zoomIn" title="Vergrößern" aria-label="Vergrößern">
78							<svg viewBox="0 0 24 24" aria-hidden="true">
78<path fill="currentColor" d="M9.5 4a5.5 5.5 0 1 0 3.45 9.78L18.17 19 19.6 17.6l-5.22-5.22A5.5 5.5 0 0 0 9.5 4Zm-1 2h2v3h3v2h-3v3h-2v-3h-3V9h3V6Z"/></svg>
79						</button>
80					</div>
81					<div class="toolbar__spacer"></div>
82					<button type="button" class="toolbar__button" data-action="fullscreen" title="Vollbild" aria-label="Vollbild ein- oder ausschalten">
83						<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 4h6v2H6v4H4V4Zm10 0h6v6h-2V6h-4V4ZM4 14h2v4h4v2H4v-6Zm14 0h2v6h-6v-2h4v-4Z"/></svg>
84						<span class="toolbar__label">Vollbild</span>
85					</button>
86				</div>
87				<div class="viewer__body">
88					<aside class="sidebar" data-sidebar aria-label="Seitenübersicht">
89						<div class="sidebar__header">
90							<h2 class="sidebar__title">Seitenübersicht</h2>
91						</div>
92						<div class="thumbnail-list" data-thumbnail-list></div>
93					</aside>
94					<div class="book-area" data-book-area>
95						<div class="book-zoom" data-book-zoom>
96							<div data-book></div>
97						</div>
98						<div class="status-message" data-status-message role="status" aria-live="polite"></div>
99					</div>
100				</div>
101				<div class="loading" data-loading>
102					<div class="loading__box">Ausgabe wird geladen …</div>
103				</div>
104			</div>
105		`;
106	}
107
108	class MeranerPageFlipInstance {
109		constructor(root, options = {}) {
110			this.root = root;
111			this.options = options;
112			this.mode = options.mode || "page";
113			this.updateHash = options.updateHash !== false && this.mode === "page";
114			this.fitToViewport = options.fitToViewport !== false && this.mode === "page";
115
116			this.pageFlip = null;
117			this.pages = [];
118			this.pageWidth = CONFIG.pageWidth;
119			this.pageHeight = CONFIG.pageHeight;
120			this.currentZoom = 1;
121			this.selectedPageNumber = null;
122			this.pendingPageNumber = null;
123			this.statusTimer = null;
124			this.resizeFrame = null;
125			this.resizeTimer = null;
126			this.toolbarHeavyFrame = null;
127			this.backgroundPrimeStarted = false;
128			this.backgroundPrimeIdle = null;
129			this.backgroundPrimeTimer = null;
130
131			this.panX = 0;
132			this.panY = 0;
133			this.zoomOriginX = 50;
134			this.zoomOriginY = 50;
135
136			this.activePointers = new Map();
137			this.pinchStartDistance = 0;
138			this.pinchStartZoom = 1;
139			this.isPinching = false;
140			this.isPanning = false;
141			this.panPointerId = null;
142			this.lastPanX = 0;
143			this.lastPanY = 0;
144			this.zoomBoundFrame = null;
145			this.pendingZoomBound = 1;
146
147			this.boundHandlers = {};
148			this.cacheElements();
149		}
150
151		cacheElements() {
152			const root = this.root;
153
154			this.elements = {
155				viewer: root,
156				loading:
157					root.querySelector("[data-loading]") ||
158					root.querySelector("#loading"),
159				book:
160					root.querySelector("[data-book]") ||
161					root.querySelector("#book"),
162				bookArea:
163					root.querySelector("[data-book-area]") ||
164					root.querySelector("#bookArea"),
165				bookZoom:
166					root.querySelector("[data-book-zoom]") ||
167					root.querySelector("#bookZoom"),
168				sidebar:
169					root.querySelector("[data-sidebar]") ||
170					root.querySelector("#sidebar"),
171				toggleSidebar:
172					root.querySelector('[data-action="toggleSidebar"]') ||
173					root.querySelector("#toggleSidebar"),
174				thumbnailList:
175					root.querySelector("[data-thumbnail-list]") ||
176					root.querySelector("#thumbnailList"),
177				firstPage:
178					root.querySelector('[data-action="firstPage"]') ||
179					root.querySelector("#firstPage"),
180				previousPage:
181					root.querySelector('[data-action="previousPage"]') ||
182					root.querySelector("#previousPage"),
183				nextPage:
184					root.querySelector('[data-action="nextPage"]') ||
185					root.querySelector("#nextPage"),
186				lastPage:
187					root.querySelector('[data-action="lastPage"]') ||
188					root.querySelector("#lastPage"),
189				pageForm:
190					root.querySelector("[data-page-form]") ||
191					root.querySelector("#pageForm"),
192				pageNumber:
193					root.querySelector("[data-page-number]") ||
194					root.querySelector("#pageNumber"),
195				pageCount:
196					root.querySelector("[data-page-count]") ||
197					root.querySelector("#pageCount"),
198				zoomOut:
199					root.querySelector('[data-action="zoomOut"]') ||
200					root.querySelector("#zoomOut"),
201				zoomIn:
202					root.querySelector('[data-action="zoomIn"]') ||
203					root.querySelector("#zoomIn"),
204				resetZoom:
205					root.querySelector('[data-action="resetZoom"]') ||
206					root.querySelector("#resetZoom"),
207				zoomValue:
208					root.querySelector("[data-zoom-value]") ||
209					root.querySelector("#zoomValue"),
210				fullscreen:
211					root.querySelector('[data-action="fullscreen"]') ||
212					root.querySelector("#fullscreen"),
213				statusMessage:
214					root.querySelector("[data-status-message]") ||
215					root.querySelector("#statusMessage")
216			};
217
218			if (this.elements.book && !this.elements.book.id) {
219				this.elements.book.id = "book";
220			}
221		}
222
223		async init(pageSources = null) {
224			if (
225				typeof St === "undefined" ||
226				typeof St.PageFlip !== "function"
227			) {
228				throw new Error("StPageFlip wurde nicht geladen.");
229			}
230
231			if (pageSources) {
232				this.renderPages(pageSources);
233			}
234
235			this.pages = Array.from(
236				this.elements.book.querySelectorAll(".book-page")
237			);
238
239			if (!this.pages.length) {
240				throw new Error("Keine Seiten gefunden.");
241			}
242
243			this.createThumbnails();
244			this.bindEvents();
245			this.refreshBookLayout();
246
247			await this.resolvePageSizeFromImages();
248			this.applyPageAspectCss();
249			this.initializePageFlip();
250
251			if (window.matchMedia("(max-width: 900px)").matches) {
252				this.setSidebar(false);
253			}
254
255			this.resetZoom(false);
256
257			if (this.elements.loading) {
258				this.elements.loading.hidden = true;
259			}
260
261			await this.waitForNearbyImages();
262			this.startBackgroundImagePrime();
263			this.refreshBookLayout();
264			return this;
265		}
266
267		getPageAspect() {
268			return this.pageHeight / this.pageWidth;
269		}
270
271		applyPageAspectCss() {
272			if (!this.elements.viewer) {
273				return;
274			}
275
276			this.elements.viewer.style.setProperty(
277				"--page-aspect",
278				`${this.pageWidth} / ${this.pageHeight}`
279			);
280		}
281
282		async resolvePageSizeFromImages() {
283			const samplePage = this.pages.find(
284				page =>
285					page &&
286					!page.classList.contains("book-page--blank") &&
287					page.querySelector("img")
288			);
289			const image = samplePage?.querySelector("img");
290
291			if (!image) {
292				return;
293			}
294
295			await this.primeImage(image);
296
297			if (!(image.naturalWidth > 0) || !(image.naturalHeight > 0)) {
298				return;
299			}
300
301			this.pageWidth = CONFIG.pageWidth;
302			this.pageHeight = Math.max(
303				1,
304				Math.round(
305					(this.pageWidth * image.naturalHeight) / image.naturalWidth
306				)
307			);
308		}
309
310		renderPages(pageSources) {
311			const fragment = document.createDocumentFragment();
312
313			const blank = document.createElement("div");
314			blank.className = "book-page book-page--blank";
315			blank.dataset.density = "soft";
316			blank.setAttribute("aria-hidden", "true");
317			fragment.appendChild(blank);
318
319			pageSources.forEach((source, index) => {
320				const src = typeof source === "string" ? source : source.src;
321				const pageNumber =
322					typeof source === "object" && source.page
323						? source.page
324						: index + 1;
325
326				const page = document.createElement("div");
327				page.className = "book-page";
328				page.dataset.page = String(pageNumber);
329
330				const image = document.createElement("img");
331				image.alt = `Seite ${pageNumber}`;
332				image.draggable = false;
333				image.decoding = "async";
334				image.loading = "eager";
335				image.src = src;
336
337				page.appendChild(image);
338				fragment.appendChild(page);
339			});
340
341			this.elements.book.replaceChildren(fragment);
342		}
343
344		pageIndexToNumber(pageIndex) {
345			const page = this.pages[pageIndex];
346
347			if (!page) {
348				return null;
349			}
350
351			const pageNumber = Number.parseInt(page.dataset.page, 10);
352			return Number.isFinite(pageNumber) ? pageNumber : null;
353		}
354
355		pageNumberToIndex(pageNumber) {
356			const requested = Number.parseInt(pageNumber, 10);
357
358			if (!Number.isFinite(requested)) {
359				return -1;
360			}
361
362			return this.pages.findIndex(page => {
363				const current = Number.parseInt(page.dataset.page, 10);
364				return Number.isFinite(current) && current === requested;
365			});
366		}
367
368		getVisiblePageCount() {
369			return this.pages.filter(page =>
370				Number.isFinite(Number.parseInt(page.dataset.page, 10))
371			).length;
372		}
373
374		getCurrentPageIndex() {
375			return this.pageFlip ? this.pageFlip.getCurrentPageIndex() : 0;
376		}
377
378		getCurrentPageNumber() {
379			return this.pageIndexToNumber(this.getCurrentPageIndex());
380		}
381
382		showStatus(message) {
383			if (!this.elements.statusMessage) {
384				return;
385			}
386
387			window.clearTimeout(this.statusTimer);
388			this.elements.statusMessage.textContent = message;
389			this.elements.statusMessage.classList.add("status-message--visible");
390
391			this.statusTimer = window.setTimeout(() => {
392				this.elements.statusMessage.classList.remove(
393					"status-message--visible"
394				);
395			}, 1600);
396		}
397
398		createThumbnails() {
399			const fragment = document.createDocumentFragment();
400
401			this.pages.forEach((page, index) => {
402				const originalImage = page.querySelector("img");
403
404				if (!originalImage) {
405					return;
406				}
407
408				const pageNumber =
409					page.dataset.page ||
410					originalImage.alt?.replace(/\D+/g, "") ||
411					String(index + 1);
412
413				const button = document.createElement("button");
414				button.type = "button";
415				button.className = "thumbnail";
416
417				if (String(index) === "1") {
418					button.classList.add("thumbnail--cover");
419				}
420
421				button.dataset.pageIndex = String(index);
422				button.title = `Seite ${pageNumber} öffnen`;
423				button.setAttribute(
424					"aria-label",
425					`Seite ${pageNumber} öffnen`
426				);
427
428				const image = document.createElement("img");
429				image.className = "thumbnail__image";
430				image.src = originalImage.currentSrc || originalImage.src;
431				image.alt = "";
432				image.loading = "lazy";
433				image.decoding = "async";
434
435				const number = document.createElement("span");
436				number.className = "thumbnail__number";
437				number.textContent = `Seite ${pageNumber}`;
438
439				button.append(image, number);
440
441				button.addEventListener("click", () => {
442					this.goToPageNumber(pageNumber);
443
444					if (window.matchMedia("(max-width: 900px)").matches) {
445						this.setSidebar(false);
446					}
447				});
448
449				fragment.appendChild(button);
450			});
451
452			this.elements.thumbnailList.replaceChildren(fragment);
453		}
454
455		updateActiveThumbnail(pageIndex) {
456			const oldActive = this.elements.thumbnailList.querySelector(
457				".thumbnail--active"
458			);
459
460			if (oldActive) {
461				oldActive.classList.remove("thumbnail--active");
462				oldActive.removeAttribute("aria-current");
463			}
464
465			const newActive = this.elements.thumbnailList.querySelector(
466				`[data-page-index="${pageIndex}"]`
467			);
468
469			if (!newActive) {
470				return;
471			}
472
473			newActive.classList.add("thumbnail--active");
474			newActive.setAttribute("aria-current", "page");
475
476			const list = this.elements.thumbnailList;
477			const listRect = list.getBoundingClientRect();
478			const thumbRect = newActive.getBoundingClientRect();
479			const outOfView =
480				thumbRect.top < listRect.top ||
481				thumbRect.bottom > listRect.bottom;
482
483			if (outOfView) {
484				newActive.scrollIntoView({
485					block: "nearest",
486					behavior: "auto"
487				});
488			}
489		}
490
491		goToPageNumber(requestedPageNumber, animate = true) {
492			const pageCount = this.getVisiblePageCount();
493			let pageNumber = Number.parseInt(requestedPageNumber, 10);
494
495			if (!Number.isFinite(pageNumber)) {
496				pageNumber = this.getCurrentPageNumber() || 1;
497			}
498
499			pageNumber = clamp(pageNumber, 1, pageCount);
500			const pageIndex = this.pageNumberToIndex(pageNumber);
501
502			if (pageIndex < 0 || !this.pageFlip) {
503				return;
504			}
505
506			/*
507			 * Enter im Zahlenfeld löst oft change + submit aus.
508			 * StPageFlip.flip() würde dann zweimal flipNext/flipPrev
509			 * ausführen und eine Seite zu weit springen.
510			 */
511			if (this.pendingPageNumber === pageNumber) {
512				return;
513			}
514
515			this.pendingPageNumber = pageNumber;
516			this.selectedPageNumber = pageNumber;
517			this.elements.pageNumber.value = String(pageNumber);
518			this.ensureNearbyImages(pageIndex);
519
520			if (animate) {
521				this.pageFlip.flip(pageIndex, "top");
522			} else {
523				this.pageFlip.turnToPage(pageIndex);
524				this.updateToolbar(this.pageFlip.getCurrentPageIndex());
525			}
526
527			window.setTimeout(() => {
528				if (this.pendingPageNumber === pageNumber) {
529					this.pendingPageNumber = null;
530				}
531			}, CONFIG.flippingTime + 50);
532		}
533
534		goToFirstPage() {
535			this.goToPageNumber(1);
536		}
537
538		goToLastPage() {
539			const lastPage = this.pages
540				.map(page => Number.parseInt(page.dataset.page, 10))
541				.filter(Number.isFinite)
542				.pop();
543
544			this.goToPageNumber(lastPage);
545		}
546
547		goToPreviousPage() {
548			this.selectedPageNumber = null;
549			this.pageFlip?.flipPrev("top");
550		}
551
552		goToNextPage() {
553			this.selectedPageNumber = null;
554			this.pageFlip?.flipNext("top");
555		}
556
557		getPageNumberFromHash() {
558			const match = window.location.hash.match(/^#seite\/(\d+)$/);
559
560			if (!match) {
561				return 1;
562			}
563
564			return clamp(
565				Number.parseInt(match[1], 10),
566				1,
567				this.getVisiblePageCount()
568			);
569		}
570
571		writeHash(pageNumber) {
572			if (!this.updateHash) {
573				return;
574			}
575
576			const newHash = `#seite/${pageNumber}`;
577
578			if (window.location.hash === newHash) {
579				return;
580			}
581
582			history.replaceState(
583				null,
584				"",
585				`${window.location.pathname}${window.location.search}${newHash}`
586			);
587		}
588
589		resolveToolbarState(pageIndex) {
590			const pageCount = this.getVisiblePageCount();
591			let effectivePageIndex = pageIndex;
592			let pageNumber = this.pageIndexToNumber(effectivePageIndex);
593
594			if (pageNumber === null) {
595				const nextPageIndex = this.pages.findIndex(
596					(page, index) =>
597						index > pageIndex &&
598						Number.isFinite(Number.parseInt(page.dataset.page, 10))
599				);
600
601				if (nextPageIndex >= 0) {
602					effectivePageIndex = nextPageIndex;
603					pageNumber = this.pageIndexToNumber(effectivePageIndex);
604				}
605			}
606
607			const visiblePageNumbers = [
608				this.pageIndexToNumber(effectivePageIndex),
609				this.pageIndexToNumber(effectivePageIndex + 1)
610			].filter(number => number !== null);
611
612			if (
613				this.selectedPageNumber !== null &&
614				visiblePageNumbers.includes(this.selectedPageNumber)
615			) {
616				pageNumber = this.selectedPageNumber;
617				effectivePageIndex = this.pageNumberToIndex(pageNumber);
618			}
619
620			return {
621				pageCount,
622				effectivePageIndex,
623				pageNumber
624			};
625		}
626
627		updateToolbar(pageIndex, options = {}) {
628			const deferHeavy = options.deferHeavy === true;
629			const {
630				pageCount,
631				effectivePageIndex,
632				pageNumber
633			} = this.resolveToolbarState(pageIndex);
634
635			this.elements.pageNumber.min = "1";
636			this.elements.pageNumber.max = String(pageCount);
637			this.elements.pageCount.textContent = `/ ${pageCount}`;
638
639			if (pageNumber === null) {
640				return;
641			}
642
643			this.elements.pageNumber.value = String(pageNumber);
644
645			const firstPageIndex = this.pageNumberToIndex(1);
646			const lastPageIndex = this.pageNumberToIndex(pageCount);
647
648			this.elements.firstPage.disabled =
649				effectivePageIndex <= firstPageIndex;
650			this.elements.previousPage.disabled =
651				effectivePageIndex <= firstPageIndex;
652			this.elements.nextPage.disabled =
653				effectivePageIndex >= lastPageIndex;
654			this.elements.lastPage.disabled =
655				effectivePageIndex >= lastPageIndex;
656
657			this.writeHash(pageNumber);
658
659			const runHeavy = () => {
660				this.updateActiveThumbnail(effectivePageIndex);
661				this.ensureNearbyImages(effectivePageIndex);
662
663				if (this.mode === "page") {
664					document.title =
665						`Seite ${pageNumber} – Meraner Blätterzeitung`;
666				}
667			};
668
669			/*
670			 * Chrome flasht, wenn am Animationsende gleichzeitig
671			 * StPageFlip die Styles zurücksetzt und wir scrollen/decoden.
672			 */
673			if (deferHeavy) {
674				window.cancelAnimationFrame(this.toolbarHeavyFrame);
675				this.toolbarHeavyFrame = window.requestAnimationFrame(() => {
676					this.toolbarHeavyFrame = window.requestAnimationFrame(runHeavy);
677				});
678				return;
679			}
680
681			runHeavy();
682		}
683
684		setSidebar(open) {
685			if (!this.elements.sidebar || !this.elements.toggleSidebar) {
686				return;
687			}
688
689			this.elements.sidebar.hidden = !open;
690			this.elements.toggleSidebar.setAttribute(
691				"aria-expanded",
692				String(open)
693			);
694
695			window.setTimeout(() => this.refreshBookLayout(), 220);
696		}
697
698		toggleSidebar() {
699			this.setSidebar(this.elements.sidebar.hidden);
700		}
701
702		applyZoom(newZoom, showMessage = true) {
703			this.currentZoom = clamp(
704				newZoom,
705				CONFIG.minZoom,
706				CONFIG.maxZoom
707			);
708
709			if (this.currentZoom <= 1) {
710				this.panX = 0;
711				this.panY = 0;
712				this.zoomOriginX = 50;
713				this.zoomOriginY = 50;
714			}
715
716			this.updateBookTransform({ resizeBook: true });
717
718			this.elements.zoomValue.textContent =
719				this.currentZoom === 1
720					? "Auto"
721					: `${Math.round(this.currentZoom * 100)} %`;
722
723			this.elements.zoomOut.disabled =
724				this.currentZoom <= CONFIG.minZoom;
725			this.elements.zoomIn.disabled =
726				this.currentZoom >= CONFIG.maxZoom;
727
728			if (showMessage) {
729				this.showStatus(
730					this.currentZoom === 1
731						? "Automatisch eingepasst"
732						: `Zoom: ${Math.round(this.currentZoom * 100)} %`
733				);
734			}
735		}
736
737		clampPan(areaWidth, areaHeight, zoomWidth, zoomHeight) {
738			const maxPanX = Math.max(0, (zoomWidth - areaWidth) / 2);
739			const maxPanY = Math.max(0, (zoomHeight - areaHeight) / 2);
740
741			this.panX = clamp(this.panX, -maxPanX, maxPanX);
742			this.panY = clamp(this.panY, -maxPanY, maxPanY);
743		}
744
745		syncPageFlipZoomBounds(zoom) {
746			this.pendingZoomBound = zoom;
747
748			if (this.zoomBoundFrame !== null) {
749				return;
750			}
751
752			this.zoomBoundFrame = requestAnimationFrame(() => {
753				this.zoomBoundFrame = null;
754
755				if (
756					!this.pageFlip ||
757					typeof this.pageFlip.getSettings !== "function"
758				) {
759					return;
760				}
761
762				const settings = this.pageFlip.getSettings();
763				const scale = Math.max(1, this.pendingZoomBound);
764				const maxWidth = Math.min(
765					Math.round(this.pageWidth * scale),
766					CONFIG.maxPageRenderWidth
767				);
768				const maxHeight = Math.round(maxWidth * this.getPageAspect());
769
770				settings.maxWidth = maxWidth;
771				settings.maxHeight = maxHeight;
772				this.pageFlip.update();
773			});
774		}
775
776		updateBookTransform({ resizeBook = true } = {}) {
777			const bookZoom = this.elements.bookZoom;
778			const bookArea = this.elements.bookArea;
779			const magnified = this.currentZoom > 1;
780			const atFit = this.currentZoom === 1;
781
782			bookZoom.classList.toggle("is-zoomed", magnified);
783
784			bookArea.style.cursor = magnified ? "grab" : "default";
785
786			/*
787			 * Bei Zoom übernimmt bookArea das Panning.
788			 * StPageFlip darf dann keine Flip-Gesten mehr empfangen.
789			 */
790			this.elements.book.style.pointerEvents = magnified ? "none" : "";
791
792			if (atFit) {
793				bookZoom.style.width = "";
794				bookZoom.style.height = "";
795				bookZoom.style.minWidth = "";
796				bookZoom.style.minHeight = "";
797				bookZoom.style.flexShrink = "";
798				bookZoom.style.transform = "";
799
800				if (resizeBook) {
801					this.syncPageFlipZoomBounds(1);
802				}
803
804				return;
805			}
806
807			const areaWidth = bookArea.clientWidth;
808			const areaHeight = bookArea.clientHeight;
809			const zoomWidth = areaWidth * this.currentZoom;
810			const zoomHeight = areaHeight * this.currentZoom;
811
812			if (resizeBook) {
813				bookZoom.style.flexShrink = "0";
814				bookZoom.style.width = `${zoomWidth}px`;
815				bookZoom.style.height = `${zoomHeight}px`;
816				bookZoom.style.minWidth = `${zoomWidth}px`;
817				bookZoom.style.minHeight = `${zoomHeight}px`;
818				/*
819				 * Verkleinern: maxWidth bleibt bei Basis.
820				 * Vergrößern: maxWidth steigt mit Zoom bis zur Quellauflösung.
821				 */
822				this.syncPageFlipZoomBounds(magnified ? this.currentZoom : 1);
823			}
824
825			if (!magnified) {
826				this.panX = 0;
827				this.panY = 0;
828				bookZoom.style.transform = "";
829				return;
830			}
831
832			const currentWidth = resizeBook
833				? zoomWidth
834				: bookZoom.offsetWidth || zoomWidth;
835			const currentHeight = resizeBook
836				? zoomHeight
837				: bookZoom.offsetHeight || zoomHeight;
838
839			this.clampPan(areaWidth, areaHeight, currentWidth, currentHeight);
840
841			/*
842			 * Nur translate – kein scale(). Die Seite wird real größer,
843			 * damit der Browser aus dem Vollbild-JPG resamplet.
844			 */
845			bookZoom.style.transform =
846				`translate(${this.panX}px, ${this.panY}px)`;
847		}
848
849		zoomIn() {
850			this.applyZoom(this.currentZoom + CONFIG.zoomStep);
851		}
852
853		zoomOut() {
854			this.applyZoom(this.currentZoom - CONFIG.zoomStep);
855		}
856
857		resetZoom(showMessage = true) {
858			this.panX = 0;
859			this.panY = 0;
860			this.zoomOriginX = 50;
861			this.zoomOriginY = 50;
862			this.applyZoom(1, showMessage);
863		}
864
865		handleWheelZoom(event) {
866			if (!(event.ctrlKey || event.metaKey)) {
867				return;
868			}
869
870			event.preventDefault();
871
872			const rect = this.elements.bookArea.getBoundingClientRect();
873			const oldZoom = this.currentZoom;
874			const direction = event.deltaY < 0 ? 1 : -1;
875			const newZoom = clamp(
876				oldZoom + direction * 0.1,
877				CONFIG.minZoom,
878				CONFIG.maxZoom
879			);
880
881			const localX = event.clientX - rect.left - rect.width / 2;
882			const localY = event.clientY - rect.top - rect.height / 2;
883
884			if (oldZoom > 0) {
885				const zoomRatio = newZoom / oldZoom;
886				this.panX = localX - (localX - this.panX) * zoomRatio;
887				this.panY = localY - (localY - this.panY) * zoomRatio;
888			}
889
890			this.applyZoom(newZoom);
891		}
892
893		getPointerDistance() {
894			const pointers = [...this.activePointers.values()];
895
896			if (pointers.length < 2) {
897				return 0;
898			}
899
900			return Math.hypot(
901				pointers[1].clientX - pointers[0].clientX,
902				pointers[1].clientY - pointers[0].clientY
903			);
904		}
905
906		shouldHandlePointer(event) {
907			if (event.pointerType === "mouse") {
908				return this.currentZoom > 1 && event.buttons === 1;
909			}
910
911			return this.activePointers.size >= 2 || this.currentZoom > 1;
912		}
913
914		handlePointerDown(event) {
915			if (event.pointerType === "mouse") {
916				if (this.currentZoom <= 1 || event.button !== 0) {
917					return;
918				}
919
920				this.isPanning = true;
921				this.panPointerId = event.pointerId;
922				this.lastPanX = event.clientX;
923				this.lastPanY = event.clientY;
924				this.elements.bookArea.setPointerCapture?.(event.pointerId);
925				this.elements.bookArea.style.cursor = "grabbing";
926				event.preventDefault();
927				return;
928			}
929
930			this.activePointers.set(event.pointerId, {
931				clientX: event.clientX,
932				clientY: event.clientY
933			});
934
935			if (this.activePointers.size === 2) {
936				this.isPinching = true;
937				this.pinchStartDistance = this.getPointerDistance();
938				this.pinchStartZoom = this.currentZoom;
939				event.preventDefault();
940				return;
941			}
942
943			if (this.currentZoom > 1 && this.activePointers.size === 1) {
944				this.isPanning = true;
945				this.panPointerId = event.pointerId;
946				this.lastPanX = event.clientX;
947				this.lastPanY = event.clientY;
948				this.elements.bookArea.setPointerCapture?.(event.pointerId);
949				event.preventDefault();
950			}
951		}
952
953		handlePointerMove(event) {
954			if (event.pointerType !== "mouse" && this.activePointers.has(event.pointerId)) {
955				this.activePointers.set(event.pointerId, {
956					clientX: event.clientX,
957					clientY: event.clientY
958				});
959			}
960
961			if (this.isPinching && this.activePointers.size === 2) {
962				event.preventDefault();
963
964				const currentDistance = this.getPointerDistance();
965
966				if (this.pinchStartDistance <= 0 || currentDistance <= 0) {
967					return;
968				}
969
970				const pointers = [...this.activePointers.values()];
971				const centerX =
972					(pointers[0].clientX + pointers[1].clientX) / 2;
973				const centerY =
974					(pointers[0].clientY + pointers[1].clientY) / 2;
975				const rect = this.elements.bookArea.getBoundingClientRect();
976				const oldZoom = this.currentZoom;
977				const newZoom = clamp(
978					this.pinchStartZoom *
979						(currentDistance / this.pinchStartDistance),
980					CONFIG.minZoom,
981					CONFIG.maxZoom
982				);
983
984				const localX = centerX - rect.left - rect.width / 2;
985				const localY = centerY - rect.top - rect.height / 2;
986
987				if (oldZoom > 0) {
988					const zoomRatio = newZoom / oldZoom;
989					this.panX = localX - (localX - this.panX) * zoomRatio;
990					this.panY = localY - (localY - this.panY) * zoomRatio;
991				}
992
993				this.applyZoom(newZoom, false);
994				return;
995			}
996
997			if (
998				this.isPanning &&
999				this.panPointerId === event.pointerId &&
1000				this.currentZoom > 1
1001			) {
1002				event.preventDefault();
1003
1004				this.panX += event.clientX - this.lastPanX;
1005				this.panY += event.clientY - this.lastPanY;
1006				this.lastPanX = event.clientX;
1007				this.lastPanY = event.clientY;
1008				this.updateBookTransform({ resizeBook: false });
1009			}
1010		}
1011
1012		handlePointerEnd(event) {
1013			if (event.pointerType !== "mouse") {
1014				this.activePointers.delete(event.pointerId);
1015			}
1016
1017			try {
1018				this.elements.bookArea.releasePointerCapture?.(
1019					event.pointerId
1020				);
1021			} catch {
1022				// already released
1023			}
1024
1025			if (this.activePointers.size < 2) {
1026				this.isPinching = false;
1027				this.pinchStartDistance = 0;
1028				this.pinchStartZoom = this.currentZoom;
1029			}
1030
1031			if (this.panPointerId === event.pointerId) {
1032				this.isPanning = false;
1033				this.panPointerId = null;
1034				this.elements.bookArea.style.cursor =
1035					this.currentZoom > 1 ? "grab" : "default";
1036			}
1037		}
1038
1039
1040		async toggleFullscreen() {
1041			try {
1042				if (!document.fullscreenElement) {
1043					await this.elements.viewer.requestFullscreen();
1044				} else {
1045					await document.exitFullscreen();
1046				}
1047			} catch (error) {
1048				console.error(error);
1049				this.showStatus("Vollbild konnte nicht aktiviert werden.");
1050			}
1051		}
1052
1053		updateFullscreenButton() {
1054			const isFullscreen = Boolean(document.fullscreenElement);
1055
1056			this.elements.fullscreen.title = isFullscreen
1057				? "Vollbild verlassen"
1058				: "Vollbild";
1059
1060			this.elements.fullscreen.setAttribute(
1061				"aria-label",
1062				isFullscreen
1063					? "Vollbild verlassen"
1064					: "Vollbild einschalten"
1065			);
1066
1067			window.setTimeout(() => this.refreshBookLayout(), 100);
1068		}
1069
1070		refreshBookLayout() {
1071			cancelAnimationFrame(this.resizeFrame);
1072
1073			if (this.fitToViewport) {
1074				const isFullscreen =
1075					document.fullscreenElement === this.elements.viewer;
1076				const viewerTop = isFullscreen
1077					? 0
1078					: Math.max(
1079						0,
1080						this.elements.viewer.getBoundingClientRect().top
1081					);
1082				const availableHeight = isFullscreen
1083					? window.innerHeight
1084					: Math.max(280, window.innerHeight - viewerTop - 16);
1085
1086				this.elements.viewer.style.height =
1087					`${Math.floor(availableHeight)}px`;
1088			} else {
1089				this.elements.viewer.style.height = "100%";
1090			}
1091
1092			this.resizeFrame = requestAnimationFrame(() => {
1093				/*
1094				 * Bei Layout-Zoom Größe neu aus dem Viewport ableiten,
1095				 * sonst nur StPageFlip neu vermessen.
1096				 */
1097				if (this.currentZoom !== 1) {
1098					this.updateBookTransform({ resizeBook: true });
1099				} else {
1100					this.pageFlip?.update();
1101				}
1102			});
1103		}
1104
1105		scheduleRefreshBookLayout() {
1106			window.clearTimeout(this.resizeTimer);
1107			this.resizeTimer = window.setTimeout(() => {
1108				this.refreshBookLayout();
1109			}, 80);
1110		}
1111
1112		isInputElement(element) {
1113			return (
1114				element instanceof HTMLInputElement ||
1115				element instanceof HTMLTextAreaElement ||
1116				element instanceof HTMLSelectElement
1117			);
1118		}
1119
1120		handleKeyboard(event) {
1121			if (this.isInputElement(event.target)) {
1122				return;
1123			}
1124
1125			if (
1126				this.mode === "lightbox" &&
1127				(event.key === "Escape" || event.key === "Esc")
1128			) {
1129				event.preventDefault();
1130				MeranerPageFlip.closeLightbox();
1131				return;
1132			}
1133
1134			switch (event.key) {
1135				case "ArrowLeft":
1136				case "PageUp":
1137					event.preventDefault();
1138					this.goToPreviousPage();
1139					break;
1140				case "ArrowRight":
1141				case "PageDown":
1142					event.preventDefault();
1143					this.goToNextPage();
1144					break;
1145				case "Home":
1146					event.preventDefault();
1147					this.goToFirstPage();
1148					break;
1149				case "End":
1150					event.preventDefault();
1151					this.goToLastPage();
1152					break;
1153				case "+":
1154					event.preventDefault();
1155					this.zoomIn();
1156					break;
1157				case "-":
1158					event.preventDefault();
1159					this.zoomOut();
1160					break;
1161				case "0":
1162					event.preventDefault();
1163					this.resetZoom();
1164					break;
1165				case "f":
1166				case "F":
1167					event.preventDefault();
1168					this.toggleFullscreen();
1169					break;
1170			}
1171		}
1172
1173		primeImage(image) {
1174			if (!image || image.dataset.primed === "1") {
1175				return Promise.resolve();
1176			}
1177
1178			image.loading = "eager";
1179
1180			if (image.dataset.src && !image.getAttribute("src")) {
1181				image.src = image.dataset.src;
1182			}
1183
1184			const markPrimed = () => {
1185				image.dataset.primed = "1";
1186			};
1187
1188			const decodeImage = () => {
1189				if (typeof image.decode !== "function") {
1190					markPrimed();
1191					return Promise.resolve();
1192				}
1193
1194				return image.decode().then(markPrimed).catch(markPrimed);
1195			};
1196
1197			if (image.complete && image.naturalWidth > 0) {
1198				return decodeImage();
1199			}
1200
1201			return new Promise(resolve => {
1202				const finish = () => {
1203					decodeImage().then(resolve);
1204				};
1205
1206				image.addEventListener("load", finish, { once: true });
1207				image.addEventListener(
1208					"error",
1209					() => {
1210						markPrimed();
1211						resolve();
1212					},
1213					{ once: true }
1214				);
1215			});
1216		}
1217
1218		ensureNearbyImages(centerIndex, ahead = 6) {
1219			const start = Math.max(0, centerIndex - 2);
1220			const end = Math.min(this.pages.length - 1, centerIndex + ahead);
1221
1222			for (let index = start; index <= end; index += 1) {
1223				const image = this.pages[index]?.querySelector("img");
1224				this.primeImage(image);
1225			}
1226		}
1227
1228		startBackgroundImagePrime() {
1229			if (this.backgroundPrimeStarted) {
1230				return;
1231			}
1232
1233			this.backgroundPrimeStarted = true;
1234
1235			let index = 0;
1236
1237			const step = () => {
1238				if (!this.pages.length || index >= this.pages.length) {
1239					return;
1240				}
1241
1242				const image = this.pages[index]?.querySelector("img");
1243				index += 1;
1244
1245				const scheduleNext = () => {
1246					if (typeof window.requestIdleCallback === "function") {
1247						this.backgroundPrimeIdle = window.requestIdleCallback(
1248							step,
1249							{ timeout: 500 }
1250						);
1251					} else {
1252						this.backgroundPrimeTimer = window.setTimeout(step, 80);
1253					}
1254				};
1255
1256				this.primeImage(image).finally(scheduleNext);
1257			};
1258
1259			step();
1260		}
1261
1262		async waitForNearbyImages() {
1263			const startIndex = Math.max(
1264				0,
1265				this.pageNumberToIndex(this.getPageNumberFromHash())
1266			);
1267
1268			const indexes = [
1269				startIndex,
1270				startIndex + 1,
1271				startIndex + 2,
1272				startIndex + 3,
1273				startIndex + 4
1274			].filter(index => index >= 0 && index < this.pages.length);
1275
1276			await Promise.allSettled(
1277				indexes.map(index => {
1278					const image = this.pages[index]?.querySelector("img");
1279					return this.primeImage(image);
1280				})
1281			);
1282		}
1283
1284		initializePageFlip() {
1285			const minWidth = 280;
1286			const minHeight = Math.max(
1287				1,
1288				Math.round(minWidth * this.getPageAspect())
1289			);
1290
1291			this.pageFlip = new St.PageFlip(this.elements.book, {
1292				width: this.pageWidth,
1293				height: this.pageHeight,
1294				size: "stretch",
1295				minWidth,
1296				maxWidth: this.pageWidth,
1297				minHeight,
1298				maxHeight: this.pageHeight,
1299				maxShadowOpacity: 0.35,
1300				showCover: CONFIG.showCover,
1301				mobileScrollSupport: false,
1302				disableFlipByClick: false,
1303				usePortrait: true,
1304				flippingTime: CONFIG.flippingTime,
1305				drawShadow: true,
1306				autoSize: false,
1307				startZIndex: 0,
1308				useMouseEvents: true,
1309				swipeDistance: 30
1310			});
1311
1312			this.pageFlip.loadFromHTML(this.pages);
1313
1314			this.pageFlip.on("flip", event => {
1315				this.updateToolbar(event.data, { deferHeavy: true });
1316			});
1317
1318			this.pageFlip.on("changeState", event => {
1319				if (!this.pageFlip) {
1320					return;
1321				}
1322
1323				const currentIndex = this.pageFlip.getCurrentPageIndex();
1324
1325				/*
1326				 * Schon beim Anfassen/Blättern die nächsten Seiten decodieren,
1327				 * bevor Chrome sie das erste Mal einblendet.
1328				 */
1329				if (
1330					event.data === "user_fold" ||
1331					event.data === "fold_corner" ||
1332					event.data === "flipping"
1333				) {
1334					this.ensureNearbyImages(currentIndex, 8);
1335					return;
1336				}
1337
1338				if (event.data === "read") {
1339					this.updateToolbar(currentIndex, {
1340						deferHeavy: true
1341					});
1342				}
1343			});
1344
1345			this.pageFlip.on("changeOrientation", () => {
1346				window.setTimeout(() => {
1347					this.updateToolbar(this.pageFlip.getCurrentPageIndex());
1348					this.refreshBookLayout();
1349				}, 50);
1350			});
1351
1352			const startPage = this.updateHash
1353				? this.getPageNumberFromHash()
1354				: 1;
1355
1356			this.pageFlip.turnToPage(this.pageNumberToIndex(startPage));
1357			this.updateToolbar(this.pageFlip.getCurrentPageIndex());
1358		}
1359
1360		bindEvents() {
1361			const on = (element, type, handler, options) => {
1362				if (!element) {
1363					return;
1364				}
1365
1366				element.addEventListener(type, handler, options);
1367			};
1368
1369			on(this.elements.toggleSidebar, "click", () => this.toggleSidebar());
1370			on(this.elements.firstPage, "click", () => this.goToFirstPage());
1371			on(this.elements.previousPage, "click", () => this.goToPreviousPage());
1372			on(this.elements.nextPage, "click", () => this.goToNextPage());
1373			on(this.elements.lastPage, "click", () => this.goToLastPage());
1374			on(this.elements.zoomIn, "click", () => this.zoomIn());
1375			on(this.elements.zoomOut, "click", () => this.zoomOut());
1376			on(this.elements.resetZoom, "click", () => this.resetZoom());
1377			on(this.elements.fullscreen, "click", () => this.toggleFullscreen());
1378
1379			on(this.elements.pageForm, "submit", event => {
1380				event.preventDefault();
1381				/*
1382				 * Direkt springen (ohne Flip-Animation): zuverlässiger bei
1383				 * Seitensprüngen und vermeidet Doppel-flip durch change+submit.
1384				 */
1385				this.goToPageNumber(this.elements.pageNumber.value, false);
1386				this.elements.pageNumber.select();
1387			});
1388
1389			on(this.elements.pageNumber, "change", () => {
1390				this.goToPageNumber(this.elements.pageNumber.value, false);
1391			});
1392
1393			on(this.elements.pageNumber, "focus", () => {
1394				this.elements.pageNumber.select();
1395			});
1396
1397			this.boundHandlers.pointerDown = event => this.handlePointerDown(event);
1398			this.boundHandlers.pointerMove = event => this.handlePointerMove(event);
1399			this.boundHandlers.pointerEnd = event => this.handlePointerEnd(event);
1400			this.boundHandlers.wheel = event => this.handleWheelZoom(event);
1401			this.boundHandlers.keyboard = event => this.handleKeyboard(event);
1402			this.boundHandlers.fullscreen = () => this.updateFullscreenButton();
1403			this.boundHandlers.resize = () => this.scheduleRefreshBookLayout();
1404			this.boundHandlers.hashchange = () => {
1405				if (!this.updateHash) {
1406					return;
1407				}
1408
1409				const pageNumber = this.getPageNumberFromHash();
1410
1411				if (pageNumber !== this.getCurrentPageNumber()) {
1412					this.goToPageNumber(pageNumber, false);
1413				}
1414			};
1415
1416			on(
1417				this.elements.bookArea,
1418				"pointerdown",
1419				this.boundHandlers.pointerDown
1420			);
1421			on(
1422				this.elements.bookArea,
1423				"pointermove",
1424				this.boundHandlers.pointerMove,
1425				{ passive: false }
1426			);
1427			on(
1428				this.elements.bookArea,
1429				"pointerup",
1430				this.boundHandlers.pointerEnd
1431			);
1432			on(
1433				this.elements.bookArea,
1434				"pointercancel",
1435				this.boundHandlers.pointerEnd
1436			);
1437			on(
1438				this.elements.bookArea,
1439				"wheel",
1440				this.boundHandlers.wheel,
1441				{ passive: false }
1442			);
1443
1444			document.addEventListener(
1445				"fullscreenchange",
1446				this.boundHandlers.fullscreen
1447			);
1448			document.addEventListener(
1449				"keydown",
1450				this.boundHandlers.keyboard
1451			);
1452			window.addEventListener("resize", this.boundHandlers.resize);
1453			window.addEventListener(
1454				"orientationchange",
1455				this.boundHandlers.resize
1456			);
1457			window.addEventListener(
1458				"hashchange",
1459				this.boundHandlers.hashchange
1460			);
1461		}
1462
1463		destroy() {
1464			window.clearTimeout(this.statusTimer);
1465			window.clearTimeout(this.resizeTimer);
1466			window.clearTimeout(this.backgroundPrimeTimer);
1467
1468			if (
1469				this.backgroundPrimeIdle !== null &&
1470				typeof window.cancelIdleCallback === "function"
1471			) {
1472				window.cancelIdleCallback(this.backgroundPrimeIdle);
1473			}
1474
1475			cancelAnimationFrame(this.resizeFrame);
1476			cancelAnimationFrame(this.toolbarHeavyFrame);
1477			cancelAnimationFrame(this.zoomBoundFrame);
1478			this.zoomBoundFrame = null;
1479
1480			document.removeEventListener(
1481				"fullscreenchange",
1482				this.boundHandlers.fullscreen
1483			);
1484			document.removeEventListener(
1485				"keydown",
1486				this.boundHandlers.keyboard
1487			);
1488			window.removeEventListener("resize", this.boundHandlers.resize);
1489			window.removeEventListener(
1490				"orientationchange",
1491				this.boundHandlers.resize
1492			);
1493			window.removeEventListener(
1494				"hashchange",
1495				this.boundHandlers.hashchange
1496			);
1497
1498			if (this.pageFlip) {
1499				try {
1500					this.pageFlip.destroy();
1501				} catch {
1502					// ignore library cleanup errors
1503				}
1504
1505				this.pageFlip = null;
1506			}
1507		}
1508	}
1509
1510	const MeranerPageFlip = {
1511		instance: null,
1512		lightboxHost: null,
1513		lightboxInstance: null,
1514		assetsPromise: null,
1515
1516		async ensureAssets() {
1517			if (typeof St !== "undefined" && typeof St.PageFlip === "function") {
1518				return;
1519			}
1520
1521			if (this.assetsPromise) {
1522				return this.assetsPromise;
1523			}
1524
1525			this.assetsPromise = new Promise((resolve, reject) => {
1526				const existing = document.querySelector(
1527					'script[src="/js/page-flip.browser.js"]'
1528				);
1529
1530				if (existing) {
1531					existing.addEventListener("load", resolve, { once: true });
1532					existing.addEventListener(
1533						"error",
1534						() => reject(new Error("StPageFlip konnte nicht geladen werden.")),
1535						{ once: true }
1536					);
1537					return;
1538				}
1539
1540				const script = document.createElement("script");
1541				script.src = "/js/page-flip.browser.js";
1542				script.onload = resolve;
1543				script.onerror = () =>
1544					reject(new Error("StPageFlip konnte nicht geladen werden."));
1545				document.head.appendChild(script);
1546			});
1547
1548			return this.assetsPromise;
1549		},
1550
1551		ensureStyles() {
1552			if (document.querySelector('link[href="/css/page-flip.meraner.css"]')) {
1553				return;
1554			}
1555
1556			const link = document.createElement("link");
1557			link.rel = "stylesheet";
1558			link.href = "/css/page-flip.meraner.css";
1559			document.head.appendChild(link);
1560		},
1561
1562		async init(root = document.getElementById("viewer"), options = {}) {
1563			if (!root) {
1564				throw new Error("Viewer-Root nicht gefunden.");
1565			}
1566
1567			this.ensureStyles();
1568			await this.ensureAssets();
1569
1570			if (this.instance) {
1571				this.instance.destroy();
1572				this.instance = null;
1573			}
1574
1575			this.instance = new MeranerPageFlipInstance(root, {
1576				mode: "page",
1577				updateHash: true,
1578				fitToViewport: true,
1579				...options
1580			});
1581
1582			await this.instance.init();
1583			return this.instance;
1584		},
1585
1586		collectPagesFromTrigger(trigger) {
1587			const source = trigger.querySelector(".pageflip-source, #book1, [data-pageflip-source]");
1588
1589			if (!source) {
1590				return [];
1591			}
1592
1593			return Array.from(
1594				source.querySelectorAll("[data-src], [data-image], img[src]")
1595			).map((node, index) => {
1596				const src =
1597					node.getAttribute("data-src") ||
1598					node.getAttribute("data-image") ||
1599					node.getAttribute("src");
1600
1601				return {
1602					src: src.startsWith("//")
1603						? `${window.location.protocol}${src}`
1604						: src,
1605					page: Number.parseInt(
1606						node.getAttribute("data-page") || String(index + 1),
1607						10
1608					)
1609				};
1610			}).filter(page => Boolean(page.src));
1611		},
1612
1613		buildLightboxHost() {
1614			if (this.lightboxHost) {
1615				this.lightboxHost.remove();
1616				this.lightboxHost = null;
1617			}
1618
1619			const host = document.createElement("div");
1620			host.className = "pageflip-lightbox";
1621			host.id = "pageflip-lightbox";
1622			host.innerHTML = `
1623				<div class="pageflip-lightbox__overlay" data-pageflip-dismiss></div>
1624				<div class="pageflip-lightbox__panel">
1625					<button type="button" class="pageflip-lightbox__close" data-pageflip-dismiss title="Schließen" aria-label="Blätterzeitung schließen">
1626						${ICON_CLOSE}
1627					</button>
1628					${createViewerMarkup()}
1629				</div>
1630			`;
1631
1632			document.body.appendChild(host);
1633			this.lightboxHost = host;
1634
1635			host.querySelectorAll("[data-pageflip-dismiss]").forEach(node => {
1636				node.addEventListener("click", () => this.closeLightbox());
1637			});
1638
1639			return host;
1640		},
1641
1642		async openLightbox(pagesOrTrigger) {
1643			this.ensureStyles();
1644			await this.ensureAssets();
1645
1646			const pages = Array.isArray(pagesOrTrigger)
1647				? pagesOrTrigger
1648				: this.collectPagesFromTrigger(pagesOrTrigger);
1649
1650			if (!pages.length) {
1651				console.error("Keine Seiten für die Lightbox gefunden.");
1652				return null;
1653			}
1654
1655			if (this.lightboxInstance) {
1656				this.lightboxInstance.destroy();
1657				this.lightboxInstance = null;
1658			}
1659
1660			const host = this.buildLightboxHost();
1661			const root = host.querySelector("[data-pageflip-root]");
1662
1663			host.classList.add("is-open");
1664			document.body.classList.add("pageflip-lightbox-on");
1665
1666			this.lightboxInstance = new MeranerPageFlipInstance(root, {
1667				mode: "lightbox",
1668				updateHash: false,
1669				fitToViewport: false
1670			});
1671
1672			await this.lightboxInstance.init(pages);
1673			this.lightboxInstance.refreshBookLayout();
1674			return this.lightboxInstance;
1675		},
1676
1677		closeLightbox() {
1678			if (this.lightboxInstance) {
1679				this.lightboxInstance.destroy();
1680				this.lightboxInstance = null;
1681			}
1682
1683			if (this.lightboxHost) {
1684				this.lightboxHost.remove();
1685				this.lightboxHost = null;
1686			}
1687
1688			document.body.classList.remove("pageflip-lightbox-on");
1689
1690			if (document.fullscreenElement) {
1691				document.exitFullscreen().catch(() => {});
1692			}
1693		},
1694
1695		bindTriggers(selector = "[data-pageflip-trigger], #book1-trigger, #book2-trigger, #book3-trigger") {
1696			document.querySelectorAll(selector).forEach(trigger => {
1697				if (trigger.dataset.pageflipBound === "1") {
1698					return;
1699				}
1700
1701				const pages = this.collectPagesFromTrigger(trigger);
1702
1703				if (!pages.length) {
1704					return;
1705				}
1706
1707				trigger.dataset.pageflipBound = "1";
1708				trigger.classList.add("pageflip-trigger");
1709
1710				trigger.addEventListener("click", event => {
1711					event.preventDefault();
1712					this.openLightbox(pages);
1713				});
1714			});
1715		}
1716	};
1717
1718	window.MeranerPageFlip = MeranerPageFlip;
1719
1720	async function bootPageMode() {
1721		const root = document.getElementById("viewer");
1722
1723		if (!root || root.dataset.pageflipMode === "lightbox-only") {
1724			return;
1725		}
1726
1727		const book = root.querySelector("#book, [data-book]");
1728
1729		if (!book || !book.querySelector(".book-page")) {
1730			return;
1731		}
1732
1733		try {
1734			await MeranerPageFlip.init(root);
1735		} catch (error) {
1736			console.error(error);
1737
1738			const loading = root.querySelector("#loading, [data-loading]");
1739
1740			if (loading) {
1741				loading.hidden = false;
1742				loading.innerHTML = `
1743					<div class="loading__box">
1744						Die Ausgabe konnte nicht geladen werden.<br>
1745						${error.message}
1746					</div>
1747				`;
1748			}
1749		}
1750	}
1751
1752	document.addEventListener("DOMContentLoaded", () => {
1753		MeranerPageFlip.bindTriggers();
1754		bootPageMode();
1755	});
1756
1757	window.addEventListener("load", () => {
1758		MeranerPageFlip.instance?.refreshBookLayout();
1759		MeranerPageFlip.lightboxInstance?.refreshBookLayout();
1760	});
1761})(window, document);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.