1 Commits

Author SHA1 Message Date
Jürgen Mummert a6c9aef952 feat: support PDF spread splitting and nav button states 2026-04-14 15:04:57 +02:00
6 changed files with 179 additions and 19 deletions
+9 -1
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); 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'] = [ $GLOBALS['TL_DCA']['tl_content']['fields']['flipbookPdfSrc'] = [
'label' => &$GLOBALS['TL_LANG']['tl_content']['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'], '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'] = [ $GLOBALS['TL_DCA']['tl_content']['fields']['flipbookShowNavigation'] = [
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'], 'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'],
'exclude' => true, 'exclude' => true,
+1
View File
@@ -15,5 +15,6 @@ $GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [
'center' => 'Zentriert starten', 'center' => 'Zentriert starten',
'spread' => 'als Doppelseite 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']['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.']; $GLOBALS['TL_LANG']['tl_content']['flipbookPlaySound'] = ['Blättersound abspielen', 'Spielt beim Blättern einen Sound ab.'];
+1
View File
@@ -15,5 +15,6 @@ $GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [
'center' => 'Start centered', 'center' => 'Start centered',
'spread' => 'Start with double-page spread', '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']['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']['flipbookPlaySound'] = ['Play page-turn sound', 'Plays a sound effect while turning pages.'];
@@ -6,6 +6,7 @@
data-pdf-url="{{ pdfUrl|default('')|e('html_attr') }}" data-pdf-url="{{ pdfUrl|default('')|e('html_attr') }}"
data-initial-pages="{{ initialRenderPages|default(4)|e('html_attr') }}" data-initial-pages="{{ initialRenderPages|default(4)|e('html_attr') }}"
data-start-mode="{{ startMode|default('center')|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-show-navigation="{{ showNavigation ? '1' : '0' }}"
data-play-turn-sound="{{ ((playTurnSound is defined) ? playTurnSound : true) ? '1' : '0' }}" data-play-turn-sound="{{ ((playTurnSound is defined) ? playTurnSound : true) ? '1' : '0' }}"
tabindex="0" tabindex="0"
+159 -11
View File
@@ -41,6 +41,7 @@ class PdfFlipbookModule {
this.initialPages = Number.isFinite(parsedInitialPages) this.initialPages = Number.isFinite(parsedInitialPages)
? Math.min(4, Math.max(2, parsedInitialPages)) ? Math.min(4, Math.max(2, parsedInitialPages))
: 4; : 4;
this.splitSpreads = root.dataset.splitSpreads === '1';
this.showNavigation = root.dataset.showNavigation === '1'; this.showNavigation = root.dataset.showNavigation === '1';
this.playTurnSoundEnabled = root.dataset.playTurnSound !== '0'; this.playTurnSoundEnabled = root.dataset.playTurnSound !== '0';
this.startMode = (root.dataset.startMode === 'spread' || root.dataset.startMode === 'cover') this.startMode = (root.dataset.startMode === 'spread' || root.dataset.startMode === 'cover')
@@ -67,7 +68,9 @@ class PdfFlipbookModule {
this.pageWidth = 0; this.pageWidth = 0;
this.pageHeight = 0; this.pageHeight = 0;
this.pageGap = 2; this.pageGap = 2;
this.sourcePageCount = 0;
this.totalPages = 0; this.totalPages = 0;
this.pageDescriptors = [];
this.instanceId = `pdf-flipbook-${++moduleCounter}`; this.instanceId = `pdf-flipbook-${++moduleCounter}`;
} }
@@ -136,6 +139,8 @@ class PdfFlipbookModule {
if (this.controlsElement) { if (this.controlsElement) {
this.controlsElement.style.opacity = '1'; this.controlsElement.style.opacity = '1';
} }
this.updateNavigationState();
}); });
} }
@@ -147,19 +152,84 @@ class PdfFlipbookModule {
}); });
this.pdf = await loadingTask.promise; 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.'); 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() { async resolveInitialAspectRatio() {
const firstPage = await this.pdf.getPage(1); const firstDescriptor = this.getDescriptor(1);
const viewport = firstPage.getViewport({ scale: 1 });
if (viewport.width > 0 && viewport.height > 0) { if (!firstDescriptor) {
this.aspectRatio = viewport.height / viewport.width; 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: () => { onPageTurn: () => {
this.playTurnSound(); this.playTurnSound();
this.queuePages(this.getLazyCandidates()); 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() { createTurnSound() {
@@ -449,14 +553,18 @@ class PdfFlipbookModule {
} }
const pageElement = this.pageElements.get(pageNumber); const pageElement = this.pageElements.get(pageNumber);
const descriptor = this.getDescriptor(pageNumber);
if (!pageElement) { if (!pageElement || !descriptor) {
return; return;
} }
const page = await this.pdf.getPage(pageNumber); const page = await this.pdf.getPage(descriptor.sourcePageNumber);
const viewportAtScale1 = page.getViewport({ scale: 1 }); 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 viewport = page.getViewport({ scale });
const devicePixelRatio = window.devicePixelRatio || 1; const devicePixelRatio = window.devicePixelRatio || 1;
const outputScale = Math.max(1, devicePixelRatio); const outputScale = Math.max(1, devicePixelRatio);
@@ -473,11 +581,16 @@ class PdfFlipbookModule {
throw new Error('Could not create canvas 2D context.'); 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.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`; canvas.style.height = `${Math.floor(viewport.height)}px`;
if (descriptor.segment === 'full') {
const renderTask = page.render({ const renderTask = page.render({
canvasContext: context, canvasContext: context,
viewport, viewport,
@@ -486,6 +599,41 @@ class PdfFlipbookModule {
}); });
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'); const loader = pageElement.querySelector('.mod-pdf-flipbook__page-loader');
@@ -20,6 +20,7 @@ class BlatterbaresPdfController extends AbstractContentElementController
$template->set('pdfUrl', $this->resolvePdfUrl($model)); $template->set('pdfUrl', $this->resolvePdfUrl($model));
$template->set('showNavigation', '1' === (string) ($model->flipbookShowNavigation ?? '1')); $template->set('showNavigation', '1' === (string) ($model->flipbookShowNavigation ?? '1'));
$template->set('playTurnSound', '1' === (string) ($model->flipbookPlaySound ?? '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('initialRenderPages', $this->normalizeInitialRenderPages((string) ($model->flipbookInitialPages ?? '4')));
$template->set('startMode', $this->normalizeStartMode((string) ($model->flipbookStartMode ?? 'center'))); $template->set('startMode', $this->normalizeStartMode((string) ($model->flipbookStartMode ?? 'center')));