diff --git a/contao/dca/tl_content.php b/contao/dca/tl_content.php index 02f9ab9..a74ed33 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,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,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'], @@ -32,6 +32,14 @@ $GLOBALS['TL_DCA']['tl_content']['fields']['flipbookStartMode'] = [ 'sql' => ['type' => 'string', 'length' => 16, 'default' => 'center'], ]; +$GLOBALS['TL_DCA']['tl_content']['fields']['flipbookSplitSpreads'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookSplitSpreads'], + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50 m12'], + 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => '0'], +]; + $GLOBALS['TL_DCA']['tl_content']['fields']['flipbookShowNavigation'] = [ 'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'], 'exclude' => true, diff --git a/contao/languages/de/tl_content.php b/contao/languages/de/tl_content.php index f7d31d6..1565bb4 100644 --- a/contao/languages/de/tl_content.php +++ b/contao/languages/de/tl_content.php @@ -15,5 +15,6 @@ $GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [ 'center' => 'Zentriert starten', 'spread' => 'als Doppelseite starten', ]; +$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-/Zurueck-Buttons unter dem Flipbook an.']; $GLOBALS['TL_LANG']['tl_content']['flipbookPlaySound'] = ['Blättersound abspielen', 'Spielt beim Blättern einen Sound ab.']; diff --git a/contao/languages/en/tl_content.php b/contao/languages/en/tl_content.php index db5fbb6..a8bd443 100644 --- a/contao/languages/en/tl_content.php +++ b/contao/languages/en/tl_content.php @@ -15,5 +15,6 @@ $GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [ 'center' => 'Start centered', 'spread' => 'Start with double-page spread', ]; +$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.']; diff --git a/contao/templates/content_element/blatterbares_pdf.html.twig b/contao/templates/content_element/blatterbares_pdf.html.twig index 11101f4..290a7b6 100644 --- a/contao/templates/content_element/blatterbares_pdf.html.twig +++ b/contao/templates/content_element/blatterbares_pdf.html.twig @@ -6,6 +6,7 @@ data-pdf-url="{{ pdfUrl|default('')|e('html_attr') }}" data-initial-pages="{{ initialRenderPages|default(4)|e('html_attr') }}" data-start-mode="{{ startMode|default('center')|e('html_attr') }}" + data-split-spreads="{{ splitSpreads ? '1' : '0' }}" data-show-navigation="{{ showNavigation ? '1' : '0' }}" data-play-turn-sound="{{ ((playTurnSound is defined) ? playTurnSound : true) ? '1' : '0' }}" tabindex="0" diff --git a/public/assets/flipbook-module.js b/public/assets/flipbook-module.js index fbb0f5c..387b1ed 100644 --- a/public/assets/flipbook-module.js +++ b/public/assets/flipbook-module.js @@ -41,6 +41,7 @@ class PdfFlipbookModule { 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.playTurnSoundEnabled = root.dataset.playTurnSound !== '0'; this.startMode = (root.dataset.startMode === 'spread' || root.dataset.startMode === 'cover') @@ -67,7 +68,9 @@ class PdfFlipbookModule { this.pageWidth = 0; this.pageHeight = 0; this.pageGap = 2; + this.sourcePageCount = 0; this.totalPages = 0; + this.pageDescriptors = []; this.instanceId = `pdf-flipbook-${++moduleCounter}`; } @@ -136,6 +139,8 @@ class PdfFlipbookModule { if (this.controlsElement) { this.controlsElement.style.opacity = '1'; } + + this.updateNavigationState(); }); } @@ -147,19 +152,84 @@ class PdfFlipbookModule { }); this.pdf = await loadingTask.promise; - this.totalPages = Number(this.pdf.numPages || 0); + this.sourcePageCount = Number(this.pdf.numPages || 0); - if (this.totalPages <= 0) { + if (this.sourcePageCount <= 0) { throw new Error('The selected PDF has no pages.'); } + + await this.buildPageDescriptors(); + this.totalPages = this.pageDescriptors.length; + } + + async buildPageDescriptors() { + const descriptors = []; + + for (let sourcePageNumber = 1; sourcePageNumber <= this.sourcePageCount; sourcePageNumber += 1) { + const page = await this.pdf.getPage(sourcePageNumber); + const viewport = page.getViewport({ scale: 1 }); + + if (this.shouldSplitSpread(sourcePageNumber, viewport)) { + descriptors.push({ + sourcePageNumber, + segment: 'left', + }); + descriptors.push({ + sourcePageNumber, + segment: 'right', + }); + } else { + descriptors.push({ + sourcePageNumber, + segment: 'full', + }); + } + } + + 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 resolveInitialAspectRatio() { - const firstPage = await this.pdf.getPage(1); - const viewport = firstPage.getViewport({ scale: 1 }); + const firstDescriptor = this.getDescriptor(1); - if (viewport.width > 0 && viewport.height > 0) { - this.aspectRatio = viewport.height / viewport.width; + if (!firstDescriptor) { + return; + } + + const firstPage = await this.pdf.getPage(firstDescriptor.sourcePageNumber); + const viewport = firstPage.getViewport({ scale: 1 }); + const divisor = firstDescriptor.segment === 'full' ? 1 : 2; + const width = viewport.width / divisor; + + if (width > 0 && viewport.height > 0) { + this.aspectRatio = viewport.height / width; } } @@ -231,8 +301,42 @@ class PdfFlipbookModule { onPageTurn: () => { this.playTurnSound(); this.queuePages(this.getLazyCandidates()); + this.updateNavigationState(); }, }); + + window.requestAnimationFrame(() => { + this.updateNavigationState(); + }); + } + + updateNavigationState() { + if (!this.showNavigation || !this.prevButton || !this.nextButton) { + return; + } + + const activePages = Array.from(this.bookElement.querySelectorAll('.c-flipbook__page.is-active')) + .map((element) => Number(element.dataset.pageNumber || 0)) + .filter((pageNumber) => Number.isInteger(pageNumber) && pageNumber > 0); + + 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'); } createTurnSound() { @@ -449,14 +553,18 @@ class PdfFlipbookModule { } const pageElement = this.pageElements.get(pageNumber); + const descriptor = this.getDescriptor(pageNumber); - if (!pageElement) { + if (!pageElement || !descriptor) { return; } - const page = await this.pdf.getPage(pageNumber); + const page = await this.pdf.getPage(descriptor.sourcePageNumber); const viewportAtScale1 = page.getViewport({ scale: 1 }); - const scale = this.pageWidth / viewportAtScale1.width; + 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.max(1, devicePixelRatio); @@ -473,19 +581,59 @@ class PdfFlipbookModule { throw new Error('Could not create canvas 2D context.'); } - canvas.width = Math.floor(viewport.width * outputScale); + 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 = `${Math.floor(viewport.width)}px`; + canvas.style.width = `${canvasCssWidth}px`; canvas.style.height = `${Math.floor(viewport.height)}px`; - const renderTask = page.render({ - canvasContext: context, - viewport, - transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0], - intent: 'display', - }); + if (descriptor.segment === 'full') { + const renderTask = page.render({ + canvasContext: context, + viewport, + transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0], + intent: 'display', + }); - await renderTask.promise; + await renderTask.promise; + } else { + const spreadCanvas = document.createElement('canvas'); + const spreadContext = spreadCanvas.getContext('2d', { alpha: false }); + + if (!spreadContext) { + throw new Error('Could not create split canvas context.'); + } + + spreadCanvas.width = Math.floor(viewport.width * outputScale); + spreadCanvas.height = Math.floor(viewport.height * outputScale); + + const spreadRenderTask = page.render({ + canvasContext: spreadContext, + viewport, + transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0], + intent: 'display', + }); + + await spreadRenderTask.promise; + + const halfWidth = Math.floor(spreadCanvas.width / 2); + const sourceX = descriptor.segment === 'left' ? 0 : spreadCanvas.width - halfWidth; + + context.drawImage( + spreadCanvas, + sourceX, + 0, + halfWidth, + spreadCanvas.height, + 0, + 0, + canvas.width, + canvas.height, + ); + } const loader = pageElement.querySelector('.mod-pdf-flipbook__page-loader'); diff --git a/src/Controller/ContentElement/BlatterbaresPdfController.php b/src/Controller/ContentElement/BlatterbaresPdfController.php index 7932d70..102d23c 100644 --- a/src/Controller/ContentElement/BlatterbaresPdfController.php +++ b/src/Controller/ContentElement/BlatterbaresPdfController.php @@ -20,6 +20,7 @@ class BlatterbaresPdfController extends AbstractContentElementController $template->set('pdfUrl', $this->resolvePdfUrl($model)); $template->set('showNavigation', '1' === (string) ($model->flipbookShowNavigation ?? '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'))); $template->set('startMode', $this->normalizeStartMode((string) ($model->flipbookStartMode ?? 'center')));