419 lines
17 KiB
JavaScript
419 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PDF page by page (phones and e-readers by default; "Seiten" in the PDF panel):
|
|
// one page at a time, cropped to its text so the letters are as large as the
|
|
// screen allows, the next one drawn ahead for the turn. A tap on the left or
|
|
// right turns (the middle shows the bars), so do a swipe, the arrow keys,
|
|
// Space, PageUp/PageDown and the mouse wheel. Every page goes through
|
|
// _pdfDraw (58-pdf.js).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// A page nearly empty (only its number) would be zoomed to fill the screen with one digit:
|
|
// 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');
|
|
if (!contentEl) return;
|
|
contentEl.classList.add('pdf-paginated');
|
|
contentEl.style.overflow = 'hidden';
|
|
const pdfVp = document.getElementById('pdf-viewport');
|
|
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(); }
|
|
contentEl.addEventListener('click', _pdfPaginatedClick);
|
|
contentEl.addEventListener('wheel', _pdfPaginatedWheel, {passive: false});
|
|
_pdfShowPage(pdfCurrentPage);
|
|
}
|
|
|
|
/** Back to scrolling, at [page] (the one shown now). */
|
|
function exitPdfPaginatedMode(page) {
|
|
const contentEl = $('reader-content');
|
|
if (!contentEl) return;
|
|
contentEl.classList.remove('pdf-paginated');
|
|
contentEl.style.overflow = '';
|
|
contentEl.removeEventListener('click', _pdfPaginatedClick);
|
|
contentEl.removeEventListener('wheel', _pdfPaginatedWheel);
|
|
reRenderPdf(page || pdfCurrentPage);
|
|
}
|
|
|
|
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;
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|
const x = e.clientX - r.left;
|
|
const dir = x < r.width * 0.4 ? -1 : x > r.width * 0.6 ? 1 : 0;
|
|
if (!dir) return; // the middle: the bars come or go (62-reader.js)
|
|
// A turn is a turn only: not also the bars.
|
|
e.stopPropagation();
|
|
pdfGoToPage(pdfCurrentPage + dir);
|
|
}
|
|
|
|
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;
|
|
_pdfWheelSum += e.deltaY;
|
|
if (Math.abs(_pdfWheelSum) < 60) return;
|
|
const dir = _pdfWheelSum > 0 ? 1 : -1;
|
|
_pdfWheelSum = 0;
|
|
_pdfWheelAt = now + 250; // one turn per flick of the wheel
|
|
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 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) {
|
|
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) {
|
|
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 (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.
|
|
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
|
// Dragging a selection's handles (the text layer) isn't a swipe.
|
|
const sel = window.getSelection();
|
|
if (sel && !sel.isCollapsed) return;
|
|
pdfGoToPage(pdfCurrentPage + (dx > 0 ? -1 : 1));
|
|
}
|
|
|
|
/** The page number the reader shows (header input) follows the page. */
|
|
function _pdfSetCurrentPage(n) {
|
|
pdfCurrentPage = n;
|
|
const input = $('reader-progress-input');
|
|
if (input && currentPdfDoc && document.activeElement !== input) input.value = pdfPageLabel(n);
|
|
// With the book's own page numbers: which page of the file it is beside them.
|
|
const suffix = $('reader-progress-suffix');
|
|
if (suffix && currentPdfDoc && pdfTotalPages) {
|
|
suffix.textContent = _pdfLabels ? `(${n} / ${pdfTotalPages})` : `/ ${pdfTotalPages}`;
|
|
if (input) input.setAttribute('inputmode', _pdfLabels ? 'text' : 'decimal');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Go to page [n], scrolling or page by page: the table of contents, the page
|
|
* number in the header, bookmarks, keys and the app's "Zu Seite springen".
|
|
*/
|
|
function pdfGoToPage(n) {
|
|
if (!currentPdfDoc) return;
|
|
n = Math.max(1, Math.min(pdfTotalPages, n | 0));
|
|
const contentEl = $('reader-content');
|
|
if (!contentEl) return;
|
|
if (!readerSettings.pdfPaginated) {
|
|
const target = contentEl.querySelector(`#pdf-page-${n}`);
|
|
if (target) {
|
|
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
|
|
contentEl.scrollTop += target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top - 8;
|
|
}
|
|
_pdfSetCurrentPage(n);
|
|
return;
|
|
}
|
|
if (n === pdfCurrentPage && _pdfView && _pdfView.shown === n) return;
|
|
_pdfShowPage(n);
|
|
_pdfTurned();
|
|
}
|
|
|
|
// A turn is reading: the place is saved soon (not only by the 10 s timer), and e-readers
|
|
// get their refresh (native-eink.js listens).
|
|
function _pdfTurned() {
|
|
if (typeof _noteReaderInteraction === 'function') _noteReaderInteraction();
|
|
clearTimeout(_pdfTurnSaveTimer);
|
|
_pdfTurnSaveTimer = setTimeout(() => { if (currentPdfDoc) saveReaderProgress(); }, 1500);
|
|
try { window.dispatchEvent(new CustomEvent('diora-pdf-turn', {detail: {page: pdfCurrentPage}})); } catch (e) {}
|
|
}
|
|
|
|
/** Show page [n] alone, cropped; draw the next one ahead and let those far away go. */
|
|
async function _pdfShowPage(n) {
|
|
const v = _pdfView;
|
|
if (!v || !v.entries.length) return;
|
|
n = Math.max(1, Math.min(v.entries.length, n));
|
|
_pdfSetCurrentPage(n);
|
|
v.shown = n;
|
|
const token = ++v.showToken;
|
|
v.entries.forEach(e => { e.wrapper.style.display = e.num === n ? '' : 'none'; });
|
|
try {
|
|
await _pdfFitPage(v, v.entries[n - 1]);
|
|
if (token !== v.showToken || v.stopped) return;
|
|
if (n < v.entries.length) await _pdfFitPage(v, v.entries[n], true);
|
|
if (token !== v.showToken || v.stopped) return;
|
|
if (n > 1 && !v.entries[n - 2].drawn) await _pdfFitPage(v, v.entries[n - 2], true);
|
|
} catch (err) {
|
|
console.warn('PDF page', n, err);
|
|
}
|
|
if (token !== v.showToken || v.stopped) return;
|
|
v.entries.forEach(e => { if (Math.abs(e.num - n) > 2 && (e.drawn || e.task)) _pdfLetGo(e, v.pdf); });
|
|
}
|
|
|
|
/**
|
|
* Page [e] sized to the screen by its text (its ink, for scans): drawn when the size differs,
|
|
* then placed so the text is centred. [ahead]: for the next turn, not shown yet.
|
|
*/
|
|
async function _pdfFitPage(v, e, ahead) {
|
|
const contentEl = v.contentEl;
|
|
const page = await v.pdf.getPage(e.num);
|
|
if (v.stopped) return;
|
|
const natural = page.getViewport({scale: 1});
|
|
let box = _pdfPageTextBoxCache[e.num];
|
|
if (!box) {
|
|
box = await _computePdfTextBox(page, natural);
|
|
_pdfPageTextBoxCache[e.num] = box;
|
|
}
|
|
if (v.stopped) return;
|
|
const W = contentEl.clientWidth;
|
|
const H = contentEl.clientHeight;
|
|
const bw = Math.max(1, box.x2 - box.x1);
|
|
const bh = Math.max(1, box.y2 - box.y1);
|
|
const fitBox = Math.min((W - PDF_CROP_PAD * 2) / bw, (H - PDF_CROP_PAD * 2) / bh);
|
|
const fitPage = Math.min((W - PDF_CROP_PAD * 2) / natural.width, (H - PDF_CROP_PAD * 2) / natural.height);
|
|
const scale = Math.max(0.1, Math.min(fitBox, fitPage * PDF_CROP_MAX)) * (readerSettings.pdfZoom / 100);
|
|
if (!e.drawn || Math.abs(scale - e.drawn) / e.drawn > 0.05) {
|
|
if (!await _pdfDraw(e, page, scale)) return;
|
|
}
|
|
// 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 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,
|
|
* or — a scan without text — what isn't white on a small drawing of it.
|
|
*/
|
|
async function _computePdfTextBox(page, natural) {
|
|
const pageW = natural.width, pageH = natural.height;
|
|
// Tier 1: text-based
|
|
try {
|
|
const tc = currentPdfDoc ? await _pdfPageText(currentPdfDoc, page.pageNumber) : await page.getTextContent();
|
|
if (tc.items && tc.items.length) {
|
|
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
|
|
for (const item of tc.items) {
|
|
if (!item.transform || !item.str || !item.str.trim()) continue;
|
|
const tx = item.transform[4], ty = item.transform[5];
|
|
const iw = item.width || 0, ih = item.height || Math.abs(item.transform[3]) || 0;
|
|
for (const [px, py] of [[tx, ty], [tx + iw, ty + ih]]) {
|
|
const [vx, vy] = natural.convertToViewportPoint(px, py);
|
|
if (vx < x1) x1 = vx;
|
|
if (vx > x2) x2 = vx;
|
|
if (vy < y1) y1 = vy;
|
|
if (vy > y2) y2 = vy;
|
|
}
|
|
}
|
|
const area = (x2 - x1) * (y2 - y1);
|
|
if (isFinite(x1) && area > pageW * pageH * 0.25) {
|
|
return {x1: Math.max(0, x1), y1: Math.max(0, y1), x2: Math.min(pageW, x2), y2: Math.min(pageH, y2)};
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
|
|
// Tier 2: pixel analysis at scale 0.3
|
|
try {
|
|
const lowScale = 0.3;
|
|
const vp = page.getViewport({scale: lowScale});
|
|
const offCanvas = document.createElement('canvas');
|
|
offCanvas.width = Math.ceil(vp.width);
|
|
offCanvas.height = Math.ceil(vp.height);
|
|
const ctx = offCanvas.getContext('2d');
|
|
await page.render({canvasContext: ctx, viewport: vp}).promise;
|
|
const {data, width, height} = ctx.getImageData(0, 0, offCanvas.width, offCanvas.height);
|
|
offCanvas.width = offCanvas.height = 0;
|
|
|
|
let rMin = height, rMax = 0, cMin = width, cMax = 0;
|
|
for (let r = 0; r < height; r++) {
|
|
for (let c = 0; c < width; c++) {
|
|
const idx = (r * width + c) * 4;
|
|
if (data[idx] + data[idx + 1] + data[idx + 2] < 720) {
|
|
if (r < rMin) rMin = r;
|
|
if (r > rMax) rMax = r;
|
|
if (c < cMin) cMin = c;
|
|
if (c > cMax) cMax = c;
|
|
}
|
|
}
|
|
}
|
|
if (rMin < rMax && cMin < cMax) {
|
|
return {x1: cMin / lowScale, y1: rMin / lowScale, x2: cMax / lowScale, y2: rMax / lowScale};
|
|
}
|
|
} catch (e) {}
|
|
|
|
// Fallback: full page
|
|
return {x1: 0, y1: 0, x2: pageW, y2: pageH};
|
|
}
|