feat: refine flipbook start modes and UI behavior
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Mummert
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Flipbook Bundle (Contao 5.7)
|
||||
|
||||
Contao Inhaltselement zum Darstellen von PDFs als blaetterbares Flipbook.
|
||||
|
||||
## Features
|
||||
|
||||
- Reines JavaScript (ES Modules)
|
||||
- `pdfjs-dist` fuer PDF-Rendering in `<canvas>`
|
||||
- `flipbook-js` fuer den Blaettereffekt
|
||||
- Lazy Rendering: 2-4 Seiten sofort, Rest on-demand
|
||||
- Optional Vor-/Zurueck-Navigation im Modul
|
||||
- Tastatursteuerung (Pfeiltasten) und Touch-Swipe
|
||||
- Responsive Breite mit beibehaltenem Seitenverhaeltnis
|
||||
|
||||
## Installation (VCS oder Paket)
|
||||
|
||||
```bash
|
||||
composer require mummert/flipbook-bundle
|
||||
```
|
||||
|
||||
## Inhaltselement im Backend
|
||||
|
||||
Elementtyp: `Blätterbares PDF`
|
||||
|
||||
Felder:
|
||||
|
||||
- `PDF-Datei` (singleSRC / UUID)
|
||||
- `Initial geladene Seiten` (2, 3 oder 4)
|
||||
- `Startmodus` (zentriert oder als Doppelseite)
|
||||
- `Navigation anzeigen` (optional)
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Das Inhaltselement rendert PDF-Seiten als `<canvas>` innerhalb von `.c-flipbook__page`.
|
||||
- Die benoetigten Open-Source-Dateien sind lokal im Bundle enthalten.
|
||||
- Siehe `THIRD_PARTY_LICENSES.md` fuer Abhaengigkeiten.
|
||||
|
||||
## Release
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# Third-Party Licenses
|
||||
|
||||
This bundle contains the following third-party open source libraries in `public/assets/vendor/`:
|
||||
|
||||
## pdfjs-dist
|
||||
|
||||
- Project: https://github.com/mozilla/pdf.js
|
||||
- Package: `pdfjs-dist`
|
||||
- Version: `4.9.155`
|
||||
- License: Apache-2.0
|
||||
|
||||
## flipbook-js
|
||||
|
||||
- Project: https://github.com/taufiqelrahman/flipbook-js
|
||||
- Package: `flipbook-js`
|
||||
- Version: `1.1.1`
|
||||
- License: MIT
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "mummert/flipbook-bundle",
|
||||
"description": "PDF flipbook content element for Contao 5.7 using pdfjs-dist and flipbook-js.",
|
||||
"type": "contao-bundle",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"contao",
|
||||
"contao-bundle",
|
||||
"pdf",
|
||||
"flipbook"
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"contao/core-bundle": "^5.7",
|
||||
"contao/manager-plugin": "^2.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Mummert\\FlipbookBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"contao-manager-plugin": "Mummert\\FlipbookBundle\\Contao\\Manager\\Plugin"
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"contao-components/installer": true,
|
||||
"contao/manager-plugin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
Mummert\FlipbookBundle\:
|
||||
resource: ../src/
|
||||
exclude:
|
||||
- ../src/DependencyInjection/
|
||||
- ../src/Contao/Manager/
|
||||
- ../src/FlipbookBundle.php
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_content']['palettes']['blatterbares_pdf'] = '{type_legend},type,headline;{flipbook_legend},flipbookPdfSrc,flipbookInitialPages,flipbookStartMode,flipbookShowNavigation;{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'],
|
||||
'exclude' => true,
|
||||
'inputType' => 'fileTree',
|
||||
'eval' => ['fieldType' => 'radio', 'files' => true, 'filesOnly' => true, 'extensions' => 'pdf', 'mandatory' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false],
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_content']['fields']['flipbookInitialPages'] = [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookInitialPages'],
|
||||
'exclude' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => ['2', '3', '4'],
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_content']['flipbookInitialPagesOptions'],
|
||||
'eval' => ['mandatory' => true, 'includeBlankOption' => false, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 1, 'default' => '4'],
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_content']['fields']['flipbookStartMode'] = [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookStartMode'],
|
||||
'exclude' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => ['center', 'spread'],
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'],
|
||||
'eval' => ['mandatory' => true, 'includeBlankOption' => false, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => 'center'],
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_content']['fields']['flipbookShowNavigation'] = [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'],
|
||||
'exclude' => true,
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['tl_class' => 'w50 m12'],
|
||||
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => '1'],
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_LANG']['CTE']['blatterbares_pdf'] = ['Blätterbares PDF', 'Zeigt ein PDF als blätterbares Flipbook mit Lazy-Rendering an.'];
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbook_legend'] = 'Blätterbares PDF';
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookPdfSrc'] = ['PDF-Datei', 'Bitte waehlen Sie die PDF-Datei aus, die als Flipbook angezeigt werden soll.'];
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookInitialPages'] = ['Initial geladene Seiten', 'Anzahl der Seiten, die beim Laden direkt gerendert werden (Rest lazy).'];
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookInitialPagesOptions'] = [
|
||||
'2' => '2 Seiten',
|
||||
'3' => '3 Seiten',
|
||||
'4' => '4 Seiten',
|
||||
];
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookStartMode'] = ['Startmodus', 'Legt fest, ob das Flipbook zentriert oder mit Doppelseite startet.'];
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [
|
||||
'center' => 'Zentriert starten',
|
||||
'spread' => 'als Doppelseite starten',
|
||||
];
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'] = ['Navigation anzeigen', 'Zeigt Vor-/Zurueck-Buttons unter dem Flipbook an.'];
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_LANG']['CTE']['blatterbares_pdf'] = ['Flippable PDF', 'Displays a PDF as a page-turning flipbook with lazy rendering.'];
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbook_legend'] = 'Flippable PDF';
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookPdfSrc'] = ['PDF file', 'Please select the PDF file to display as a flipbook.'];
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookInitialPages'] = ['Initially rendered pages', 'Number of pages rendered immediately on load (remaining pages are lazy-rendered).'];
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookInitialPagesOptions'] = [
|
||||
'2' => '2 pages',
|
||||
'3' => '3 pages',
|
||||
'4' => '4 pages',
|
||||
];
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookStartMode'] = ['Start mode', 'Defines whether the flipbook starts centered or as a double-page spread.'];
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [
|
||||
'center' => 'Start centered',
|
||||
'spread' => 'Start with double-page spread',
|
||||
];
|
||||
$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'] = ['Show navigation', 'Displays previous/next buttons below the flipbook.'];
|
||||
@@ -0,0 +1,32 @@
|
||||
{% set hasPdf = pdfUrl|default('') is not empty %}
|
||||
|
||||
<div
|
||||
class="ce-blatterbares-pdf mod-pdf-flipbook"
|
||||
data-pdf-flipbook-element="1"
|
||||
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-show-navigation="{{ showNavigation ? '1' : '0' }}"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="mod-pdf-flipbook__status" data-flipbook-loader="1" aria-live="polite">PDF wird geladen ...</div>
|
||||
|
||||
<div class="mod-pdf-flipbook__stage" data-flipbook-stage="1">
|
||||
<div id="flipbook-{{ data.id|default(random()) }}" class="c-flipbook" data-flipbook-book="1"></div>
|
||||
</div>
|
||||
|
||||
{% if showNavigation %}
|
||||
<div class="mod-pdf-flipbook__controls" aria-label="Flipbook Navigation">
|
||||
<button type="button" data-flipbook-prev="1">Zurück</button>
|
||||
<button type="button" data-flipbook-next="1">Weiter</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not hasPdf %}
|
||||
<p class="mod-pdf-flipbook__error">Keine PDF-Datei konfiguriert.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<link rel="stylesheet" href="/bundles/flipbook/assets/vendor/flipbook.min.css?v=20260414d">
|
||||
<link rel="stylesheet" href="/bundles/flipbook/assets/flipbook-module.css?v=20260414d">
|
||||
<script type="module" src="/bundles/flipbook/assets/flipbook-module.js?v=20260414d"></script>
|
||||
@@ -0,0 +1,84 @@
|
||||
.mod-pdf-flipbook {
|
||||
--flipbook-bg: #e0e0e0;
|
||||
--flipbook-text: #302d29;
|
||||
--flipbook-accent: #6f4f28;
|
||||
position: relative;
|
||||
padding: 3rem;
|
||||
background: var(--flipbook-bg);
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook:focus-visible {
|
||||
outline: 2px solid var(--flipbook-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook__status {
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--flipbook-text);
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook__status.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook__status.is-error {
|
||||
color: #9f1f1f;
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook__stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook .c-flipbook {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook .c-flipbook__page {
|
||||
background: #fefdf9;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2dbcc;
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook .c-flipbook__page canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook__page-loader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
color: #6f675a;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(184, 176, 160, 0.18),
|
||||
rgba(184, 176, 160, 0.18) 14px,
|
||||
rgba(239, 235, 227, 0.28) 14px,
|
||||
rgba(239, 235, 227, 0.28) 28px
|
||||
);
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook__controls {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.mod-pdf-flipbook__error {
|
||||
margin-top: 0.8rem;
|
||||
color: #9f1f1f;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mod-pdf-flipbook__controls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
const MODULE_SELECTOR = '[data-pdf-flipbook-element="1"]';
|
||||
const PDF_MODULE_URL = '/bundles/flipbook/assets/vendor/pdf.min.mjs';
|
||||
const PDF_WORKER_URL = '/bundles/flipbook/assets/vendor/pdf.worker.min.mjs';
|
||||
const FLIPBOOK_MODULE_URL = '/bundles/flipbook/assets/vendor/flipbook.esm.min.js';
|
||||
const INIT_MARKER = 'pdfFlipbookInitialized';
|
||||
const BOOTSTRAP_MARKER = '__mummertPdfFlipbookBootstrapBound';
|
||||
|
||||
let dependenciesPromise;
|
||||
let moduleCounter = 0;
|
||||
|
||||
const loadDependencies = async () => {
|
||||
if (dependenciesPromise) {
|
||||
return dependenciesPromise;
|
||||
}
|
||||
|
||||
dependenciesPromise = Promise.all([
|
||||
import(PDF_MODULE_URL),
|
||||
import(FLIPBOOK_MODULE_URL),
|
||||
]).then(([pdfjsLib, flipbookModule]) => {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = PDF_WORKER_URL;
|
||||
|
||||
return {
|
||||
pdfjsLib,
|
||||
FlipBook: flipbookModule.default,
|
||||
};
|
||||
});
|
||||
|
||||
return dependenciesPromise;
|
||||
};
|
||||
|
||||
class PdfFlipbookModule {
|
||||
constructor(root, dependencies) {
|
||||
const parsedInitialPages = Number.parseInt(root.dataset.initialPages || '4', 10);
|
||||
|
||||
this.root = root;
|
||||
this.dependencies = dependencies;
|
||||
this.pdfUrl = (root.dataset.pdfUrl || '').trim();
|
||||
this.initialPages = Number.isFinite(parsedInitialPages)
|
||||
? Math.min(4, Math.max(2, parsedInitialPages))
|
||||
: 4;
|
||||
this.showNavigation = root.dataset.showNavigation === '1';
|
||||
this.startMode = (root.dataset.startMode === 'spread' || root.dataset.startMode === 'cover')
|
||||
? 'spread'
|
||||
: 'center';
|
||||
this.loader = root.querySelector('[data-flipbook-loader="1"]');
|
||||
this.stage = root.querySelector('[data-flipbook-stage="1"]');
|
||||
this.bookElement = root.querySelector('[data-flipbook-book="1"]');
|
||||
this.nextButton = this.showNavigation ? root.querySelector('[data-flipbook-next="1"]') : null;
|
||||
this.prevButton = this.showNavigation ? root.querySelector('[data-flipbook-prev="1"]') : null;
|
||||
|
||||
this.pdf = null;
|
||||
this.flipbook = null;
|
||||
this.pageElements = new Map();
|
||||
this.renderedPages = new Set();
|
||||
this.pendingPages = new Set();
|
||||
this.renderQueue = [];
|
||||
this.renderInProgress = false;
|
||||
this.resizeTimer = null;
|
||||
this.touchStart = null;
|
||||
this.aspectRatio = 1.4142;
|
||||
this.pageWidth = 0;
|
||||
this.pageHeight = 0;
|
||||
this.pageGap = 2;
|
||||
this.totalPages = 0;
|
||||
|
||||
this.instanceId = `pdf-flipbook-${++moduleCounter}`;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.bookElement || !this.stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.pdfUrl) {
|
||||
this.setStatus('Keine PDF-Datei gefunden.', true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookElement.id = this.instanceId;
|
||||
|
||||
try {
|
||||
this.setStatus('PDF wird geladen ...');
|
||||
await this.loadPdf();
|
||||
await this.resolveInitialAspectRatio();
|
||||
this.buildPageSkeleton();
|
||||
this.updateLayout();
|
||||
await this.renderInitialPages();
|
||||
this.initializeFlipbook();
|
||||
this.bindKeyboard();
|
||||
this.bindTouchSwipe();
|
||||
this.bindResize();
|
||||
this.queuePages(this.getLazyCandidates());
|
||||
this.setStatus('');
|
||||
} catch {
|
||||
this.setStatus('PDF konnte nicht geladen werden.', true);
|
||||
}
|
||||
}
|
||||
|
||||
async loadPdf() {
|
||||
const loadingTask = this.dependencies.pdfjsLib.getDocument({
|
||||
url: this.pdfUrl,
|
||||
useWorkerFetch: true,
|
||||
isEvalSupported: false,
|
||||
});
|
||||
|
||||
this.pdf = await loadingTask.promise;
|
||||
this.totalPages = Number(this.pdf.numPages || 0);
|
||||
|
||||
if (this.totalPages <= 0) {
|
||||
throw new Error('The selected PDF has no pages.');
|
||||
}
|
||||
}
|
||||
|
||||
async resolveInitialAspectRatio() {
|
||||
const firstPage = await this.pdf.getPage(1);
|
||||
const viewport = firstPage.getViewport({ scale: 1 });
|
||||
|
||||
if (viewport.width > 0 && viewport.height > 0) {
|
||||
this.aspectRatio = viewport.height / viewport.width;
|
||||
}
|
||||
}
|
||||
|
||||
buildPageSkeleton() {
|
||||
const visualPageCount = this.totalPages % 2 === 0 ? this.totalPages : this.totalPages + 1;
|
||||
|
||||
this.bookElement.innerHTML = '';
|
||||
this.pageElements.clear();
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= visualPageCount; pageNumber += 1) {
|
||||
const page = document.createElement('div');
|
||||
const loader = document.createElement('div');
|
||||
|
||||
page.className = 'c-flipbook__page';
|
||||
page.dataset.pageNumber = String(pageNumber);
|
||||
|
||||
loader.className = 'mod-pdf-flipbook__page-loader';
|
||||
loader.textContent = pageNumber <= this.totalPages ? `Seite ${pageNumber} wird gerendert ...` : 'Leere Seite';
|
||||
page.appendChild(loader);
|
||||
|
||||
if (pageNumber > this.totalPages) {
|
||||
page.dataset.empty = '1';
|
||||
}
|
||||
|
||||
this.bookElement.appendChild(page);
|
||||
|
||||
if (pageNumber <= this.totalPages) {
|
||||
this.pageElements.set(pageNumber, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLayout() {
|
||||
const stageWidth = Math.max(this.stage.clientWidth || 0, 282);
|
||||
this.pageWidth = Math.max(Math.floor((stageWidth - this.pageGap) / 2), 140);
|
||||
this.pageHeight = Math.max(Math.floor(this.pageWidth * this.aspectRatio), 180);
|
||||
|
||||
this.stage.style.height = `${this.pageHeight}px`;
|
||||
this.bookElement.style.height = `${this.pageHeight}px`;
|
||||
|
||||
this.pageElements.forEach((pageElement) => {
|
||||
pageElement.style.width = `${this.pageWidth}px`;
|
||||
pageElement.style.height = `${this.pageHeight}px`;
|
||||
});
|
||||
}
|
||||
|
||||
async renderInitialPages() {
|
||||
const limit = Math.min(this.initialPages, this.totalPages);
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= limit; pageNumber += 1) {
|
||||
await this.renderPage(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
initializeFlipbook() {
|
||||
const startsWithDoubleSpread = this.startMode === 'spread';
|
||||
|
||||
this.flipbook = new this.dependencies.FlipBook(this.bookElement, {
|
||||
nextButton: this.nextButton,
|
||||
previousButton: this.prevButton,
|
||||
canClose: !startsWithDoubleSpread,
|
||||
arrowKeys: false,
|
||||
initialActivePage: 0,
|
||||
initialCall: false,
|
||||
width: '100%',
|
||||
height: `${this.pageHeight}px`,
|
||||
onPageTurn: () => {
|
||||
this.queuePages(this.getLazyCandidates());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
bindKeyboard() {
|
||||
this.root.addEventListener('pointerdown', () => {
|
||||
this.root.focus();
|
||||
});
|
||||
|
||||
this.root.addEventListener('keydown', (event) => {
|
||||
if (!this.flipbook) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
this.flipbook.turnPage('forward');
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
this.flipbook.turnPage('back');
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindTouchSwipe() {
|
||||
this.bookElement.addEventListener('touchstart', (event) => {
|
||||
const touch = event.changedTouches && event.changedTouches[0];
|
||||
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.touchStart = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
};
|
||||
}, { passive: true });
|
||||
|
||||
this.bookElement.addEventListener('touchend', (event) => {
|
||||
if (!this.flipbook || !this.touchStart) {
|
||||
this.touchStart = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.changedTouches && event.changedTouches[0];
|
||||
|
||||
if (!touch) {
|
||||
this.touchStart = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.clientX - this.touchStart.x;
|
||||
const deltaY = touch.clientY - this.touchStart.y;
|
||||
const horizontalThreshold = 40;
|
||||
const verticalLimit = 60;
|
||||
|
||||
if (Math.abs(deltaX) >= horizontalThreshold && Math.abs(deltaY) < verticalLimit) {
|
||||
if (deltaX < 0) {
|
||||
this.flipbook.turnPage('forward');
|
||||
} else {
|
||||
this.flipbook.turnPage('back');
|
||||
}
|
||||
}
|
||||
|
||||
this.touchStart = null;
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
bindResize() {
|
||||
const onResize = () => {
|
||||
window.clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = window.setTimeout(() => {
|
||||
if (!this.pdf) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousHeight = this.pageHeight;
|
||||
this.updateLayout();
|
||||
|
||||
if (Math.abs(previousHeight - this.pageHeight) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookElement.style.height = `${this.pageHeight}px`;
|
||||
|
||||
const rerender = Array.from(this.renderedPages);
|
||||
this.renderedPages.clear();
|
||||
|
||||
rerender.forEach((pageNumber) => {
|
||||
const page = this.pageElements.get(pageNumber);
|
||||
|
||||
if (page) {
|
||||
const oldCanvas = page.querySelector('canvas');
|
||||
|
||||
if (oldCanvas) {
|
||||
oldCanvas.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.queuePages(rerender, true);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
if ('ResizeObserver' in window) {
|
||||
const observer = new ResizeObserver(onResize);
|
||||
observer.observe(this.stage);
|
||||
} else {
|
||||
window.addEventListener('resize', onResize, { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
getLazyCandidates() {
|
||||
const activePages = Array.from(this.bookElement.querySelectorAll('.c-flipbook__page.is-active'));
|
||||
const candidates = new Set();
|
||||
|
||||
if (activePages.length === 0) {
|
||||
const fallbackFrom = Math.min(this.initialPages + 1, this.totalPages);
|
||||
const fallbackTo = Math.min(fallbackFrom + 2, this.totalPages);
|
||||
|
||||
for (let pageNumber = fallbackFrom; pageNumber <= fallbackTo; pageNumber += 1) {
|
||||
candidates.add(pageNumber);
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
activePages.forEach((activePage) => {
|
||||
const currentPage = Number(activePage.dataset.pageNumber || 0);
|
||||
|
||||
for (let offset = -1; offset <= 3; offset += 1) {
|
||||
const pageNumber = currentPage + offset;
|
||||
|
||||
if (pageNumber >= 1 && pageNumber <= this.totalPages) {
|
||||
candidates.add(pageNumber);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
queuePages(pageNumbers, prioritize = false) {
|
||||
for (const pageNumber of pageNumbers) {
|
||||
if (!Number.isInteger(pageNumber) || pageNumber < 1 || pageNumber > this.totalPages) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.renderedPages.has(pageNumber) || this.pendingPages.has(pageNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.pendingPages.add(pageNumber);
|
||||
|
||||
if (prioritize) {
|
||||
this.renderQueue.unshift(pageNumber);
|
||||
} else {
|
||||
this.renderQueue.push(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
this.processRenderQueue();
|
||||
}
|
||||
|
||||
async processRenderQueue() {
|
||||
if (this.renderInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderInProgress = true;
|
||||
|
||||
while (this.renderQueue.length > 0) {
|
||||
const pageNumber = this.renderQueue.shift();
|
||||
|
||||
if (!pageNumber) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.pendingPages.delete(pageNumber);
|
||||
|
||||
try {
|
||||
await this.renderPage(pageNumber);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.renderInProgress = false;
|
||||
}
|
||||
|
||||
async renderPage(pageNumber) {
|
||||
if (this.renderedPages.has(pageNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageElement = this.pageElements.get(pageNumber);
|
||||
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await this.pdf.getPage(pageNumber);
|
||||
const viewportAtScale1 = page.getViewport({ scale: 1 });
|
||||
const scale = this.pageWidth / viewportAtScale1.width;
|
||||
const viewport = page.getViewport({ scale });
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const outputScale = Math.max(1, devicePixelRatio);
|
||||
const existingCanvas = pageElement.querySelector('canvas');
|
||||
|
||||
if (existingCanvas) {
|
||||
existingCanvas.remove();
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d', { alpha: false });
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Could not create canvas 2D context.');
|
||||
}
|
||||
|
||||
canvas.width = Math.floor(viewport.width * outputScale);
|
||||
canvas.height = Math.floor(viewport.height * outputScale);
|
||||
canvas.style.width = `${Math.floor(viewport.width)}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',
|
||||
});
|
||||
|
||||
await renderTask.promise;
|
||||
|
||||
const loader = pageElement.querySelector('.mod-pdf-flipbook__page-loader');
|
||||
|
||||
if (loader) {
|
||||
loader.remove();
|
||||
}
|
||||
|
||||
pageElement.appendChild(canvas);
|
||||
this.renderedPages.add(pageNumber);
|
||||
}
|
||||
|
||||
setStatus(message, isError = false) {
|
||||
if (!this.loader) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
this.loader.classList.add('is-hidden');
|
||||
this.loader.classList.remove('is-error');
|
||||
this.loader.textContent = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.loader.classList.remove('is-hidden');
|
||||
this.loader.classList.toggle('is-error', isError);
|
||||
this.loader.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const modules = Array.from(document.querySelectorAll(MODULE_SELECTOR))
|
||||
.filter((moduleElement) => moduleElement.dataset[INIT_MARKER] !== '1');
|
||||
|
||||
if (modules.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dependencies = await loadDependencies();
|
||||
|
||||
await Promise.all(modules.map(async (moduleElement) => {
|
||||
moduleElement.dataset[INIT_MARKER] = '1';
|
||||
|
||||
const module = new PdfFlipbookModule(moduleElement, dependencies);
|
||||
await module.init();
|
||||
}));
|
||||
};
|
||||
|
||||
if (window[BOOTSTRAP_MARKER]) {
|
||||
run().catch(() => {
|
||||
});
|
||||
|
||||
} else {
|
||||
window[BOOTSTRAP_MARKER] = true;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
run().catch(() => {
|
||||
});
|
||||
}, { once: true });
|
||||
} else {
|
||||
run().catch(() => {
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
.c-flipbook{perspective:2200px;transform-style:preserve-3d;opacity:1;height:200px;position:absolute;left:0;transition:left .7s;top:0}.c-flipbook.at-front-cover{left:-25%}.c-flipbook.at-rear-cover{left:25%}.c-flipbook:not(.is-ready) *{transition:none!important}.c-flipbook:after{content:'';display:table;clear:both}.c-flipbook[data-useragent*='MSIE 10.0'] .c-flipbook__page{opacity:0}.c-flipbook[data-useragent*='MSIE 10.0'] .c-flipbook__page.is-active{transition:opacity .9s ease,transform .9s ease;opacity:1}.c-flipbook[data-useragent*='MSIE 10.0'] .c-flipbook__page.was-active{transition-delay:2s;transition:opacity .9s ease,transform .9s ease;opacity:0}.is-calling{transform:rotateY(-20deg)!important}.c-flipbook__page{cursor:pointer;overflow:hidden;position:absolute;width:50%;background:#efeef4;backface-visibility:hidden;transform:rotateY(0);user-select:none;transition:transform .9s ease}.c-flipbook__page.is-active{z-index:2}.c-flipbook__page.was-active{z-index:1}.c-flipbook__page.is-animating:nth-child(odd){z-index:4}.c-flipbook__page.is-animating:nth-child(odd)~.c-flipbook__page.is-animating{z-index:3}.c-flipbook__page.is-animating+.c-flipbook__page:not(.is-animating):nth-child(odd){z-index:1}.c-flipbook__page:nth-child(2n){transform-origin:100%;left:0;border-radius:6px 0 0 6px}.c-flipbook__page:nth-child(2n).is-active{transform:rotateY(10deg)}.c-flipbook__page:nth-child(2n).is-active:hover{transform:rotateY(15deg)}.c-flipbook__page:nth-child(2n):not(.last-page){border-right:none}.c-flipbook__page:nth-child(2n).is-active:hover{transform:rotateY(5deg)}.c-flipbook__page:nth-child(odd){transform-origin:0;right:0;transform:rotateY(-180deg);border-radius:0 6px 6px 0}.c-flipbook__page:nth-child(odd).is-active{transform:rotateY(-10deg)}.c-flipbook__page:nth-child(odd).is-active:hover{transform:rotateY(-15deg)}.c-flipbook__page:nth-child(odd):not(.first-page){border-left:none}.c-flipbook__page:nth-child(odd).is-active~.c-flipbook__page:nth-child(2n){transform:rotateY(180deg)}.c-flipbook__page:nth-child(odd).is-active~.c-flipbook__page:nth-child(odd){transform:rotateY(0)}.c-flipbook__page:nth-child(odd).is-active:hover{transform:rotateY(-5deg)}.c-flipbook__page.is-active:not(:hover){transform:rotateY(0)}.c-flipbook__page:before{content:'';position:absolute;z-index:3;right:0;width:100%;height:100%;background-size:100% 100%}.no-csstransforms3d .c-flipbook__page{display:none}.no-csstransforms3d .c-flipbook__page.is-active{display:block;position:relative;float:left}.c-flipbook-image{position:relative;z-index:2;height:auto;width:100%;display:block;pointer-events:none}.c-flipbook__page .ss-loading{font-size:2rem;position:absolute;z-index:1;top:0;bottom:0;width:100%;display:flex}.c-flipbook__page .ss-loading:before{display:flex;align-items:center;justify-content:center;width:100%}@supports (transition:transform 0.9s ease) and (not (-ms-ime-align:auto)){.c-flipbook__page{transition:transform .9s ease}}
|
||||
Vendored
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\FlipbookBundle\Contao\Manager;
|
||||
|
||||
use Contao\CoreBundle\ContaoCoreBundle;
|
||||
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
|
||||
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
|
||||
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
|
||||
use Mummert\FlipbookBundle\FlipbookBundle;
|
||||
|
||||
class Plugin implements BundlePluginInterface
|
||||
{
|
||||
public function getBundles(ParserInterface $parser): iterable
|
||||
{
|
||||
return [
|
||||
BundleConfig::create(FlipbookBundle::class)
|
||||
->setLoadAfter([ContaoCoreBundle::class]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\FlipbookBundle\Controller\ContentElement;
|
||||
|
||||
use Contao\ContentModel;
|
||||
use Contao\CoreBundle\Controller\ContentElement\AbstractContentElementController;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsContentElement;
|
||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||
use Contao\FilesModel;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
#[AsContentElement(type: 'blatterbares_pdf', category: 'media', template: 'content_element/blatterbares_pdf')]
|
||||
class BlatterbaresPdfController extends AbstractContentElementController
|
||||
{
|
||||
protected function getResponse(FragmentTemplate $template, ContentModel $model, Request $request): Response
|
||||
{
|
||||
$template->set('pdfUrl', $this->resolvePdfUrl($model));
|
||||
$template->set('showNavigation', '1' === (string) ($model->flipbookShowNavigation ?? '1'));
|
||||
$template->set('initialRenderPages', $this->normalizeInitialRenderPages((string) ($model->flipbookInitialPages ?? '4')));
|
||||
$template->set('startMode', $this->normalizeStartMode((string) ($model->flipbookStartMode ?? 'center')));
|
||||
|
||||
return $template->getResponse();
|
||||
}
|
||||
|
||||
private function resolvePdfUrl(ContentModel $model): string
|
||||
{
|
||||
$uuid = (string) ($model->flipbookPdfSrc ?? '');
|
||||
|
||||
if ('' === $uuid) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$fileModel = FilesModel::findByUuid($uuid);
|
||||
|
||||
if (null === $fileModel) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = trim((string) $fileModel->path, '/');
|
||||
|
||||
if ('' === $path || !str_ends_with(strtolower($path), '.pdf')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '/'.implode('/', array_map('rawurlencode', explode('/', $path)));
|
||||
}
|
||||
|
||||
private function normalizeInitialRenderPages(string $value): int
|
||||
{
|
||||
$count = (int) $value;
|
||||
|
||||
if ($count < 2) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if ($count > 4) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function normalizeStartMode(string $value): string
|
||||
{
|
||||
if ('spread' === $value || 'cover' === $value) {
|
||||
return 'spread';
|
||||
}
|
||||
|
||||
return 'center';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\FlipbookBundle\DependencyInjection;
|
||||
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Extension\Extension;
|
||||
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
|
||||
|
||||
class FlipbookExtension extends Extension
|
||||
{
|
||||
public function load(array $configs, ContainerBuilder $container): void
|
||||
{
|
||||
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../config'));
|
||||
$loader->load('services.yaml');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\FlipbookBundle;
|
||||
|
||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||
|
||||
class FlipbookBundle extends Bundle
|
||||
{
|
||||
public function getPath(): string
|
||||
{
|
||||
return dirname(__DIR__);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user