diff --git a/README.md b/README.md index ec76e6e..0fb5a46 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/contao/dca/tl_content.php b/contao/dca/tl_content.php index a74ed33..e931eb2 100644 --- a/contao/dca/tl_content.php +++ b/contao/dca/tl_content.php @@ -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, diff --git a/contao/languages/de/default.php b/contao/languages/de/default.php index 0093d77..06d2341 100644 --- a/contao/languages/de/default.php +++ b/contao/languages/de/default.php @@ -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'; diff --git a/contao/languages/de/tl_content.php b/contao/languages/de/tl_content.php index 0a8d446..bef4c70 100644 --- a/contao/languages/de/tl_content.php +++ b/contao/languages/de/tl_content.php @@ -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.']; diff --git a/contao/languages/en/default.php b/contao/languages/en/default.php index 083ad63..2e606f0 100644 --- a/contao/languages/en/default.php +++ b/contao/languages/en/default.php @@ -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'; diff --git a/contao/languages/en/tl_content.php b/contao/languages/en/tl_content.php index a8bd443..230f79d 100644 --- a/contao/languages/en/tl_content.php +++ b/contao/languages/en/tl_content.php @@ -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.']; diff --git a/contao/templates/content_element/blatterbares_pdf.html.twig b/contao/templates/content_element/blatterbares_pdf.html.twig index a5c3113..09408e4 100644 --- a/contao/templates/content_element/blatterbares_pdf.html.twig +++ b/contao/templates/content_element/blatterbares_pdf.html.twig @@ -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 @@
- {% if showNavigation %} + {% if showNavigation or showFullscreen %} {% endif %} diff --git a/public/assets/flipbook-module.css b/public/assets/flipbook-module.css index db15c64..a4638a1 100644 --- a/public/assets/flipbook-module.css +++ b/public/assets/flipbook-module.css @@ -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. */ diff --git a/public/assets/flipbook-module.js b/public/assets/flipbook-module.js index b792333..6cc4759 100644 --- a/public/assets/flipbook-module.js +++ b/public/assets/flipbook-module.js @@ -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,35 +587,7 @@ class PdfFlipbookModule { bindResize() { const onResize = () => { window.clearTimeout(this.resizeTimer); - this.resizeTimer = window.setTimeout(() => { - 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); - }, 120); + this.resizeTimer = window.setTimeout(() => this.relayout(), 120); }; if ('ResizeObserver' in window) { @@ -595,6 +598,132 @@ class PdfFlipbookModule { } } + /** + * 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; @@ -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(); diff --git a/src/Controller/ContentElement/BlatterbaresPdfController.php b/src/Controller/ContentElement/BlatterbaresPdfController.php index 4395352..567bf6f 100644 --- a/src/Controller/ContentElement/BlatterbaresPdfController.php +++ b/src/Controller/ContentElement/BlatterbaresPdfController.php @@ -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')));