diff --git a/static/css/app.css b/static/css/app.css
index 98b3e6b..3162ee4 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -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; }
.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 { 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; }
diff --git a/static/js/app.js b/static/js/app.js
index 531d80e..1dcb443 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -2700,7 +2700,7 @@ async function _evictBookCache(bookList) {
// Reader settings
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 currentPdfDoc = null;
let currentPdfBuffer = null;
@@ -2726,6 +2726,9 @@ let pdfTotalPages = 0;
let _pdfPageTextBoxCache = {};
let _pdfRenderGen = 0;
let _touchStartX = 0;
+let _pinchStartDist = 0;
+let _pinchStartZoom = 100;
+let _isPinching = false;
if (typeof pdfjsLib !== 'undefined') {
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;
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++) {
if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; }
const page = await pdf.getPage(pageNum);
const naturalVp = page.getViewport({scale: 1});
+ const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
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 wrapper = document.createElement('div');
@@ -3028,7 +3036,22 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
canvas.style.height = viewport.height + 'px';
inner.appendChild(canvas);
wrapper.appendChild(inner);
- pdfVp.appendChild(wrapper);
+
+ if (!readerSettings.pdfSpread) {
+ 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');
ctx.scale(dpr, dpr);
@@ -3132,14 +3155,11 @@ async function openBook(bookId) {
// Apply reader settings (theme, font size, etc.)
applyReaderSettings(isPdfBook);
- // Swipe for PDF paginated
- contentEl.addEventListener('touchstart', e => { _touchStartX = e.touches[0].clientX; }, {passive: true});
- contentEl.addEventListener('touchend', e => {
- if (!readerSettings.pdfPaginated) return;
- const delta = e.changedTouches[0].clientX - _touchStartX;
- if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
- else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1);
- }, {passive: true});
+ // Touch: swipe (paginated) + pinch-to-zoom
+ contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true});
+ contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false});
+ contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true});
+ contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true});
// Set up progress input
const progressInput = $('reader-progress-input');
@@ -3405,9 +3425,16 @@ function closeReader() {
const progressSuffix = $('reader-progress-suffix');
if (progressSuffix) progressSuffix.textContent = '';
- // Free image blob URLs
+ // Remove touch handlers and free image blob URLs
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);
currentImageMap = {};
@@ -3578,6 +3605,7 @@ function toggleSettingsPanel() {
panel.innerHTML = `
+
`;
}
@@ -3629,30 +3657,6 @@ function toggleSettingsPanel() {
});
} else {
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)));
panel.querySelector('#rs-zoom-minus').addEventListener('click', () =>
@@ -3667,6 +3671,37 @@ function toggleSettingsPanel() {
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);
}
+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) {
if (!currentPdfDoc) return;
n = Math.max(1, Math.min(pdfTotalPages, n));