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; }
|
.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; }
|
||||||
|
|
|
||||||
155
static/js/app.js
155
static/js/app.js
|
|
@ -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);
|
||||||
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');
|
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));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue