Add PDF two-page spread mode and mobile pinch-to-zoom
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 16s

Spread mode: new toggle in reader settings renders pages side-by-side
(cover alone, then pairs 2-3, 4-5...) using full screen width. Each
page scales to half the container. Navigation and scroll position
tracking are unchanged since per-page IDs are preserved.

Pinch-to-zoom: captures 2-finger pinch on the PDF reader, blocking
native browser zoom. Live CSS zoom during gesture, snaps to nearest
10% step on release. Single-finger scroll unaffected. applyPdfZoom
moved to module level so touch handlers can call it. Touch listeners
cleaned up on reader close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-04-05 13:18:25 +02:00
parent e9c5b8058b
commit 916e8a568b
2 changed files with 124 additions and 38 deletions

View file

@ -1678,6 +1678,13 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; } .reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
.pdf-paginated .pdf-page-wrapper { margin:0; } .pdf-paginated .pdf-page-wrapper { margin:0; }
/* PDF two-page spread */
.pdf-spread-wrapper { display:flex; flex-direction:row; gap:8px; justify-content:center; margin-bottom:1rem; }
.pdf-spread-wrapper .pdf-page-wrapper { margin:0; }
.pdf-spread-cover { margin-bottom:1rem; }
/* Disable text selection during pinch */
#reader-content.pinch-active { user-select:none; -webkit-user-select:none; }
/* Highlight popover */ /* Highlight popover */
.highlight-popover { position:fixed; z-index:500; display:flex; gap:6px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.5); } .highlight-popover { position:fixed; z-index:500; display:flex; gap:6px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.5); }
.hl-color-btn { width:24px; height:24px; border-radius:50%; border:2px solid transparent; cursor:pointer; font-weight:700; font-size:12px; color:#000; line-height:1; } .hl-color-btn { width:24px; height:24px; border-radius:50%; border:2px solid transparent; cursor:pointer; font-weight:700; font-size:12px; color:#000; line-height:1; }

View file

@ -2700,7 +2700,7 @@ async function _evictBookCache(bookList) {
// Reader settings // Reader settings
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
pdfZoom: 100, pdfInverted: false, pdfPaginated: false }; pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false };
let readerSettingsPanelOpen = false; let readerSettingsPanelOpen = false;
let currentPdfDoc = null; let currentPdfDoc = null;
let currentPdfBuffer = null; let currentPdfBuffer = null;
@ -2726,6 +2726,9 @@ let pdfTotalPages = 0;
let _pdfPageTextBoxCache = {}; let _pdfPageTextBoxCache = {};
let _pdfRenderGen = 0; let _pdfRenderGen = 0;
let _touchStartX = 0; let _touchStartX = 0;
let _pinchStartDist = 0;
let _pinchStartZoom = 100;
let _isPinching = false;
if (typeof pdfjsLib !== 'undefined') { if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js';
@ -3000,14 +3003,19 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
if (scaleOverride == null) pdfVp.style.zoom = readerSettings.pdfZoom / 100; if (scaleOverride == null) pdfVp.style.zoom = readerSettings.pdfZoom / 100;
contentEl.appendChild(pdfVp); contentEl.appendChild(pdfVp);
const containerWidth = Math.min(contentEl.clientWidth - 32, 900); const containerWidth = readerSettings.pdfSpread
? contentEl.clientWidth - 32
: Math.min(contentEl.clientWidth - 32, 900);
let spreadContainer = null;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; } if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; }
const page = await pdf.getPage(pageNum); const page = await pdf.getPage(pageNum);
const naturalVp = page.getViewport({scale: 1}); const naturalVp = page.getViewport({scale: 1});
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
const scale = scaleOverride != null ? scaleOverride const scale = scaleOverride != null ? scaleOverride
: Math.max(0.5, containerWidth / naturalVp.width); : Math.max(0.5, pageWidth / naturalVp.width);
const viewport = page.getViewport({scale}); const viewport = page.getViewport({scale});
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
@ -3028,7 +3036,22 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
canvas.style.height = viewport.height + 'px'; canvas.style.height = viewport.height + 'px';
inner.appendChild(canvas); inner.appendChild(canvas);
wrapper.appendChild(inner); wrapper.appendChild(inner);
if (!readerSettings.pdfSpread) {
pdfVp.appendChild(wrapper); pdfVp.appendChild(wrapper);
} else if (pageNum === 1) {
wrapper.classList.add('pdf-spread-cover');
pdfVp.appendChild(wrapper);
spreadContainer = null;
} else {
if (pageNum % 2 === 0) {
spreadContainer = document.createElement('div');
spreadContainer.className = 'pdf-spread-wrapper';
pdfVp.appendChild(spreadContainer);
}
if (spreadContainer) spreadContainer.appendChild(wrapper);
else pdfVp.appendChild(wrapper);
}
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
@ -3132,14 +3155,11 @@ async function openBook(bookId) {
// Apply reader settings (theme, font size, etc.) // Apply reader settings (theme, font size, etc.)
applyReaderSettings(isPdfBook); applyReaderSettings(isPdfBook);
// Swipe for PDF paginated // Touch: swipe (paginated) + pinch-to-zoom
contentEl.addEventListener('touchstart', e => { _touchStartX = e.touches[0].clientX; }, {passive: true}); contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true});
contentEl.addEventListener('touchend', e => { contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false});
if (!readerSettings.pdfPaginated) return; contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true});
const delta = e.changedTouches[0].clientX - _touchStartX; contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true});
if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1);
}, {passive: true});
// Set up progress input // Set up progress input
const progressInput = $('reader-progress-input'); const progressInput = $('reader-progress-input');
@ -3405,9 +3425,16 @@ function closeReader() {
const progressSuffix = $('reader-progress-suffix'); const progressSuffix = $('reader-progress-suffix');
if (progressSuffix) progressSuffix.textContent = ''; if (progressSuffix) progressSuffix.textContent = '';
// Free image blob URLs // Remove touch handlers and free image blob URLs
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (contentEl) contentEl.innerHTML = ''; if (contentEl) {
contentEl.removeEventListener('touchstart', _pdfTouchStart);
contentEl.removeEventListener('touchmove', _pdfTouchMove);
contentEl.removeEventListener('touchend', _pdfTouchEnd);
contentEl.removeEventListener('touchcancel', _pdfTouchEnd);
contentEl.classList.remove('pinch-active');
contentEl.innerHTML = '';
}
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
currentImageMap = {}; currentImageMap = {};
@ -3578,6 +3605,7 @@ function toggleSettingsPanel() {
panel.innerHTML = ` panel.innerHTML = `
<label>Zoom <button class="btn btn-sm" id="rs-zoom-minus"></button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label> <label>Zoom <button class="btn btn-sm" id="rs-zoom-minus"></button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label>
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invert</button> <button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invert</button>
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread">Spread</button>
`; `;
} }
@ -3629,30 +3657,6 @@ function toggleSettingsPanel() {
}); });
} else { } else {
const zoomRange = panel.querySelector('#rs-zoom'); const zoomRange = panel.querySelector('#rs-zoom');
const zoomVal = panel.querySelector('#rs-zoom-val');
function applyPdfZoom(newZoom) {
const contentEl2 = $('reader-content');
// Preserve scroll position across zoom change
const fraction = contentEl2
? contentEl2.scrollTop / (contentEl2.scrollHeight - contentEl2.clientHeight || 1)
: 0;
readerSettings.pdfZoom = newZoom;
zoomRange.value = newZoom;
zoomVal.textContent = newZoom + '%';
saveReaderSettings();
if (readerSettings.pdfPaginated) {
pdfSmartZoomPage(pdfCurrentPage);
} else {
const vp = document.getElementById('pdf-viewport');
if (vp) vp.style.zoom = readerSettings.pdfZoom / 100;
if (contentEl2 && fraction > 0) {
requestAnimationFrame(() => {
contentEl2.scrollTop = fraction * (contentEl2.scrollHeight - contentEl2.clientHeight);
});
}
}
}
zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10))); zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10)));
panel.querySelector('#rs-zoom-minus').addEventListener('click', () => panel.querySelector('#rs-zoom-minus').addEventListener('click', () =>
@ -3667,6 +3671,37 @@ function toggleSettingsPanel() {
saveReaderSettings(); saveReaderSettings();
}); });
panel.querySelector('#rs-spread').addEventListener('click', function () {
readerSettings.pdfSpread = !readerSettings.pdfSpread;
this.classList.toggle('active', readerSettings.pdfSpread);
saveReaderSettings();
reRenderPdf();
});
}
}
function applyPdfZoom(newZoom) {
const contentEl2 = $('reader-content');
const fraction = contentEl2
? contentEl2.scrollTop / (contentEl2.scrollHeight - contentEl2.clientHeight || 1)
: 0;
readerSettings.pdfZoom = newZoom;
const zoomRange = document.getElementById('rs-zoom');
const zoomVal = document.getElementById('rs-zoom-val');
if (zoomRange) zoomRange.value = newZoom;
if (zoomVal) zoomVal.textContent = newZoom + '%';
saveReaderSettings();
if (readerSettings.pdfPaginated) {
pdfSmartZoomPage(pdfCurrentPage);
} else {
const vp = document.getElementById('pdf-viewport');
if (vp) vp.style.zoom = readerSettings.pdfZoom / 100;
if (contentEl2 && fraction > 0) {
requestAnimationFrame(() => {
contentEl2.scrollTop = fraction * (contentEl2.scrollHeight - contentEl2.clientHeight);
});
}
} }
} }
@ -3724,6 +3759,50 @@ function _pdfPaginatedClick(e) {
else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1); else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1);
} }
function _pdfTouchStart(e) {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
_pinchStartDist = Math.hypot(dx, dy);
_pinchStartZoom = readerSettings.pdfZoom;
_isPinching = true;
const ce = $('reader-content');
if (ce) ce.classList.add('pinch-active');
} else {
_touchStartX = e.touches[0].clientX;
_isPinching = false;
}
}
function _pdfTouchMove(e) {
if (e.touches.length !== 2 || !_isPinching) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.hypot(dx, dy);
if (_pinchStartDist === 0) return;
const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist)));
const vp = document.getElementById('pdf-viewport');
if (vp) vp.style.zoom = liveZoom / 100;
}
function _pdfTouchEnd(e) {
if (_isPinching) {
_isPinching = false;
const ce = $('reader-content');
if (ce) ce.classList.remove('pinch-active');
const vp = document.getElementById('pdf-viewport');
const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom;
const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10));
applyPdfZoom(snapped);
return;
}
if (!readerSettings.pdfPaginated) return;
const delta = e.changedTouches[0].clientX - _touchStartX;
if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1);
}
function pdfGoToPage(n) { function pdfGoToPage(n) {
if (!currentPdfDoc) return; if (!currentPdfDoc) return;
n = Math.max(1, Math.min(pdfTotalPages, n)); n = Math.max(1, Math.min(pdfTotalPages, n));