Add PDF two-page spread mode and mobile pinch-to-zoom
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:
parent
e9c5b8058b
commit
916e8a568b
2 changed files with 124 additions and 38 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
155
static/js/app.js
155
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 = `
|
||||
<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.pdfSpread ? 'active' : ''}" id="rs-spread">Spread</button>
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue