PDF-Zoom neu: Pinch zoomt um die Fingermitte und zeichnet scharf neu (Scrollen: an Ort und Stelle, die Stelle bleibt; Seiten: vergrößerte Seite mit einem Finger verschiebbar, am Rand weiterwischen blättert); pdfSmartZoomPage gab es nicht mehr; Seiten werden auf eigener Canvas gezeichnet und erst fertig eingesetzt (sw CACHE v169)
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 11m29s
Test / e2e (push) Failing after 1m28s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-11-04 15:37:12 +01:00
parent 0d712a7f21
commit 1e36d8600b
6 changed files with 251 additions and 52 deletions

View file

@ -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; }

View file

@ -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';

View file

@ -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') {

View file

@ -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.

View file

@ -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,

View file

@ -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',