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));