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>
This commit is contained in:
Jürgen Mummert
2026-08-05 15:12:10 +02:00
co-authored by Claude Opus 5
parent 24d5606b26
commit 204fee73f5
10 changed files with 237 additions and 37 deletions
+12
View File
@@ -12,6 +12,7 @@ Contao Inhaltselement zum Darstellen von PDFs als blätterbares Flipbook.
mehrhundertseitigen PDFs beschränkt bleibt
- Optionales Aufteilen von Doppelseiten in linke und rechte Einzelseite
- Optionale Vor-/Zurück-Navigation, optionaler Blättersound
- Optionaler Vollbildmodus, der die Seitengröße an die Bildschirmhöhe anpasst
- Tastatursteuerung (Pfeiltasten) und Touch-Swipe
- Responsive Breite mit beibehaltenem Seitenverhältnis
- Frontend-Texte übersetzbar (DE/EN mitgeliefert)
@@ -33,6 +34,7 @@ Felder:
- `Startmodus` (zentriert oder als Doppelseite)
- `Doppelseiten aufteilen` (optional)
- `Navigation anzeigen` (optional)
- `Vollbild-Schaltfläche anzeigen` (optional)
- `Blättersound abspielen` (optional)
## Hinweise
@@ -58,6 +60,16 @@ unter dem Flipbook einen Download-Link auf die Original-PDF ein — das ist die
gleichwertige Alternative im Sinne von WCAG. Wird der Link per CSS ausgeblendet,
entfällt diese Gleichwertigkeit.
### Vollbild
Die Schaltfläche wird erst eingeblendet, wenn der Browser die Fullscreen-API für
normale Elemente anbietet. Safari auf dem iPhone gehört nicht dazu — dort bleibt
sie verborgen, statt eine Funktion anzubieten, die nicht arbeiten kann.
Im Vollbild wird die Seitenhöhe an den Bildschirm angepasst und das Buch
zentriert; ohne das wäre eine A4-Seite bei Bildschirmbreite deutlich höher als
der Viewport. Die Seiten werden dabei in der neuen Größe neu gerendert.
### Geschützte Ordner
Contao liefert Dateien unter `files/` nur aus, wenn der Ordner öffentlich
+9 -1
View File
@@ -2,7 +2,7 @@
declare(strict_types=1);
$GLOBALS['TL_DCA']['tl_content']['palettes']['blatterbares_pdf'] = '{type_legend},type,headline;{flipbook_legend},flipbookPdfSrc,flipbookInitialPages,flipbookStartMode,flipbookSplitSpreads,flipbookShowNavigation,flipbookPlaySound;{template_legend:hide},customTpl;{protected_legend:hide},protected;{expert_legend:hide},cssID;{invisible_legend:hide},invisible,start,stop';
$GLOBALS['TL_DCA']['tl_content']['palettes']['blatterbares_pdf'] = '{type_legend},type,headline;{flipbook_legend},flipbookPdfSrc,flipbookInitialPages,flipbookStartMode,flipbookSplitSpreads,flipbookShowNavigation,flipbookShowFullscreen,flipbookPlaySound;{template_legend:hide},customTpl;{protected_legend:hide},protected;{expert_legend:hide},cssID;{invisible_legend:hide},invisible,start,stop';
$GLOBALS['TL_DCA']['tl_content']['fields']['flipbookPdfSrc'] = [
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookPdfSrc'],
@@ -48,6 +48,14 @@ $GLOBALS['TL_DCA']['tl_content']['fields']['flipbookShowNavigation'] = [
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => '1'],
];
$GLOBALS['TL_DCA']['tl_content']['fields']['flipbookShowFullscreen'] = [
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookShowFullscreen'],
'exclude' => true,
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => '1'],
];
$GLOBALS['TL_DCA']['tl_content']['fields']['flipbookPlaySound'] = [
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookPlaySound'],
'exclude' => true,
+2
View File
@@ -17,3 +17,5 @@ $GLOBALS['TL_LANG']['MSC']['flipbookNavLabel'] = 'Flipbook-Navigation';
$GLOBALS['TL_LANG']['MSC']['flipbookPrev'] = 'Zurück';
$GLOBALS['TL_LANG']['MSC']['flipbookNext'] = 'Weiter';
$GLOBALS['TL_LANG']['MSC']['flipbookDownload'] = 'PDF herunterladen';
$GLOBALS['TL_LANG']['MSC']['flipbookFullscreenEnter'] = 'Vollbild';
$GLOBALS['TL_LANG']['MSC']['flipbookFullscreenExit'] = 'Vollbild beenden';
+2 -1
View File
@@ -17,4 +17,5 @@ $GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [
];
$GLOBALS['TL_LANG']['tl_content']['flipbookSplitSpreads'] = ['Doppelseiten aufteilen', 'Teilt breite PDF-Seiten ab Seite 2 automatisch in linke und rechte Einzelseite.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'] = ['Navigation anzeigen', 'Zeigt Vor-/Zurück-Buttons unter dem Flipbook an.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookPlaySound'] = ['Blättersound abspielen', 'Spielt beim Blättern einen Sound ab.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookShowFullscreen'] = ['Vollbild-Schaltfläche anzeigen', 'Zeigt eine Schaltfläche, mit der das Flipbook bildschirmfüllend dargestellt wird.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookPlaySound'] =['Blättersound abspielen', 'Spielt beim Blättern einen Sound ab.'];
+2
View File
@@ -17,3 +17,5 @@ $GLOBALS['TL_LANG']['MSC']['flipbookNavLabel'] = 'Flipbook navigation';
$GLOBALS['TL_LANG']['MSC']['flipbookPrev'] = 'Previous';
$GLOBALS['TL_LANG']['MSC']['flipbookNext'] = 'Next';
$GLOBALS['TL_LANG']['MSC']['flipbookDownload'] = 'Download PDF';
$GLOBALS['TL_LANG']['MSC']['flipbookFullscreenEnter'] = 'Fullscreen';
$GLOBALS['TL_LANG']['MSC']['flipbookFullscreenExit'] = 'Exit fullscreen';
+2 -1
View File
@@ -17,4 +17,5 @@ $GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [
];
$GLOBALS['TL_LANG']['tl_content']['flipbookSplitSpreads'] = ['Split double-page spreads', 'Automatically splits wide PDF pages from page 2 onwards into left and right single pages.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'] = ['Show navigation', 'Displays previous/next buttons below the flipbook.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookPlaySound'] = ['Play page-turn sound', 'Plays a sound effect while turning pages.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookShowFullscreen'] = ['Show fullscreen button', 'Displays a button that expands the flipbook to fill the screen.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookPlaySound'] =['Play page-turn sound', 'Plays a sound effect while turning pages.'];
@@ -10,6 +10,7 @@
data-start-mode="{{ startMode|default('center')|e('html_attr') }}"
data-split-spreads="{{ splitSpreads ? '1' : '0' }}"
data-show-navigation="{{ showNavigation ? '1' : '0' }}"
data-show-fullscreen="{{ showFullscreen ? '1' : '0' }}"
data-play-turn-sound="{{ ((playTurnSound is defined) ? playTurnSound : true) ? '1' : '0' }}"
data-i18n-loading="{{ 'MSC.flipbookLoading'|trans([], 'contao_default')|e('html_attr') }}"
data-i18n-error="{{ 'MSC.flipbookError'|trans([], 'contao_default')|e('html_attr') }}"
@@ -17,6 +18,8 @@
data-i18n-rendering="{{ 'MSC.flipbookRendering'|trans([], 'contao_default')|e('html_attr') }}"
data-i18n-blank="{{ 'MSC.flipbookBlank'|trans([], 'contao_default')|e('html_attr') }}"
data-i18n-page-label="{{ 'MSC.flipbookPageLabel'|trans([], 'contao_default')|e('html_attr') }}"
data-i18n-fullscreen-enter="{{ 'MSC.flipbookFullscreenEnter'|trans([], 'contao_default')|e('html_attr') }}"
data-i18n-fullscreen-exit="{{ 'MSC.flipbookFullscreenExit'|trans([], 'contao_default')|e('html_attr') }}"
role="region"
aria-label="{{ 'MSC.flipbookRegionLabel'|trans([], 'contao_default')|e('html_attr') }}"
tabindex="0"
@@ -27,10 +30,16 @@
<div class="c-flipbook" data-flipbook-book="1"></div>
</div>
{% if showNavigation %}
{% if showNavigation or showFullscreen %}
<div class="mod-pdf-flipbook__controls" data-flipbook-controls="1" role="group" aria-label="{{ 'MSC.flipbookNavLabel'|trans([], 'contao_default')|e('html_attr') }}" style="visibility:hidden;opacity:0">
{% if showNavigation %}
<button type="button" data-flipbook-prev="1">{{ 'MSC.flipbookPrev'|trans([], 'contao_default') }}</button>
<button type="button" data-flipbook-next="1">{{ 'MSC.flipbookNext'|trans([], 'contao_default') }}</button>
{% endif %}
{% if showFullscreen %}
{# Hidden until the module confirms the browser exposes the Fullscreen API. #}
<button type="button" class="mod-pdf-flipbook__fullscreen" data-flipbook-fullscreen="1" aria-pressed="false" hidden>{{ 'MSC.flipbookFullscreenEnter'|trans([], 'contao_default') }}</button>
{% endif %}
</div>
{% endif %}
+28
View File
@@ -114,6 +114,34 @@
color: #9f1f1f;
}
/* Set by the module once the browser confirms the Fullscreen API is usable. */
.mod-pdf-flipbook.is-fullscreen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100vw;
height: 100vh;
padding: 1.5rem;
overflow: hidden;
}
.mod-pdf-flipbook.is-fullscreen .mod-pdf-flipbook__stage {
width: 100%;
margin: 0;
}
.mod-pdf-flipbook.is-fullscreen .mod-pdf-flipbook__status {
margin-bottom: 0;
}
/* The equivalent alternative stays on the page itself; repeating it inside the
viewer would only eat height the book can use. */
.mod-pdf-flipbook.is-fullscreen .mod-pdf-flipbook__download {
display: none;
}
/* Text in a canvas is invisible to assistive technology and cannot be selected
or searched, so the original file stays reachable as an equivalent
alternative. */
+148 -12
View File
@@ -34,6 +34,8 @@ const DEFAULT_TEXTS = {
rendering: 'Seite {page} wird gerendert ...',
blank: 'Leere Seite',
pageLabel: 'Seite {page} von {total}',
fullscreenEnter: 'Vollbild',
fullscreenExit: 'Vollbild beenden',
};
let dependenciesPromise;
@@ -78,6 +80,7 @@ class PdfFlipbookModule {
: 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'
@@ -89,6 +92,8 @@ class PdfFlipbookModule {
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"]');
@@ -96,6 +101,7 @@ class PdfFlipbookModule {
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;
@@ -153,6 +159,7 @@ class PdfFlipbookModule {
this.bindKeyboard();
this.bindTouchSwipe();
this.bindResize();
this.setupFullscreen();
this.queuePages(this.getLazyCandidates());
await this.showReadyState();
this.setStatus('');
@@ -372,10 +379,34 @@ class PdfFlipbookModule {
}
updateLayout() {
const stageWidth = Math.max(this.stage.clientWidth || 0, 282);
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`;
@@ -556,7 +587,22 @@ class PdfFlipbookModule {
bindResize() {
const onResize = () => {
window.clearTimeout(this.resizeTimer);
this.resizeTimer = window.setTimeout(() => {
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;
}
@@ -569,8 +615,8 @@ class PdfFlipbookModule {
return;
}
// Invalidate everything rendered for the old page size, including
// a render that may still be in flight.
// Invalidate everything rendered for the old page size, including a
// render that may still be in flight.
this.renderGeneration += 1;
this.cancelActiveRender();
@@ -584,15 +630,98 @@ class PdfFlipbookModule {
});
this.queuePages(rerender, true);
}, 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 });
}
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() {
@@ -868,6 +997,13 @@ class PdfFlipbookModule {
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();
@@ -20,7 +20,7 @@ class BlatterbaresPdfController extends AbstractContentElementController
/**
* Bumped when the shipped assets change, so browsers do not keep a stale copy.
*/
private const ASSET_VERSION = '2.0.0';
private const ASSET_VERSION = '2.1.0';
public function __construct(
#[Autowire('%kernel.project_dir%')]
@@ -35,6 +35,7 @@ class BlatterbaresPdfController extends AbstractContentElementController
$template->set('pdfUrl', $this->resolvePdfUrl($model));
$template->set('showNavigation', '1' === (string) ($model->flipbookShowNavigation ?? '1'));
$template->set('showFullscreen', '1' === (string) ($model->flipbookShowFullscreen ?? '1'));
$template->set('playTurnSound', '1' === (string) ($model->flipbookPlaySound ?? '1'));
$template->set('splitSpreads', '1' === (string) ($model->flipbookSplitSpreads ?? '0'));
$template->set('initialRenderPages', $this->normalizeInitialRenderPages((string) ($model->flipbookInitialPages ?? '4')));