From 1e36d8600b3b53340df53c6c91e3fc19dc22dc87 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Wed, 4 Nov 2026 15:37:12 +0100 Subject: [PATCH] =?UTF-8?q?PDF-Zoom=20neu:=20Pinch=20zoomt=20um=20die=20Fi?= =?UTF-8?q?ngermitte=20und=20zeichnet=20scharf=20neu=20(Scrollen:=20an=20O?= =?UTF-8?q?rt=20und=20Stelle,=20die=20Stelle=20bleibt;=20Seiten:=20vergr?= =?UTF-8?q?=C3=B6=C3=9Ferte=20Seite=20mit=20einem=20Finger=20verschiebbar,?= =?UTF-8?q?=20am=20Rand=20weiterwischen=20bl=C3=A4ttert);=20pdfSmartZoomPa?= =?UTF-8?q?ge=20gab=20es=20nicht=20mehr;=20Seiten=20werden=20auf=20eigener?= =?UTF-8?q?=20Canvas=20gezeichnet=20und=20erst=20fertig=20eingesetzt=20(sw?= =?UTF-8?q?=20CACHE=20v169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- static/css/app.css | 4 +- static/js/app/50-reader-state.js | 3 - static/js/app/58-pdf.js | 78 ++++++++++- static/js/app/64-reader-settings.js | 19 ++- static/js/app/66-pdf-paginated.js | 197 +++++++++++++++++++++++----- static/js/sw.js | 2 +- 6 files changed, 251 insertions(+), 52 deletions(-) diff --git a/static/css/app.css b/static/css/app.css index 7082ef6..85a06cc 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1851,8 +1851,10 @@ html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); } /* Page by page the page fills the whole reading area, without its padding: 66-pdf-paginated.js measures the area and places the page by its text box itself. */ .reader-content.pdf-paginated { overflow:hidden !important; display:block; padding:0 !important; } -/* The pages are as wide as they're drawn, not the text column's line length (--reader-max-width). */ +/* The pages are as wide as they're drawn, not the text column's line length (--reader-max-width); + zoomed wider than the screen, scrolling reaches their left edge too (not centred past it). */ .reader-content > #pdf-viewport { max-width:none; } +.reader-content:not(.pdf-paginated) > #pdf-viewport { width:max-content; min-width:100%; } .reader-content.pdf-paginated > #pdf-viewport { width:100%; height:100%; max-width:none; margin:0; } .pdf-paginated .pdf-page-wrapper { margin:0; } .pdf-paginated .pdf-page-wrapper { position:relative; } diff --git a/static/js/app/50-reader-state.js b/static/js/app/50-reader-state.js index fd6fa63..0e51459 100644 --- a/static/js/app/50-reader-state.js +++ b/static/js/app/50-reader-state.js @@ -45,9 +45,6 @@ function _readerScrollTick() { _readerScrollLastTs = now; $('reader-content')?.scrollBy({top: Math.round(_readerScrollDir * READER_SCROLL_SPEED * dt / 1000)}); } -let _pinchStartDist = 0; -let _pinchStartZoom = 100; -let _isPinching = false; if (typeof pdfjsLib !== 'undefined') { pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js'; diff --git a/static/js/app/58-pdf.js b/static/js/app/58-pdf.js index 5e7c99b..f93dd6b 100644 --- a/static/js/app/58-pdf.js +++ b/static/js/app/58-pdf.js @@ -439,26 +439,38 @@ async function _pdfDrawNow(e, page, cssScale) { let ratio = window.devicePixelRatio || 1; const area = viewport.width * viewport.height; if (area * ratio * ratio > PDF_MAX_PIXELS) ratio = Math.max(0.5, Math.sqrt(PDF_MAX_PIXELS / area)); - e.canvas.width = Math.round(viewport.width * ratio); - e.canvas.height = Math.round(viewport.height * ratio); - e.canvas.style.width = viewport.width + 'px'; - e.canvas.style.height = viewport.height + 'px'; + // Drawn on a canvas of its own and put in place when it's done: the page shown so far stays + // until then (after a zoom larger or smaller and blurred for a moment, never blank). + const canvas = document.createElement('canvas'); + canvas.className = 'pdf-page'; + canvas.width = Math.round(viewport.width * ratio); + canvas.height = Math.round(viewport.height * ratio); + canvas.style.width = viewport.width + 'px'; + canvas.style.height = viewport.height + 'px'; const task = page.render({ - canvasContext: e.canvas.getContext('2d'), + canvasContext: canvas.getContext('2d'), viewport, transform: ratio !== 1 ? [ratio, 0, 0, ratio, 0, 0] : null, }); e.task = task; + const discard = () => { canvas.width = 0; canvas.height = 0; }; try { await task.promise; } catch (err) { if (e.task === task) e.task = null; + discard(); if (err && err.name !== 'RenderingCancelledException') console.warn('PDF page', e.num, err); return false; } if (e.task === task) e.task = null; - if (e.token !== token) return false; + if (e.token !== token) { discard(); return false; } + const old = e.canvas; + if (old.parentNode) old.parentNode.replaceChild(canvas, old); + old.width = 0; + old.height = 0; + e.canvas = canvas; e.drawn = cssScale; + e.stale = false; const recolor = _pdfColorFilter(); e.canvas.classList.toggle('pdf-recolored', recolor); if (recolor) _pdfImageLayer(e, page, viewport, ratio, token).catch(err => console.warn('PDF page pictures', e.num, err)); @@ -478,6 +490,56 @@ function _pdfLetGo(e, pdf) { if (pdf) pdf.getPage(e.num).then(p => { if (!e.task) p.cleanup(); }, () => {}); } +/** + * What of which page is at (x, y) on the screen (client pixels): {e, x, y, rx, ry}, rx/ry the + * place on the page as a share of its width and height. + */ +function _pdfPointAt(x, y) { + const v = _pdfView; + if (!v || !v.entries.length) return null; + let lo = 0, hi = v.entries.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (v.entries[mid].wrapper.getBoundingClientRect().bottom > y) hi = mid; else lo = mid + 1; + } + const e = v.entries[lo]; + const r = e.canvas.getBoundingClientRect(); + return {e, x, y, rx: r.width ? (x - r.left) / r.width : 0, ry: r.height ? (y - r.top) / r.height : 0}; +} + +/** + * Scrolling, zoomed by [f]: every page's place grows or shrinks at once (the ones drawn are + * shown larger or smaller until drawn anew, those near the view first), and [anchor]'s point + * (_pdfPointAt) stays where it is on the screen. false when there's no scrolling view. + */ +function _pdfRezoomScroll(f, anchor) { + const v = _pdfView; + if (!v || v.stopped || readerSettings.pdfPaginated || !v.work) return false; + for (const e of v.entries) { + e.canvas.style.width = (parseFloat(e.canvas.style.width) || 0) * f + 'px'; + e.canvas.style.height = (parseFloat(e.canvas.style.height) || 0) * f + 'px'; + if (e.task) { + e.token++; + e.drawing = null; + try { e.task.cancel(); } catch (err) {} + } + if (e.drawn || e.task) { + _pdfDropLayers(e); + e.stale = true; + } + if (e.near) v.queue.add(e); + } + if (anchor) { + const contentEl = v.contentEl; + const r = anchor.e.canvas.getBoundingClientRect(); + if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect(); + contentEl.scrollLeft += r.left + anchor.rx * r.width - anchor.x; + contentEl.scrollTop += r.top + anchor.ry * r.height - anchor.y; + } + v.work(); + return true; +} + /** The page shown at the top of the scrolling view (binary search, the pages are in order). */ function _pdfTopPage(contentEl) { const v = _pdfView; @@ -596,13 +658,15 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { const cur = _pdfTopPage(contentEl); const next = Array.from(view.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0]; view.queue.delete(next); - if (next.near && !next.drawn) await draw(next); + if (next.near && (!next.drawn || next.stale)) await draw(next); } } finally { view.working = false; } } + view.work = work; + // Turning the device, a narrower window, the bars coming or going: laid out again at the same page. let lastW = contentEl.clientWidth, lastH = contentEl.clientHeight; if (typeof ResizeObserver === 'function') { diff --git a/static/js/app/64-reader-settings.js b/static/js/app/64-reader-settings.js index 8e7288e..51d57aa 100644 --- a/static/js/app/64-reader-settings.js +++ b/static/js/app/64-reader-settings.js @@ -384,18 +384,29 @@ function toggleSettingsPanel() { } } -async function applyPdfZoom(newZoom) { +/** + * The PDF at another zoom (%). Scrolling, [anchor] (_pdfPointAt: where the fingers were) stays + * in place, else the middle of the screen; page by page, the page is fitted anew (_pdfFitPage). + */ +async function applyPdfZoom(newZoom, anchor) { + const old = readerSettings.pdfZoom; 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 (!currentPdfDoc) return; if (readerSettings.pdfPaginated) { - pdfSmartZoomPage(pdfCurrentPage); - } else { - await reRenderPdf(); + await _pdfShowPage(pdfCurrentPage); + return; } + if (newZoom === old) return; + if (!anchor) { + const r = $('reader-content').getBoundingClientRect(); + anchor = _pdfPointAt(r.left + r.width / 2, r.top + r.height / 2); + } + if (!_pdfRezoomScroll(newZoom / old, anchor)) await reRenderPdf(); } // The page at the top of the view (scrolling) — 58-pdf.js keeps the pages in order. diff --git a/static/js/app/66-pdf-paginated.js b/static/js/app/66-pdf-paginated.js index 5b6a8a6..6a3cf45 100644 --- a/static/js/app/66-pdf-paginated.js +++ b/static/js/app/66-pdf-paginated.js @@ -13,11 +13,17 @@ // at most this many times the size the whole page would have. const PDF_CROP_MAX = 2.5; const PDF_CROP_PAD = 12; +// Zoom (readerSettings.pdfZoom, %): the PDF panel's slider and pinching. +const PDF_ZOOM_MIN = 50; +const PDF_ZOOM_MAX = 200; let _pdfTurnSaveTimer = 0; let _pdfWheelSum = 0; let _pdfWheelAt = 0; let _touchStartY = 0; +// A touch gesture under way: {kind: 'pinch' | 'pan' | 'after'} (_pdfTouchStart). +let _pdfGesture = null; +let _pdfPanAt = 0; // when a page was last moved by a finger (that touch is no tap) function enterPdfPaginatedMode() { const contentEl = $('reader-content'); @@ -25,7 +31,7 @@ function enterPdfPaginatedMode() { contentEl.classList.add('pdf-paginated'); contentEl.style.overflow = 'hidden'; const pdfVp = document.getElementById('pdf-viewport'); - if (pdfVp) pdfVp.style.zoom = ''; + if (pdfVp) { pdfVp.style.transform = ''; pdfVp.style.transformOrigin = ''; } // From scrolling: the drawing near the view stops, the pages are this mode's now. const v = _pdfView; if (v && v.observer) { v.observer.disconnect(); v.observer = null; v.queue.clear(); } @@ -47,6 +53,8 @@ function exitPdfPaginatedMode(page) { function _pdfPaginatedClick(e) { if (e.target.closest('a, button, input')) return; + // The page was just moved with a finger: no tap. + if (Date.now() - _pdfPanAt < 400) return; // Text just selected (the text layer): that tap wasn't a turn. const sel = window.getSelection(); if (sel && !sel.isCollapsed) return; @@ -62,6 +70,18 @@ function _pdfPaginatedClick(e) { function _pdfPaginatedWheel(e) { if (e.ctrlKey) return; // the browser's zoom e.preventDefault(); + // A page taller than the screen (zoomed in) is moved first; at its end the wheel turns. + const pe = _pdfView && _pdfView.entries[pdfCurrentPage - 1]; + if (pe && pe.place && pe.place.minY < pe.place.maxY) { + const y = Math.max(pe.place.minY, Math.min(pe.place.maxY, pe.pos.y - e.deltaY)); + if (Math.abs(y - pe.pos.y) > 0.5) { + _pdfPlace(pe, pe.pos.x, y); + _pdfKeepFocus(pe); + _pdfWheelSum = 0; + _pdfWheelAt = Date.now(); + return; + } + } const now = Date.now(); if (now - _pdfWheelAt > 400) _pdfWheelSum = 0; _pdfWheelAt = now; @@ -73,48 +93,123 @@ function _pdfPaginatedWheel(e) { pdfGoToPage(pdfCurrentPage + dir); } +// Touch (62-reader.js listens for every book; only a PDF's are handled here): +// - two fingers zoom around the point between them: a preview while they move (a CSS +// transform, the layout stays as it is), then drawn anew at the new size with that point +// where it was (scrolling: _pdfRezoomScroll; page by page: _pdfFitPage and v.focus); +// - page by page, a page larger than the screen moves with one finger, and pushed on past +// its edge sideways it turns; otherwise a swipe turns. function _pdfTouchStart(e) { + const v = _pdfView; + if (!currentPdfDoc || !v) return; + const contentEl = $('reader-content'); 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; - _touchStartY = e.touches[0].clientY; - _isPinching = false; + const a = e.touches[0], b = e.touches[1]; + const x = (a.clientX + b.clientX) / 2, y = (a.clientY + b.clientY) / 2; + const r = contentEl.getBoundingClientRect(); + const g = {kind: 'pinch', dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1, + zoom: readerSettings.pdfZoom, f: 1, cx: x - r.left, cy: y - r.top}; + if (readerSettings.pdfPaginated) { + g.entry = v.entries[pdfCurrentPage - 1]; + if (!g.entry || !g.entry.pos) return; + g.pos = {x: g.entry.pos.x, y: g.entry.pos.y}; + } else { + const vp = document.getElementById('pdf-viewport'); + if (!vp) return; + const vr = vp.getBoundingClientRect(); + g.vp = vp; + g.ox = x - vr.left; + g.oy = y - vr.top; + g.anchor = _pdfPointAt(x, y); + } + _pdfGesture = g; + contentEl.classList.add('pinch-active'); + return; + } + if (e.touches.length !== 1) return; + const t = e.touches[0]; + _touchStartX = t.clientX; + _touchStartY = t.clientY; + _pdfGesture = null; + const pe = readerSettings.pdfPaginated && v.entries[pdfCurrentPage - 1]; + if (pe && pe.place && (pe.place.minX < pe.place.maxX || pe.place.minY < pe.place.maxY)) { + _pdfGesture = {kind: 'pan', entry: pe, x: t.clientX, y: t.clientY, ox: pe.pos.x, oy: pe.pos.y, + dx: 0, dy: 0, moved: 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; + const g = _pdfGesture; + if (!g) return; + if (g.kind === 'pinch') { + if (e.touches.length !== 2) return; + e.preventDefault(); + const a = e.touches[0], b = e.touches[1]; + const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); + const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, g.zoom * dist / g.dist)); + g.f = zoom / g.zoom; + if (g.entry) { + // The page grows around the point between the fingers (its transform's origin is 0 0). + const x = g.cx - g.f * (g.cx - g.pos.x), y = g.cy - g.f * (g.cy - g.pos.y); + g.entry.inner.style.transform = `translate(${x}px, ${y}px) scale(${g.f})`; + } else { + g.vp.style.transformOrigin = `${g.ox}px ${g.oy}px`; + g.vp.style.transform = `scale(${g.f})`; + } + return; + } + if (g.kind === 'pan' && e.touches.length === 1) { + const t = e.touches[0]; + g.dx = t.clientX - g.x; + g.dy = t.clientY - g.y; + if (!g.moved && Math.hypot(g.dx, g.dy) < 8) return; + g.moved = true; + e.preventDefault(); + _pdfPlace(g.entry, g.ox + g.dx, g.oy + g.dy); + } } 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 && vp.style.zoom ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom; - // The live zoom was only a preview: the page is drawn anew at the new size. - if (vp) vp.style.zoom = ''; - const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10)); - applyPdfZoom(snapped); + const g = _pdfGesture; + if (g && g.kind === 'pinch') { + // The other finger may stay down a moment: it's no swipe. + _pdfGesture = {kind: 'after'}; + const contentEl = $('reader-content'); + if (contentEl) contentEl.classList.remove('pinch-active'); + const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, Math.round(g.zoom * g.f / 10) * 10)); + if (g.entry) { + const pe = g.entry, s = pe.drawn, p = pe.place; + // The page's point between the fingers stays there (_pdfFitPage); the preview stays + // until the page is drawn at its new size. + if (s && p) { + _pdfView.focus = {x: (g.cx - g.pos.x) / s, y: (g.cy - g.pos.y) / s, sx: g.cx / p.W, sy: g.cy / p.H}; + _pdfView.focusPage = pe.num; + } + if (zoom === readerSettings.pdfZoom) _pdfPlace(pe, g.pos.x, g.pos.y); + } else { + g.vp.style.transform = ''; + g.vp.style.transformOrigin = ''; + } + if (zoom !== readerSettings.pdfZoom) applyPdfZoom(zoom, g.anchor); return; } - if (!readerSettings.pdfPaginated || !e.changedTouches.length) return; + if (g && g.kind === 'after') { + if (!e.touches || !e.touches.length) _pdfGesture = null; + return; + } + if (g && g.kind === 'pan') { + _pdfGesture = null; + if (!g.moved) return; // a tap: the click turns or shows the bars + _pdfPanAt = Date.now(); + _pdfKeepFocus(g.entry); + // Already at its edge and pushed on sideways: the next or previous page (a move that only + // reaches the edge doesn't turn as well). + const p = g.entry.place; + const atEdge = g.dx < 0 ? g.ox <= p.minX + 1 : g.ox >= p.maxX - 1; + if (atEdge && Math.abs(g.dx) > 50 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5) pdfGoToPage(pdfCurrentPage + (g.dx > 0 ? -1 : 1)); + return; + } + if (!currentPdfDoc || !readerSettings.pdfPaginated || !e.changedTouches.length) return; const dx = e.changedTouches[0].clientX - _touchStartX; const dy = e.changedTouches[0].clientY - _touchStartY; // Sideways, clearly: a slanted scroll is not a turn. @@ -217,18 +312,48 @@ async function _pdfFitPage(v, e, ahead) { if (!e.drawn || Math.abs(scale - e.drawn) / e.drawn > 0.05) { if (!await _pdfDraw(e, page, scale)) return; } - // Centre the text box (at the scale it was drawn with). + // The text box centred where it fits (at the scale it was drawn with); larger than the + // screen (zoomed in), it can be moved up to its edges and starts at its top left — or + // where it was looked at (v.focus: a pinch, a move, the screen turned). const s = e.drawn; - const offsetX = (W - s * bw) / 2 - s * box.x1; - const offsetY = (H - s * bh) / 2 - s * box.y1; - e.inner.style.transform = `translate(${offsetX}px, ${offsetY}px)`; + const range = (size, lo, hi) => { + if (hi - lo + PDF_CROP_PAD * 2 <= size + 1) { + const c = (size - (hi - lo)) / 2 - lo; + return [c, c]; + } + return [size - PDF_CROP_PAD - hi, PDF_CROP_PAD - lo]; + }; + const [minX, maxX] = range(W, s * box.x1, s * box.x2); + const [minY, maxY] = range(H, s * box.y1, s * box.y2); + e.place = {minX, maxX, minY, maxY, W, H}; + const f = v.focusPage === e.num ? v.focus : null; e.inner.style.transformOrigin = '0 0'; + _pdfPlace(e, f ? W * f.sx - s * f.x : maxX, f ? H * f.sy - s * f.y : maxY); e.wrapper.style.overflow = 'hidden'; e.wrapper.style.width = W + 'px'; e.wrapper.style.height = H + 'px'; if (!ahead && v.shown === e.num) e.wrapper.style.display = ''; } +/** Page [e] moved to (x, y) (its offset in the reading area), kept within its edges (e.place). */ +function _pdfPlace(e, x, y) { + const p = e.place; + if (p) { + x = Math.max(p.minX, Math.min(p.maxX, x)); + y = Math.max(p.minY, Math.min(p.maxY, y)); + } + e.pos = {x, y}; + e.inner.style.transform = `translate(${x}px, ${y}px)`; +} + +/** Page [e] moved: that part of it stays in view when it's laid out again (another zoom, turned). */ +function _pdfKeepFocus(e) { + const v = _pdfView, p = e.place; + if (!v || !p || !e.drawn) return; + v.focus = {x: (p.W / 2 - e.pos.x) / e.drawn, y: (p.H / 2 - e.pos.y) / e.drawn, sx: 0.5, sy: 0.5}; + v.focusPage = e.num; +} + /** * Where the page's content is, in the page's own top-down coordinates at scale 1 (as * getViewport gives them, so turned pages and odd media boxes come out right): its text, diff --git a/static/js/sw.js b/static/js/sw.js index 711116c..c73287e 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v168'; +const CACHE = 'diora-v169'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',