@laminapdf/viewer 0.2.0-alpha.2 → 0.2.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -7
- package/dist/client.d.ts +51 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +94 -30
- package/dist/client.js.map +1 -1
- package/dist/find/controller.d.ts +73 -0
- package/dist/find/controller.d.ts.map +1 -0
- package/dist/find/controller.js +227 -0
- package/dist/find/controller.js.map +1 -0
- package/dist/find/fold.d.ts +23 -0
- package/dist/find/fold.d.ts.map +1 -0
- package/dist/find/fold.js +99 -0
- package/dist/find/fold.js.map +1 -0
- package/dist/find/page.d.ts +44 -0
- package/dist/find/page.d.ts.map +1 -0
- package/dist/find/page.js +173 -0
- package/dist/find/page.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/layers/box.d.ts +10 -0
- package/dist/layers/box.d.ts.map +1 -0
- package/dist/layers/box.js +19 -0
- package/dist/layers/box.js.map +1 -0
- package/dist/layers/highlight-layer.d.ts +31 -0
- package/dist/layers/highlight-layer.d.ts.map +1 -0
- package/dist/layers/highlight-layer.js +95 -0
- package/dist/layers/highlight-layer.js.map +1 -0
- package/dist/layers/spans.d.ts +42 -0
- package/dist/layers/spans.d.ts.map +1 -0
- package/dist/layers/spans.js +121 -0
- package/dist/layers/spans.js.map +1 -0
- package/dist/layers/structure-layer.d.ts +15 -0
- package/dist/layers/structure-layer.d.ts.map +1 -0
- package/dist/layers/structure-layer.js +152 -0
- package/dist/layers/structure-layer.js.map +1 -0
- package/dist/layers/text-layer.d.ts +53 -0
- package/dist/layers/text-layer.d.ts.map +1 -0
- package/dist/layers/text-layer.js +292 -0
- package/dist/layers/text-layer.js.map +1 -0
- package/dist/layout/range.d.ts +3 -0
- package/dist/layout/range.d.ts.map +1 -0
- package/dist/layout/range.js +5 -0
- package/dist/layout/range.js.map +1 -0
- package/dist/network/watch.d.ts +30 -0
- package/dist/network/watch.d.ts.map +1 -0
- package/dist/network/watch.js +181 -0
- package/dist/network/watch.js.map +1 -0
- package/dist/protocol.d.ts +127 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +2 -2
- package/dist/protocol.js.map +1 -1
- package/dist/render/tiles.d.ts +43 -3
- package/dist/render/tiles.d.ts.map +1 -1
- package/dist/render/tiles.js +62 -10
- package/dist/render/tiles.js.map +1 -1
- package/dist/source/http-range.d.ts +59 -0
- package/dist/source/http-range.d.ts.map +1 -0
- package/dist/source/http-range.js +284 -0
- package/dist/source/http-range.js.map +1 -0
- package/dist/thumbnails/strip.d.ts +37 -0
- package/dist/thumbnails/strip.d.ts.map +1 -0
- package/dist/thumbnails/strip.js +313 -0
- package/dist/thumbnails/strip.js.map +1 -0
- package/dist/view/budgets.d.ts +29 -0
- package/dist/view/budgets.d.ts.map +1 -0
- package/dist/view/budgets.js +63 -0
- package/dist/view/budgets.js.map +1 -0
- package/dist/view/touch.d.ts +71 -0
- package/dist/view/touch.d.ts.map +1 -0
- package/dist/view/touch.js +220 -0
- package/dist/view/touch.js.map +1 -0
- package/dist/view/viewer.d.ts +42 -1
- package/dist/view/viewer.d.ts.map +1 -1
- package/dist/view/viewer.js +433 -69
- package/dist/view/viewer.js.map +1 -1
- package/dist/worker/document.d.ts +33 -1
- package/dist/worker/document.d.ts.map +1 -1
- package/dist/worker/document.js +181 -24
- package/dist/worker/document.js.map +1 -1
- package/dist/worker/entry.d.ts +1 -1
- package/dist/worker/entry.d.ts.map +1 -1
- package/dist/worker/entry.js +3 -1
- package/dist/worker/entry.js.map +1 -1
- package/dist/worker/find.d.ts +7 -0
- package/dist/worker/find.d.ts.map +1 -0
- package/dist/worker/find.js +59 -0
- package/dist/worker/find.js.map +1 -0
- package/dist/worker/methods.d.ts.map +1 -1
- package/dist/worker/methods.js +31 -4
- package/dist/worker/methods.js.map +1 -1
- package/dist/worker/navigation.d.ts +6 -0
- package/dist/worker/navigation.d.ts.map +1 -0
- package/dist/worker/navigation.js +16 -0
- package/dist/worker/navigation.js.map +1 -0
- package/dist/worker/network.d.ts +4 -0
- package/dist/worker/network.d.ts.map +1 -0
- package/dist/worker/network.js +24 -0
- package/dist/worker/network.js.map +1 -0
- package/dist/worker/render.d.ts +5 -2
- package/dist/worker/render.d.ts.map +1 -1
- package/dist/worker/render.js +14 -7
- package/dist/worker/render.js.map +1 -1
- package/dist/worker/text.d.ts +17 -0
- package/dist/worker/text.d.ts.map +1 -0
- package/dist/worker/text.js +75 -0
- package/dist/worker/text.js.map +1 -0
- package/dist/zoom/destination.d.ts +18 -0
- package/dist/zoom/destination.d.ts.map +1 -0
- package/dist/zoom/destination.js +56 -0
- package/dist/zoom/destination.js.map +1 -0
- package/package.json +6 -5
package/dist/view/viewer.js
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
// The viewer: a scroll container whose spacer is the whole document's height, and page
|
|
2
2
|
// shells for only the pages in view plus a margin. Each mounted page borrows a canvas from a
|
|
3
3
|
// fixed pool and paints in two passes, a low-resolution whole page and then sharp tiles over the
|
|
4
|
-
// part in view, through the document worker at `visible` or `prefetch` priority. A page
|
|
4
|
+
// part in view, through the document worker at `visible` or `prefetch` priority. A page too large
|
|
5
|
+
// for its canvas at the full pixel ratio (past a pixel cap, or the pool's fit) keeps that canvas as
|
|
6
|
+
// a backdrop at a lower one, and a detail canvas over the part in view takes the sharp tiles. A page
|
|
5
7
|
// that leaves the margin aborts its renders, and a result that arrives for it anyway is dropped,
|
|
6
8
|
// never drawn. Zoom keeps the document point under the cursor (or the viewport centre) where it
|
|
7
9
|
// is; during a gesture the canvases are stretched by CSS and the sharp re-render waits until the
|
|
8
10
|
// gesture settles. Framework-free: UI layers read `getState()`, `subscribe()` and the events.
|
|
9
|
-
import {
|
|
11
|
+
import { pagePixels } from "@laminapdf/render";
|
|
12
|
+
import { BitmapCache } from "../canvas/cache.js";
|
|
10
13
|
import { CanvasPool } from "../canvas/pool.js";
|
|
14
|
+
import { FindController } from "../find/controller.js";
|
|
15
|
+
import { HighlightLayer } from "../layers/highlight-layer.js";
|
|
16
|
+
import { TextLayer } from "../layers/text-layer.js";
|
|
11
17
|
import { isRotation, PageLayout, rotated } from "../layout/page-layout.js";
|
|
18
|
+
import { range } from "../layout/range.js";
|
|
12
19
|
import { renderKey, renderKeyFields } from "../protocol.js";
|
|
13
|
-
import { lowScale, tilesFor } from "../render/tiles.js";
|
|
20
|
+
import { backdropRatio, bounds, contains, lowScale, sharpScale, tilesFor } from "../render/tiles.js";
|
|
21
|
+
import { destinationView } from "../zoom/destination.js";
|
|
14
22
|
import { contentPosition, percentOfScale, pointAt, scaleFor, scrollToPoint, } from "../zoom/zoom.js";
|
|
23
|
+
import { deviceHints, viewerBudgets } from "./budgets.js";
|
|
24
|
+
import { TouchGestures } from "./touch.js";
|
|
15
25
|
/** The built-in layer slots, bottom to top, above the canvas. */
|
|
16
26
|
const SLOTS = ["text", "annot", "highlight"];
|
|
17
27
|
/** The tallest scroll space browsers handle (Firefox stops near 17.9 M CSS px). */
|
|
@@ -23,6 +33,10 @@ function isAbort(e) {
|
|
|
23
33
|
function geometryOf(layout) {
|
|
24
34
|
return `${layout.scale}:${layout.rotation}`;
|
|
25
35
|
}
|
|
36
|
+
/** The view rotation a `geometryOf` string was painted for. */
|
|
37
|
+
function rotationOf(geometry) {
|
|
38
|
+
return Number(geometry.slice(geometry.indexOf(":") + 1));
|
|
39
|
+
}
|
|
26
40
|
/** A closed ImageBitmap has no size: one the cache evicted while a pass held it. */
|
|
27
41
|
function isClosed(bitmap) {
|
|
28
42
|
return bitmap.width === 0 || bitmap.height === 0;
|
|
@@ -38,11 +52,22 @@ export class Viewer {
|
|
|
38
52
|
#settleMs;
|
|
39
53
|
#dpr;
|
|
40
54
|
#pool;
|
|
55
|
+
#detailPool;
|
|
56
|
+
#backdropMaxPixels;
|
|
57
|
+
#canvasMaxPixels;
|
|
58
|
+
#touch;
|
|
41
59
|
#cache;
|
|
42
60
|
#root;
|
|
43
61
|
#spacer;
|
|
44
62
|
#pages = new Map();
|
|
45
63
|
#layers = [];
|
|
64
|
+
/** The text layer, unless `text: false`. */
|
|
65
|
+
#text;
|
|
66
|
+
#highlights;
|
|
67
|
+
/** Find in this viewer's document: query, options, matches, next and previous. */
|
|
68
|
+
find;
|
|
69
|
+
/** Pages kept mounted whatever the scroll (`keepMounted`): those a selection touches. */
|
|
70
|
+
#pinned = new Set();
|
|
46
71
|
#listeners = new Set();
|
|
47
72
|
#events = new Map();
|
|
48
73
|
#cleanup = [];
|
|
@@ -65,13 +90,26 @@ export class Viewer {
|
|
|
65
90
|
this.#gap = options.gap ?? 16;
|
|
66
91
|
this.#settleMs = options.settleMs ?? 150;
|
|
67
92
|
this.#dpr = options.dpr ?? (() => globalThis.devicePixelRatio || 1);
|
|
68
|
-
|
|
69
|
-
this.#
|
|
93
|
+
const budgets = viewerBudgets(options.device ?? deviceHints());
|
|
94
|
+
this.#pool = new CanvasPool({ ...budgets.pool, ...options.pool });
|
|
95
|
+
this.#detailPool = new CanvasPool({ ...budgets.detailPool, ...options.detailPool });
|
|
96
|
+
this.#cache = new BitmapCache(options.cacheBytes ?? budgets.cacheBytes);
|
|
97
|
+
this.#backdropMaxPixels = options.backdropMaxPixels ?? budgets.backdropMaxPixels;
|
|
98
|
+
this.#canvasMaxPixels = options.canvasMaxPixels ?? budgets.canvasMaxPixels;
|
|
99
|
+
this.#touch = new TouchGestures(this.#touchHost());
|
|
70
100
|
this.#zoom = options.zoom ?? "fit-width";
|
|
71
101
|
this.#root = document.createElement("div");
|
|
72
102
|
this.#root.className = "lamina-viewer";
|
|
73
103
|
this.#root.tabIndex = 0;
|
|
74
|
-
|
|
104
|
+
// `touch-action: none`: the viewer pans and zooms under a finger itself, so the browser's own
|
|
105
|
+
// page zoom never fights it, and zooming the chrome stays the browser's (UI/UX plan §5.4).
|
|
106
|
+
Object.assign(this.#root.style, {
|
|
107
|
+
position: "relative",
|
|
108
|
+
overflow: "auto",
|
|
109
|
+
width: "100%",
|
|
110
|
+
height: "100%",
|
|
111
|
+
touchAction: "none",
|
|
112
|
+
});
|
|
75
113
|
this.#spacer = document.createElement("div");
|
|
76
114
|
this.#spacer.className = "lamina-viewer-content";
|
|
77
115
|
Object.assign(this.#spacer.style, { position: "relative" });
|
|
@@ -86,6 +124,29 @@ export class Viewer {
|
|
|
86
124
|
visibleRange: { first: 0, last: -1 },
|
|
87
125
|
};
|
|
88
126
|
this.#listen();
|
|
127
|
+
if (options.text !== false) {
|
|
128
|
+
const host = {
|
|
129
|
+
root: this.#root,
|
|
130
|
+
client: this.#client,
|
|
131
|
+
rotation: () => this.#rotation,
|
|
132
|
+
keepMounted: (pages) => this.keepMounted(pages),
|
|
133
|
+
};
|
|
134
|
+
this.#text = new TextLayer(host, options.text === true || options.text === undefined ? {} : options.text);
|
|
135
|
+
this.addLayer(this.#text);
|
|
136
|
+
}
|
|
137
|
+
this.find = new FindController({
|
|
138
|
+
find: (query, find, pages, signal) => this.#client.find(query, find, pages, { signal }),
|
|
139
|
+
pageCount: () => this.#sizes.length,
|
|
140
|
+
currentPage: () => this.#state.currentPage,
|
|
141
|
+
reveal: (page, quads) => this.#reveal(page, quads),
|
|
142
|
+
redraw: (pages) => this.#highlights.refresh(pages),
|
|
143
|
+
});
|
|
144
|
+
this.#highlights = new HighlightLayer({
|
|
145
|
+
matchesOn: (page) => this.find.matchesOn(page),
|
|
146
|
+
points: (page) => this.#sizes[page],
|
|
147
|
+
rotation: () => this.#rotation,
|
|
148
|
+
});
|
|
149
|
+
this.addLayer(this.#highlights);
|
|
89
150
|
}
|
|
90
151
|
/** The scroll container (append it, or use {@link mount}). */
|
|
91
152
|
get element() {
|
|
@@ -101,6 +162,9 @@ export class Viewer {
|
|
|
101
162
|
this.#unmountAll();
|
|
102
163
|
this.#cache.clear();
|
|
103
164
|
this.#document = document;
|
|
165
|
+
this.#pinned = new Set();
|
|
166
|
+
this.find.reset();
|
|
167
|
+
this.#text?.open(document);
|
|
104
168
|
this.#sizes = document.pages.map((p) => ({ width: p.width, height: p.height }));
|
|
105
169
|
this.#layout = undefined;
|
|
106
170
|
this.#root.scrollTop = 0;
|
|
@@ -128,6 +192,18 @@ export class Viewer {
|
|
|
128
192
|
set.add(l);
|
|
129
193
|
return () => set.delete(l);
|
|
130
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Keeps `pages` mounted wherever the view scrolls, until the next call replaces them: the pages
|
|
197
|
+
* a text selection touches, so scrolling never takes the selection away. A pinned page out of
|
|
198
|
+
* view keeps its shell and its text; its canvas goes back to the pool like any other.
|
|
199
|
+
*/
|
|
200
|
+
keepMounted(pages) {
|
|
201
|
+
const next = new Set(pages);
|
|
202
|
+
if (next.size === this.#pinned.size && [...next].every((p) => this.#pinned.has(p)))
|
|
203
|
+
return;
|
|
204
|
+
this.#pinned = next;
|
|
205
|
+
this.#schedule();
|
|
206
|
+
}
|
|
131
207
|
/** Adds a layer to every page, mounted now and later; returns a function that removes it. */
|
|
132
208
|
addLayer(layer) {
|
|
133
209
|
this.#layers.push(layer);
|
|
@@ -143,6 +219,7 @@ export class Viewer {
|
|
|
143
219
|
}
|
|
144
220
|
/** Scrolls page `index` to the top of the viewport (or its centre). */
|
|
145
221
|
goToPage(index, options = {}) {
|
|
222
|
+
this.#touch.stopMomentum();
|
|
146
223
|
const layout = this.#layout;
|
|
147
224
|
if (layout === undefined || layout.pageCount === 0)
|
|
148
225
|
return;
|
|
@@ -154,6 +231,28 @@ export class Viewer {
|
|
|
154
231
|
: Math.max(0, top);
|
|
155
232
|
this.#schedule();
|
|
156
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Goes to `target` (an outline item's, a named destination's or a link's, from
|
|
236
|
+
* `ViewerClient.outline` or `namedDestination`): its zoom if it sets one, then its point at the
|
|
237
|
+
* viewport's top-left, or its page's top when it names no point (ISO 32000-2 §12.3.2.2).
|
|
238
|
+
*/
|
|
239
|
+
goToDestination(target) {
|
|
240
|
+
this.#touch.stopMomentum();
|
|
241
|
+
const size = this.#sizes[target.page];
|
|
242
|
+
if (this.#layout === undefined || size === undefined)
|
|
243
|
+
return;
|
|
244
|
+
const view = destinationView(target, size, this.#rotation, this.#viewport(), this.#gap);
|
|
245
|
+
if (view.zoom !== undefined) {
|
|
246
|
+
this.#zoom = view.zoom;
|
|
247
|
+
this.#rezoom(undefined);
|
|
248
|
+
}
|
|
249
|
+
const layout = this.#layout;
|
|
250
|
+
const at = contentPosition(layout, this.#root.clientWidth, { page: view.page, x: view.x ?? 0, y: view.y ?? 0 });
|
|
251
|
+
this.#root.scrollTop = Math.max(0, view.y === undefined ? layout.offsetOf(view.page) - this.#gap / 2 : at.y);
|
|
252
|
+
if (view.x !== undefined)
|
|
253
|
+
this.#root.scrollLeft = Math.max(0, at.x);
|
|
254
|
+
this.#schedule();
|
|
255
|
+
}
|
|
157
256
|
/**
|
|
158
257
|
* Sets the zoom (a mode, or a percentage clamped to 10–800 %), keeping the document point at
|
|
159
258
|
* `anchor` (viewport CSS px; default its centre) where it is.
|
|
@@ -173,6 +272,9 @@ export class Viewer {
|
|
|
173
272
|
throw new RangeError(`rotate() takes a multiple of 90, got ${delta}`);
|
|
174
273
|
this.#rotation = next;
|
|
175
274
|
this.#rezoom(undefined);
|
|
275
|
+
// A half turn keeps every page's size, so no layer hears of it from a resize.
|
|
276
|
+
this.#text?.refresh();
|
|
277
|
+
this.#highlights.refresh();
|
|
176
278
|
}
|
|
177
279
|
/** The document point at viewport position (`x`, `y`) in CSS px. */
|
|
178
280
|
pointAt(x, y) {
|
|
@@ -201,6 +303,7 @@ export class Viewer {
|
|
|
201
303
|
canvases: this.#pool.inUse,
|
|
202
304
|
peakCanvases: this.#pool.peak,
|
|
203
305
|
maxCanvases: this.#pool.max,
|
|
306
|
+
detailCanvases: this.#detailPool.inUse,
|
|
204
307
|
drawsOnUnmounted: this.#drawsOnUnmounted,
|
|
205
308
|
dropped: this.#dropped,
|
|
206
309
|
aborted: this.#aborted,
|
|
@@ -216,11 +319,51 @@ export class Viewer {
|
|
|
216
319
|
clearTimeout(this.#settling);
|
|
217
320
|
this.#unmountAll();
|
|
218
321
|
this.#cache.clear();
|
|
322
|
+
this.find.reset();
|
|
323
|
+
this.#text?.destroy();
|
|
219
324
|
for (const f of this.#cleanup.splice(0))
|
|
220
325
|
f();
|
|
221
326
|
this.#root.remove();
|
|
222
327
|
}
|
|
223
328
|
// --- internals ---
|
|
329
|
+
/**
|
|
330
|
+
* Scrolls so that `quads` (a find match, in page `page`'s points) are in view: nothing moves
|
|
331
|
+
* when they already are, else they come to the viewport's centre.
|
|
332
|
+
*/
|
|
333
|
+
#reveal(page, quads) {
|
|
334
|
+
const layout = this.#layout;
|
|
335
|
+
const size = this.#sizes[page];
|
|
336
|
+
if (layout === undefined || size === undefined || page >= layout.pageCount || quads.length < 8)
|
|
337
|
+
return;
|
|
338
|
+
this.#touch.stopMomentum();
|
|
339
|
+
// The corners as fractions of the page, turned with the view (clockwise).
|
|
340
|
+
const turn = (fx, fy) => ({ 0: [fx, fy], 90: [1 - fy, fx], 180: [1 - fx, 1 - fy], 270: [fy, 1 - fx] })[this.#rotation];
|
|
341
|
+
const xs = [];
|
|
342
|
+
const ys = [];
|
|
343
|
+
for (let at = 0; at + 1 < quads.length; at += 2) {
|
|
344
|
+
const [x, y] = turn((quads[at] ?? 0) / size.width, (quads[at + 1] ?? 0) / size.height);
|
|
345
|
+
xs.push(x);
|
|
346
|
+
ys.push(y);
|
|
347
|
+
}
|
|
348
|
+
const viewportWidth = this.#root.clientWidth;
|
|
349
|
+
const pageLeft = layout.leftOf(page, viewportWidth);
|
|
350
|
+
const pageTop = layout.offsetOf(page);
|
|
351
|
+
const width = layout.widths[page] ?? 0;
|
|
352
|
+
const height = layout.heights[page] ?? 0;
|
|
353
|
+
const left = pageLeft + Math.min(...xs) * width;
|
|
354
|
+
const right = pageLeft + Math.max(...xs) * width;
|
|
355
|
+
const top = pageTop + Math.min(...ys) * height;
|
|
356
|
+
const bottom = pageTop + Math.max(...ys) * height;
|
|
357
|
+
const view = this.#scroll();
|
|
358
|
+
const viewportHeight = this.#root.clientHeight;
|
|
359
|
+
if (top < view.top || bottom > view.top + viewportHeight) {
|
|
360
|
+
this.#root.scrollTop = Math.max(0, (top + bottom) / 2 - viewportHeight / 2);
|
|
361
|
+
}
|
|
362
|
+
if (left < view.left || right > view.left + viewportWidth) {
|
|
363
|
+
this.#root.scrollLeft = Math.max(0, (left + right) / 2 - viewportWidth / 2);
|
|
364
|
+
}
|
|
365
|
+
this.#schedule();
|
|
366
|
+
}
|
|
224
367
|
#scroll() {
|
|
225
368
|
return { left: this.#root.scrollLeft, top: this.#root.scrollTop };
|
|
226
369
|
}
|
|
@@ -282,12 +425,21 @@ export class Viewer {
|
|
|
282
425
|
this.#root.scrollTop = scroll.top;
|
|
283
426
|
this.#root.scrollLeft = scroll.left;
|
|
284
427
|
// Renders for the old geometry are stale: abort them (a page that failed gets another try);
|
|
285
|
-
// the canvases stretch until the gesture settles and the new pass paints.
|
|
428
|
+
// the canvases stretch until the gesture settles and the new pass paints, so no page is sharp.
|
|
429
|
+
let unsharpened = false;
|
|
286
430
|
for (const view of this.#pages.values()) {
|
|
287
431
|
this.#abort(view);
|
|
432
|
+
// The new geometry needs other canvases: a page that waited for one tries again.
|
|
433
|
+
view.waiting = false;
|
|
288
434
|
if (view.state === "error")
|
|
289
435
|
view.state = "placeholder";
|
|
436
|
+
if (view.state === "sharp") {
|
|
437
|
+
view.state = "low";
|
|
438
|
+
unsharpened = true;
|
|
439
|
+
}
|
|
290
440
|
}
|
|
441
|
+
if (unsharpened)
|
|
442
|
+
this.#emit("renderstats", this.stats());
|
|
291
443
|
clearTimeout(this.#settling);
|
|
292
444
|
this.#settling = setTimeout(() => {
|
|
293
445
|
this.#settling = undefined;
|
|
@@ -320,6 +472,7 @@ export class Viewer {
|
|
|
320
472
|
const frame = {
|
|
321
473
|
layout,
|
|
322
474
|
top: this.#root.scrollTop,
|
|
475
|
+
left: this.#root.scrollLeft,
|
|
323
476
|
width: this.#root.clientWidth,
|
|
324
477
|
height: this.#root.clientHeight,
|
|
325
478
|
};
|
|
@@ -337,13 +490,17 @@ export class Viewer {
|
|
|
337
490
|
this.#paintRange(frame, visible, first, last);
|
|
338
491
|
this.#emit("renderstats", this.stats());
|
|
339
492
|
}
|
|
340
|
-
/**
|
|
493
|
+
/**
|
|
494
|
+
* Unmounts the shells outside `first..last` (but the pinned ones), mounts the missing ones and
|
|
495
|
+
* positions them all.
|
|
496
|
+
*/
|
|
341
497
|
#placeShells(frame, first, last) {
|
|
342
498
|
const { layout, width } = frame;
|
|
499
|
+
const pinned = [...this.#pinned].filter((i) => i >= 0 && i < layout.pageCount && (i < first || i > last));
|
|
343
500
|
for (const [i, view] of this.#pages)
|
|
344
|
-
if (i < first || i > last)
|
|
501
|
+
if ((i < first || i > last) && !this.#pinned.has(i))
|
|
345
502
|
this.#unmount(view);
|
|
346
|
-
for (
|
|
503
|
+
for (const i of [...pinned, ...range(first, last)]) {
|
|
347
504
|
const view = this.#pages.get(i) ?? this.#mount(i);
|
|
348
505
|
const w = layout.widths[i];
|
|
349
506
|
const h = layout.heights[i];
|
|
@@ -386,7 +543,9 @@ export class Viewer {
|
|
|
386
543
|
controller: new AbortController(),
|
|
387
544
|
state: "placeholder",
|
|
388
545
|
painted: "",
|
|
546
|
+
scale: 0,
|
|
389
547
|
tiles: new Set(),
|
|
548
|
+
detail: undefined,
|
|
390
549
|
busy: false,
|
|
391
550
|
waiting: false,
|
|
392
551
|
pass: 0,
|
|
@@ -405,6 +564,10 @@ export class Viewer {
|
|
|
405
564
|
slot = document.createElement("div");
|
|
406
565
|
slot.className = `lamina-layer-${name}`;
|
|
407
566
|
Object.assign(slot.style, { position: "absolute", inset: "0" });
|
|
567
|
+
// The slots above the text cover the whole page: taking the pointer, they would sit between
|
|
568
|
+
// every press and the spans, and no drag could select text.
|
|
569
|
+
if (name !== "text")
|
|
570
|
+
slot.style.pointerEvents = "none";
|
|
408
571
|
view.slots.set(name, slot);
|
|
409
572
|
view.shell.append(slot);
|
|
410
573
|
}
|
|
@@ -430,6 +593,7 @@ export class Viewer {
|
|
|
430
593
|
this.#unmount(view);
|
|
431
594
|
}
|
|
432
595
|
#releaseCanvas(view) {
|
|
596
|
+
this.#releaseDetail(view);
|
|
433
597
|
if (view.canvas !== undefined)
|
|
434
598
|
this.#pool.release(view.canvas);
|
|
435
599
|
view.canvas = undefined;
|
|
@@ -442,24 +606,71 @@ export class Viewer {
|
|
|
442
606
|
other.waiting = false;
|
|
443
607
|
this.#schedule();
|
|
444
608
|
}
|
|
445
|
-
/** The
|
|
446
|
-
#
|
|
447
|
-
const { layout, top, height } = frame;
|
|
448
|
-
const documentId = this.#document?.id ?? 0;
|
|
609
|
+
/** The part of page `index` in view in `frame`, in CSS px from the page's top-left. */
|
|
610
|
+
#inView(index, frame) {
|
|
611
|
+
const { layout, top, left, width, height } = frame;
|
|
449
612
|
const cssW = layout.widths[index];
|
|
450
613
|
const cssH = layout.heights[index];
|
|
451
614
|
const pageTop = layout.offsetOf(index);
|
|
615
|
+
const pageLeft = layout.leftOf(index, width);
|
|
616
|
+
const x0 = Math.max(0, left - pageLeft);
|
|
617
|
+
const x1 = Math.min(cssW, left + width - pageLeft);
|
|
452
618
|
const y0 = Math.max(0, top - pageTop);
|
|
453
619
|
const y1 = Math.min(cssH, top + height - pageTop);
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
620
|
+
return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* The sharp tiles of page `index` over `area` (CSS px from the page's top-left) at `scale` (device
|
|
624
|
+
* px per pt), with their render requests and keys. They lie inside the page as the renderer sizes
|
|
625
|
+
* it at that scale.
|
|
626
|
+
*/
|
|
627
|
+
#tiles(index, layout, scale, area) {
|
|
628
|
+
const page = pagePixels(this.#sizes[index], scale, layout.rotation);
|
|
629
|
+
return tilesFor(area, page, scale / layout.scale).map((clip) => this.#tile(index, layout, scale, clip));
|
|
630
|
+
}
|
|
631
|
+
/** Page `index` whole at `scale`, as one tile. */
|
|
632
|
+
#wholePage(index, layout, scale) {
|
|
633
|
+
const page = pagePixels(this.#sizes[index], scale, layout.rotation);
|
|
634
|
+
return this.#tile(index, layout, scale, { x: 0, y: 0, ...page });
|
|
635
|
+
}
|
|
636
|
+
/** The tile `clip` of page `index` at `scale`: its render request and cache key. */
|
|
637
|
+
#tile(index, layout, scale, clip) {
|
|
638
|
+
const request = { page: index, scale, rotation: layout.rotation, clip };
|
|
639
|
+
return { clip, request, key: renderKey(request, this.#document?.id ?? 0) };
|
|
640
|
+
}
|
|
641
|
+
/** The device px per pt of a sharp tile: the layout's scale × the device pixel ratio. */
|
|
642
|
+
#fullScale(layout) {
|
|
643
|
+
return layout.scale * this.#dpr();
|
|
644
|
+
}
|
|
645
|
+
/** Whether `view`'s canvas is below the full pixel ratio, so the part in view needs a detail canvas. */
|
|
646
|
+
#needsDetail(view, layout) {
|
|
647
|
+
return view.scale < this.#fullScale(layout) * (1 - 1e-9);
|
|
648
|
+
}
|
|
649
|
+
/** Every sharp tile now in view is drawn: into the detail canvas, or else into the page canvas. */
|
|
650
|
+
#sharpInView(view, frame) {
|
|
651
|
+
const { layout } = frame;
|
|
652
|
+
const area = this.#inView(view.index, frame);
|
|
653
|
+
if (!this.#needsDetail(view, layout)) {
|
|
654
|
+
return this.#tiles(view.index, layout, view.scale, area).every((t) => view.tiles.has(t.key));
|
|
655
|
+
}
|
|
656
|
+
const tiles = this.#tiles(view.index, layout, this.#fullScale(layout), area);
|
|
657
|
+
// Nothing of the page in view (off to one side, or only the gap below it): nothing to
|
|
658
|
+
// sharpen, and its detail canvas goes back to the pool for a page that has.
|
|
659
|
+
if (tiles.length === 0) {
|
|
660
|
+
this.#releaseDetail(view);
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
const detail = view.detail;
|
|
664
|
+
if (detail === undefined || detail.geometry !== geometryOf(layout))
|
|
665
|
+
return false;
|
|
666
|
+
return tiles.every((t) => detail.tiles.has(t.key));
|
|
459
667
|
}
|
|
460
668
|
/** Starts page `index`'s pass if it isn't busy and its canvas isn't done for `frame` yet. */
|
|
461
669
|
#paint(index, priority, frame) {
|
|
462
670
|
const view = this.#pages.get(index);
|
|
671
|
+
// A page in the margin gives its detail canvas back for the pages in view.
|
|
672
|
+
if (view !== undefined && priority === "prefetch")
|
|
673
|
+
this.#releaseDetail(view);
|
|
463
674
|
if (view === undefined || this.#document === undefined || view.busy || view.waiting || view.state === "error") {
|
|
464
675
|
return;
|
|
465
676
|
}
|
|
@@ -467,10 +678,8 @@ export class Viewer {
|
|
|
467
678
|
if (view.painted === geometryOf(layout)) {
|
|
468
679
|
if (priority === "prefetch")
|
|
469
680
|
return;
|
|
470
|
-
const canvas = view.canvas;
|
|
471
|
-
const ratio = canvas === undefined ? 0 : canvas.width / layout.widths[index];
|
|
472
681
|
// Sharp means every tile now in view is drawn: scrolling down a tall page sharpens the rest.
|
|
473
|
-
if (
|
|
682
|
+
if (view.scale > 0 && this.#sharpInView(view, frame)) {
|
|
474
683
|
if (view.state !== "sharp") {
|
|
475
684
|
view.state = "sharp";
|
|
476
685
|
this.#emit("renderstats", this.stats());
|
|
@@ -517,7 +726,15 @@ export class Viewer {
|
|
|
517
726
|
return;
|
|
518
727
|
if (priority === "prefetch")
|
|
519
728
|
return;
|
|
520
|
-
|
|
729
|
+
if (!this.#needsDetail(view, layout)) {
|
|
730
|
+
this.#releaseDetail(view);
|
|
731
|
+
await this.#paintTiles(pass_, this.#tiles(index, layout, view.scale, this.#inView(index, frame)));
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
// The part in view first, sharp; then the whole backdrop in one render (it is capped at
|
|
735
|
+
// BACKDROP_MAX_PIXELS, below the renderer's limit), for what scrolls into view next.
|
|
736
|
+
await this.#paintDetail(pass_, frame);
|
|
737
|
+
await this.#paintTiles(pass_, [this.#wholePage(index, layout, view.scale)]);
|
|
521
738
|
}
|
|
522
739
|
/** The blurry pass: resizes the canvas and stretches a low-resolution page over it. */
|
|
523
740
|
async #paintLow(pass) {
|
|
@@ -539,22 +756,65 @@ export class Viewer {
|
|
|
539
756
|
this.#drop(low, false);
|
|
540
757
|
return false;
|
|
541
758
|
}
|
|
542
|
-
|
|
759
|
+
// The canvas's scale: the device pixel ratio, unless the backdrop's pixel cap or the pool's fit
|
|
760
|
+
// lowers it (then a detail canvas takes the part in view). The canvas is the page at that scale
|
|
761
|
+
// exactly as the renderer sizes it, so every tile fits inside.
|
|
762
|
+
const css = { width: cssW, height: cssH };
|
|
763
|
+
const ratio = backdropRatio(dpr, css, this.#backdropMaxPixels);
|
|
764
|
+
// The pool is asked for the page in whole pixels, as the renderer sizes it at that ratio.
|
|
765
|
+
const wanted = pagePixels(this.#sizes[index], layout.scale * ratio, layout.rotation);
|
|
766
|
+
const fitted = this.#pool.fit(canvas, wanted.width, wanted.height);
|
|
767
|
+
const scale = sharpScale(layout.scale, ratio, css, fitted, wanted);
|
|
768
|
+
const { width, height } = pagePixels(this.#sizes[index], scale, layout.rotation);
|
|
769
|
+
// What the canvas shows now, if it is this page in this orientation at another zoom: drawn back
|
|
770
|
+
// at the new size, so a re-zoom never trades sharp pixels for blurry ones.
|
|
771
|
+
const before = await this.#snapshot(view, layout, width, height);
|
|
772
|
+
if (!live()) {
|
|
773
|
+
before?.close();
|
|
774
|
+
this.#drop(low);
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
543
777
|
// Resizing clears the canvas: it happens only now, with the new pixels ready (no white flash).
|
|
544
778
|
canvas.width = width;
|
|
545
779
|
canvas.height = height;
|
|
546
|
-
|
|
780
|
+
// The old pixels cover the whole page, so the low pass is drawn only when there are none.
|
|
781
|
+
const drawn = this.#draw(view, canvas, before ?? low.bitmap, 0, 0, width, height);
|
|
782
|
+
before?.close();
|
|
547
783
|
this.#release(low);
|
|
548
784
|
// Not drawn (evicted meanwhile): the canvas stays unpainted, and the next frame tries again.
|
|
549
785
|
if (!drawn)
|
|
550
786
|
return false;
|
|
551
787
|
view.painted = geometryOf(layout);
|
|
788
|
+
view.scale = scale;
|
|
552
789
|
view.tiles.clear();
|
|
553
790
|
view.state = "low";
|
|
554
|
-
this.#pruneOtherGeometries(index, layout, new Set([request.scale, layout.scale *
|
|
791
|
+
this.#pruneOtherGeometries(index, layout, new Set([request.scale, scale, layout.scale * dpr]));
|
|
555
792
|
this.#emit("renderstats", this.stats());
|
|
556
793
|
return true;
|
|
557
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* A copy of `view`'s page canvas when it holds the page in `layout`'s rotation at another zoom (the
|
|
797
|
+
* pixels the user sees stretched), no larger than the new `width` × `height`, for #paintLow to
|
|
798
|
+
* draw back at the new size; else undefined.
|
|
799
|
+
*/
|
|
800
|
+
async #snapshot(view, layout, width, height) {
|
|
801
|
+
const canvas = view.canvas;
|
|
802
|
+
if (canvas === undefined || view.painted === "" || rotationOf(view.painted) !== layout.rotation)
|
|
803
|
+
return undefined;
|
|
804
|
+
try {
|
|
805
|
+
// No larger than the new canvas: zooming out shrinks the copy rather than doubling the memory.
|
|
806
|
+
if (width >= canvas.width && height >= canvas.height)
|
|
807
|
+
return await createImageBitmap(canvas);
|
|
808
|
+
return await createImageBitmap(canvas, {
|
|
809
|
+
resizeWidth: Math.min(width, canvas.width),
|
|
810
|
+
resizeHeight: Math.min(height, canvas.height),
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
// No copy (out of memory, or a browser without resize options): the low pass stands in.
|
|
815
|
+
return undefined;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
558
818
|
/** Frees page `index`'s bitmaps of other scales or rotations at once, rather than at the budget. */
|
|
559
819
|
#pruneOtherGeometries(index, layout, scales) {
|
|
560
820
|
this.#cache.prune(index, (key) => {
|
|
@@ -562,13 +822,13 @@ export class Viewer {
|
|
|
562
822
|
return scales.has(scale) && rotation === layout.rotation;
|
|
563
823
|
});
|
|
564
824
|
}
|
|
565
|
-
/**
|
|
566
|
-
async #paintTiles(pass,
|
|
567
|
-
const { view,
|
|
825
|
+
/** `tiles` (at the canvas's scale) into the page's canvas. */
|
|
826
|
+
async #paintTiles(pass, tiles) {
|
|
827
|
+
const { view, priority, signal, live } = pass;
|
|
568
828
|
const canvas = view.canvas;
|
|
569
829
|
if (canvas === undefined)
|
|
570
830
|
return;
|
|
571
|
-
for (const tile of
|
|
831
|
+
for (const tile of tiles) {
|
|
572
832
|
if (view.tiles.has(tile.key))
|
|
573
833
|
continue;
|
|
574
834
|
const got = await this.#bitmap(tile.request, tile.key, priority, signal);
|
|
@@ -576,12 +836,114 @@ export class Viewer {
|
|
|
576
836
|
this.#drop(got);
|
|
577
837
|
return;
|
|
578
838
|
}
|
|
579
|
-
const drawn = this.#draw(view, got.bitmap, tile.clip.x, tile.clip.y);
|
|
839
|
+
const drawn = this.#draw(view, canvas, got.bitmap, tile.clip.x, tile.clip.y);
|
|
580
840
|
this.#release(got);
|
|
581
841
|
if (drawn)
|
|
582
842
|
view.tiles.add(tile.key);
|
|
583
843
|
}
|
|
584
844
|
}
|
|
845
|
+
/**
|
|
846
|
+
* Sharp tiles over the part in view at the full pixel ratio, into the detail canvas: placed (or
|
|
847
|
+
* moved) to cover them, its cached tiles drawn at once so it never shows empty, then the rest.
|
|
848
|
+
*/
|
|
849
|
+
async #paintDetail(pass, frame) {
|
|
850
|
+
const { view, layout, priority, signal, live } = pass;
|
|
851
|
+
const tiles = this.#tiles(view.index, layout, this.#fullScale(layout), this.#inView(view.index, frame));
|
|
852
|
+
const rect = bounds(tiles.map((t) => t.clip));
|
|
853
|
+
if (rect === undefined) {
|
|
854
|
+
this.#releaseDetail(view);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const detail = this.#placeDetail(view, layout, rect);
|
|
858
|
+
if (detail === undefined) {
|
|
859
|
+
// No detail canvas to be had: the page shows its backdrop and waits until one is released,
|
|
860
|
+
// rather than try again every frame.
|
|
861
|
+
this.#releaseDetail(view);
|
|
862
|
+
view.waiting = true;
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const missing = tiles.filter((tile) => {
|
|
866
|
+
if (detail.tiles.has(tile.key))
|
|
867
|
+
return false;
|
|
868
|
+
const cached = this.#cache.get(tile.key);
|
|
869
|
+
if (cached === undefined || !this.#drawDetail(view, detail, cached, tile.clip))
|
|
870
|
+
return true;
|
|
871
|
+
detail.tiles.add(tile.key);
|
|
872
|
+
return false;
|
|
873
|
+
});
|
|
874
|
+
for (const tile of missing) {
|
|
875
|
+
const got = await this.#bitmap(tile.request, tile.key, priority, signal);
|
|
876
|
+
if (!live() || view.detail !== detail) {
|
|
877
|
+
this.#drop(got);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
const drawn = this.#drawDetail(view, detail, got.bitmap, tile.clip);
|
|
881
|
+
this.#release(got);
|
|
882
|
+
if (drawn)
|
|
883
|
+
detail.tiles.add(tile.key);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* `view`'s detail canvas covering `rect` (device px at the full scale): the one it has, if it
|
|
888
|
+
* already covers it at this geometry, else one sized and placed for `rect`. Undefined when the
|
|
889
|
+
* detail pool has no canvas or not the pixels (the page then shows its backdrop).
|
|
890
|
+
*/
|
|
891
|
+
#placeDetail(view, layout, rect) {
|
|
892
|
+
const scale = this.#fullScale(layout);
|
|
893
|
+
const geometry = geometryOf(layout);
|
|
894
|
+
const old = view.detail;
|
|
895
|
+
if (old !== undefined && old.geometry === geometry && old.scale === scale && contains(old.rect, rect))
|
|
896
|
+
return old;
|
|
897
|
+
// Past the device's cap for one canvas (a large tablet's viewport), the page keeps its backdrop.
|
|
898
|
+
if (rect.width * rect.height > this.#canvasMaxPixels)
|
|
899
|
+
return undefined;
|
|
900
|
+
const canvas = old?.canvas ?? this.#detailPool.acquire();
|
|
901
|
+
if (canvas === undefined)
|
|
902
|
+
return undefined;
|
|
903
|
+
const fitted = this.#detailPool.fit(canvas, rect.width, rect.height);
|
|
904
|
+
if (fitted.width < rect.width || fitted.height < rect.height) {
|
|
905
|
+
if (old === undefined)
|
|
906
|
+
this.#detailPool.release(canvas);
|
|
907
|
+
return undefined;
|
|
908
|
+
}
|
|
909
|
+
// Resizing clears it: #paintDetail draws the cached tiles back before the next frame.
|
|
910
|
+
canvas.width = rect.width;
|
|
911
|
+
canvas.height = rect.height;
|
|
912
|
+
// In percentages of the shell, so it stretches with the page while a zoom gesture runs.
|
|
913
|
+
const page = pagePixels(this.#sizes[view.index], scale, layout.rotation);
|
|
914
|
+
const percent = (n, of) => `${(100 * n) / of}%`;
|
|
915
|
+
Object.assign(canvas.style, {
|
|
916
|
+
position: "absolute",
|
|
917
|
+
left: percent(rect.x, page.width),
|
|
918
|
+
top: percent(rect.y, page.height),
|
|
919
|
+
width: percent(rect.width, page.width),
|
|
920
|
+
height: percent(rect.height, page.height),
|
|
921
|
+
});
|
|
922
|
+
if (old === undefined) {
|
|
923
|
+
canvas.className = "lamina-page-detail";
|
|
924
|
+
if (view.canvas === undefined)
|
|
925
|
+
view.shell.prepend(canvas);
|
|
926
|
+
else
|
|
927
|
+
view.canvas.after(canvas);
|
|
928
|
+
}
|
|
929
|
+
const detail = { canvas, rect, scale, geometry, tiles: new Set() };
|
|
930
|
+
view.detail = detail;
|
|
931
|
+
return detail;
|
|
932
|
+
}
|
|
933
|
+
#releaseDetail(view) {
|
|
934
|
+
if (view.detail === undefined)
|
|
935
|
+
return;
|
|
936
|
+
this.#detailPool.release(view.detail.canvas);
|
|
937
|
+
view.detail = undefined;
|
|
938
|
+
// A detail canvas is free again: pages that waited for one try now.
|
|
939
|
+
for (const other of this.#pages.values())
|
|
940
|
+
other.waiting = false;
|
|
941
|
+
this.#schedule();
|
|
942
|
+
}
|
|
943
|
+
/** Draws a sharp tile at `clip` (device px of the page) into `detail`, offset by its rectangle. */
|
|
944
|
+
#drawDetail(view, detail, bitmap, clip) {
|
|
945
|
+
return this.#draw(view, detail.canvas, bitmap, clip.x - detail.rect.x, clip.y - detail.rect.y);
|
|
946
|
+
}
|
|
585
947
|
/**
|
|
586
948
|
* A bitmap for `request`, from the cache or the worker. `owned` is true when the cache didn't
|
|
587
949
|
* keep it, so the caller closes it after drawing. A cached bitmap another page's pass evicted
|
|
@@ -605,9 +967,9 @@ export class Viewer {
|
|
|
605
967
|
if (got.owned)
|
|
606
968
|
got.bitmap.close();
|
|
607
969
|
}
|
|
608
|
-
/** The one place pixels reach a canvas
|
|
609
|
-
#draw(view, bitmap, x, y, width, height) {
|
|
610
|
-
const ctx =
|
|
970
|
+
/** The one place pixels reach a canvas of `view`: it counts a draw onto a shell out of the DOM. */
|
|
971
|
+
#draw(view, canvas, bitmap, x, y, width, height) {
|
|
972
|
+
const ctx = canvas.getContext("2d");
|
|
611
973
|
// A bitmap another pass's cache write evicted (closed) after it was fetched: not drawn.
|
|
612
974
|
if (ctx === undefined || ctx === null || isClosed(bitmap))
|
|
613
975
|
return false;
|
|
@@ -668,13 +1030,14 @@ export class Viewer {
|
|
|
668
1030
|
this.#on("scroll", () => this.#schedule(), { passive: true });
|
|
669
1031
|
this.#listenWheel();
|
|
670
1032
|
this.#listenKeys();
|
|
671
|
-
this.#
|
|
1033
|
+
this.#listenTouch();
|
|
672
1034
|
this.#listenGestures();
|
|
673
1035
|
this.#listenResize();
|
|
674
1036
|
}
|
|
675
1037
|
/** Ctrl/cmd+wheel and trackpad pinch (which browsers send as ctrl+wheel): zoom at the cursor. */
|
|
676
1038
|
#listenWheel() {
|
|
677
1039
|
this.#on("wheel", (e) => {
|
|
1040
|
+
this.#touch.stopMomentum();
|
|
678
1041
|
if (!e.ctrlKey && !e.metaKey)
|
|
679
1042
|
return;
|
|
680
1043
|
e.preventDefault();
|
|
@@ -686,6 +1049,7 @@ export class Viewer {
|
|
|
686
1049
|
/** `+` and `-` zoom by a quarter, `0` goes to actual size. */
|
|
687
1050
|
#listenKeys() {
|
|
688
1051
|
this.#on("keydown", (e) => {
|
|
1052
|
+
this.#touch.stopMomentum();
|
|
689
1053
|
if (e.altKey)
|
|
690
1054
|
return;
|
|
691
1055
|
const p = this.#state.percent;
|
|
@@ -700,42 +1064,39 @@ export class Viewer {
|
|
|
700
1064
|
e.preventDefault();
|
|
701
1065
|
});
|
|
702
1066
|
}
|
|
703
|
-
/** Touch
|
|
704
|
-
#
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
pinch = undefined;
|
|
1067
|
+
/** Touch and pen: pan with momentum, pinch around the fingers, double-tap zoom (view/touch.ts). */
|
|
1068
|
+
#listenTouch() {
|
|
1069
|
+
const gestures = this.#touch;
|
|
1070
|
+
this.#on("pointerdown", (e) => gestures.down(e));
|
|
1071
|
+
this.#on("pointermove", (e) => gestures.move(e));
|
|
1072
|
+
for (const end of ["pointerup", "pointercancel", "lostpointercapture"]) {
|
|
1073
|
+
this.#on(end, (e) => gestures.up(e));
|
|
1074
|
+
}
|
|
1075
|
+
this.#cleanup.push(() => gestures.stop());
|
|
1076
|
+
}
|
|
1077
|
+
/** The gestures' view of this viewer; it reads the root when called, so it may be made before the root. */
|
|
1078
|
+
#touchHost() {
|
|
1079
|
+
return {
|
|
1080
|
+
percent: () => this.#state.percent,
|
|
1081
|
+
fitsWidth: () => this.#zoom === "fit-width",
|
|
1082
|
+
zoom: (to, anchor) => this.setZoom(to, { anchor }),
|
|
1083
|
+
pointAt: (at) => this.pointAt(at.x, at.y),
|
|
1084
|
+
positionOf: (point) => this.positionOf(point),
|
|
1085
|
+
scrollBy: (dx, dy) => this.#root.scrollBy({ left: dx, top: dy, behavior: "instant" }),
|
|
1086
|
+
local: (e) => this.#anchorAt(e.clientX, e.clientY),
|
|
1087
|
+
frame: (callback) => requestAnimationFrame(callback),
|
|
1088
|
+
cancelFrame: (id) => cancelAnimationFrame(id),
|
|
1089
|
+
capture: (id) => {
|
|
1090
|
+
try {
|
|
1091
|
+
this.#root.setPointerCapture(id);
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
// The pointer is already up: nothing to keep.
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
734
1097
|
};
|
|
735
|
-
this.#on("pointerup", up);
|
|
736
|
-
this.#on("pointercancel", up);
|
|
737
1098
|
}
|
|
738
|
-
/** Safari's trackpad pinch: gesture events with a scale. */
|
|
1099
|
+
/** Safari's trackpad pinch: gesture events with a scale (and iOS's touch pinch, which the touch gestures take). */
|
|
739
1100
|
#listenGestures() {
|
|
740
1101
|
let gesture;
|
|
741
1102
|
this.#on("gesturestart", (e) => {
|
|
@@ -746,6 +1107,9 @@ export class Viewer {
|
|
|
746
1107
|
if (gesture === undefined)
|
|
747
1108
|
return;
|
|
748
1109
|
e.preventDefault();
|
|
1110
|
+
// iOS sends these for a two-finger touch pinch too: the touch gestures zoom it then.
|
|
1111
|
+
if (this.#touch.pinching())
|
|
1112
|
+
return;
|
|
749
1113
|
const ge = e;
|
|
750
1114
|
this.setZoom(gesture * ge.scale, { anchor: this.#anchorAt(ge.clientX, ge.clientY) });
|
|
751
1115
|
});
|