@laminapdf/viewer 0.2.0-alpha.3 → 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 +71 -5
- package/dist/client.d.ts +48 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +89 -29
- 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 +115 -2
- package/dist/protocol.d.ts.map +1 -1
- 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 +425 -68
- package/dist/view/viewer.js.map +1 -1
- package/dist/worker/document.d.ts +30 -0
- package/dist/worker/document.d.ts.map +1 -1
- package/dist/worker/document.js +176 -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 +2 -0
- 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 +28 -2
- 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 +13 -6
- 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 +5 -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
|
}
|
|
@@ -286,6 +429,8 @@ export class Viewer {
|
|
|
286
429
|
let unsharpened = false;
|
|
287
430
|
for (const view of this.#pages.values()) {
|
|
288
431
|
this.#abort(view);
|
|
432
|
+
// The new geometry needs other canvases: a page that waited for one tries again.
|
|
433
|
+
view.waiting = false;
|
|
289
434
|
if (view.state === "error")
|
|
290
435
|
view.state = "placeholder";
|
|
291
436
|
if (view.state === "sharp") {
|
|
@@ -327,6 +472,7 @@ export class Viewer {
|
|
|
327
472
|
const frame = {
|
|
328
473
|
layout,
|
|
329
474
|
top: this.#root.scrollTop,
|
|
475
|
+
left: this.#root.scrollLeft,
|
|
330
476
|
width: this.#root.clientWidth,
|
|
331
477
|
height: this.#root.clientHeight,
|
|
332
478
|
};
|
|
@@ -344,13 +490,17 @@ export class Viewer {
|
|
|
344
490
|
this.#paintRange(frame, visible, first, last);
|
|
345
491
|
this.#emit("renderstats", this.stats());
|
|
346
492
|
}
|
|
347
|
-
/**
|
|
493
|
+
/**
|
|
494
|
+
* Unmounts the shells outside `first..last` (but the pinned ones), mounts the missing ones and
|
|
495
|
+
* positions them all.
|
|
496
|
+
*/
|
|
348
497
|
#placeShells(frame, first, last) {
|
|
349
498
|
const { layout, width } = frame;
|
|
499
|
+
const pinned = [...this.#pinned].filter((i) => i >= 0 && i < layout.pageCount && (i < first || i > last));
|
|
350
500
|
for (const [i, view] of this.#pages)
|
|
351
|
-
if (i < first || i > last)
|
|
501
|
+
if ((i < first || i > last) && !this.#pinned.has(i))
|
|
352
502
|
this.#unmount(view);
|
|
353
|
-
for (
|
|
503
|
+
for (const i of [...pinned, ...range(first, last)]) {
|
|
354
504
|
const view = this.#pages.get(i) ?? this.#mount(i);
|
|
355
505
|
const w = layout.widths[i];
|
|
356
506
|
const h = layout.heights[i];
|
|
@@ -393,7 +543,9 @@ export class Viewer {
|
|
|
393
543
|
controller: new AbortController(),
|
|
394
544
|
state: "placeholder",
|
|
395
545
|
painted: "",
|
|
546
|
+
scale: 0,
|
|
396
547
|
tiles: new Set(),
|
|
548
|
+
detail: undefined,
|
|
397
549
|
busy: false,
|
|
398
550
|
waiting: false,
|
|
399
551
|
pass: 0,
|
|
@@ -412,6 +564,10 @@ export class Viewer {
|
|
|
412
564
|
slot = document.createElement("div");
|
|
413
565
|
slot.className = `lamina-layer-${name}`;
|
|
414
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";
|
|
415
571
|
view.slots.set(name, slot);
|
|
416
572
|
view.shell.append(slot);
|
|
417
573
|
}
|
|
@@ -437,6 +593,7 @@ export class Viewer {
|
|
|
437
593
|
this.#unmount(view);
|
|
438
594
|
}
|
|
439
595
|
#releaseCanvas(view) {
|
|
596
|
+
this.#releaseDetail(view);
|
|
440
597
|
if (view.canvas !== undefined)
|
|
441
598
|
this.#pool.release(view.canvas);
|
|
442
599
|
view.canvas = undefined;
|
|
@@ -449,24 +606,71 @@ export class Viewer {
|
|
|
449
606
|
other.waiting = false;
|
|
450
607
|
this.#schedule();
|
|
451
608
|
}
|
|
452
|
-
/** The
|
|
453
|
-
#
|
|
454
|
-
const { layout, top, height } = frame;
|
|
455
|
-
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;
|
|
456
612
|
const cssW = layout.widths[index];
|
|
457
613
|
const cssH = layout.heights[index];
|
|
458
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);
|
|
459
618
|
const y0 = Math.max(0, top - pageTop);
|
|
460
619
|
const y1 = Math.min(cssH, top + height - pageTop);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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));
|
|
466
667
|
}
|
|
467
668
|
/** Starts page `index`'s pass if it isn't busy and its canvas isn't done for `frame` yet. */
|
|
468
669
|
#paint(index, priority, frame) {
|
|
469
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);
|
|
470
674
|
if (view === undefined || this.#document === undefined || view.busy || view.waiting || view.state === "error") {
|
|
471
675
|
return;
|
|
472
676
|
}
|
|
@@ -474,10 +678,8 @@ export class Viewer {
|
|
|
474
678
|
if (view.painted === geometryOf(layout)) {
|
|
475
679
|
if (priority === "prefetch")
|
|
476
680
|
return;
|
|
477
|
-
const canvas = view.canvas;
|
|
478
|
-
const ratio = canvas === undefined ? 0 : canvas.width / layout.widths[index];
|
|
479
681
|
// Sharp means every tile now in view is drawn: scrolling down a tall page sharpens the rest.
|
|
480
|
-
if (
|
|
682
|
+
if (view.scale > 0 && this.#sharpInView(view, frame)) {
|
|
481
683
|
if (view.state !== "sharp") {
|
|
482
684
|
view.state = "sharp";
|
|
483
685
|
this.#emit("renderstats", this.stats());
|
|
@@ -524,7 +726,15 @@ export class Viewer {
|
|
|
524
726
|
return;
|
|
525
727
|
if (priority === "prefetch")
|
|
526
728
|
return;
|
|
527
|
-
|
|
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)]);
|
|
528
738
|
}
|
|
529
739
|
/** The blurry pass: resizes the canvas and stretches a low-resolution page over it. */
|
|
530
740
|
async #paintLow(pass) {
|
|
@@ -546,22 +756,65 @@ export class Viewer {
|
|
|
546
756
|
this.#drop(low, false);
|
|
547
757
|
return false;
|
|
548
758
|
}
|
|
549
|
-
|
|
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
|
+
}
|
|
550
777
|
// Resizing clears the canvas: it happens only now, with the new pixels ready (no white flash).
|
|
551
778
|
canvas.width = width;
|
|
552
779
|
canvas.height = height;
|
|
553
|
-
|
|
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();
|
|
554
783
|
this.#release(low);
|
|
555
784
|
// Not drawn (evicted meanwhile): the canvas stays unpainted, and the next frame tries again.
|
|
556
785
|
if (!drawn)
|
|
557
786
|
return false;
|
|
558
787
|
view.painted = geometryOf(layout);
|
|
788
|
+
view.scale = scale;
|
|
559
789
|
view.tiles.clear();
|
|
560
790
|
view.state = "low";
|
|
561
|
-
this.#pruneOtherGeometries(index, layout, new Set([request.scale, layout.scale *
|
|
791
|
+
this.#pruneOtherGeometries(index, layout, new Set([request.scale, scale, layout.scale * dpr]));
|
|
562
792
|
this.#emit("renderstats", this.stats());
|
|
563
793
|
return true;
|
|
564
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
|
+
}
|
|
565
818
|
/** Frees page `index`'s bitmaps of other scales or rotations at once, rather than at the budget. */
|
|
566
819
|
#pruneOtherGeometries(index, layout, scales) {
|
|
567
820
|
this.#cache.prune(index, (key) => {
|
|
@@ -569,13 +822,13 @@ export class Viewer {
|
|
|
569
822
|
return scales.has(scale) && rotation === layout.rotation;
|
|
570
823
|
});
|
|
571
824
|
}
|
|
572
|
-
/**
|
|
573
|
-
async #paintTiles(pass,
|
|
574
|
-
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;
|
|
575
828
|
const canvas = view.canvas;
|
|
576
829
|
if (canvas === undefined)
|
|
577
830
|
return;
|
|
578
|
-
for (const tile of
|
|
831
|
+
for (const tile of tiles) {
|
|
579
832
|
if (view.tiles.has(tile.key))
|
|
580
833
|
continue;
|
|
581
834
|
const got = await this.#bitmap(tile.request, tile.key, priority, signal);
|
|
@@ -583,12 +836,114 @@ export class Viewer {
|
|
|
583
836
|
this.#drop(got);
|
|
584
837
|
return;
|
|
585
838
|
}
|
|
586
|
-
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);
|
|
587
840
|
this.#release(got);
|
|
588
841
|
if (drawn)
|
|
589
842
|
view.tiles.add(tile.key);
|
|
590
843
|
}
|
|
591
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
|
+
}
|
|
592
947
|
/**
|
|
593
948
|
* A bitmap for `request`, from the cache or the worker. `owned` is true when the cache didn't
|
|
594
949
|
* keep it, so the caller closes it after drawing. A cached bitmap another page's pass evicted
|
|
@@ -612,9 +967,9 @@ export class Viewer {
|
|
|
612
967
|
if (got.owned)
|
|
613
968
|
got.bitmap.close();
|
|
614
969
|
}
|
|
615
|
-
/** The one place pixels reach a canvas
|
|
616
|
-
#draw(view, bitmap, x, y, width, height) {
|
|
617
|
-
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");
|
|
618
973
|
// A bitmap another pass's cache write evicted (closed) after it was fetched: not drawn.
|
|
619
974
|
if (ctx === undefined || ctx === null || isClosed(bitmap))
|
|
620
975
|
return false;
|
|
@@ -675,13 +1030,14 @@ export class Viewer {
|
|
|
675
1030
|
this.#on("scroll", () => this.#schedule(), { passive: true });
|
|
676
1031
|
this.#listenWheel();
|
|
677
1032
|
this.#listenKeys();
|
|
678
|
-
this.#
|
|
1033
|
+
this.#listenTouch();
|
|
679
1034
|
this.#listenGestures();
|
|
680
1035
|
this.#listenResize();
|
|
681
1036
|
}
|
|
682
1037
|
/** Ctrl/cmd+wheel and trackpad pinch (which browsers send as ctrl+wheel): zoom at the cursor. */
|
|
683
1038
|
#listenWheel() {
|
|
684
1039
|
this.#on("wheel", (e) => {
|
|
1040
|
+
this.#touch.stopMomentum();
|
|
685
1041
|
if (!e.ctrlKey && !e.metaKey)
|
|
686
1042
|
return;
|
|
687
1043
|
e.preventDefault();
|
|
@@ -693,6 +1049,7 @@ export class Viewer {
|
|
|
693
1049
|
/** `+` and `-` zoom by a quarter, `0` goes to actual size. */
|
|
694
1050
|
#listenKeys() {
|
|
695
1051
|
this.#on("keydown", (e) => {
|
|
1052
|
+
this.#touch.stopMomentum();
|
|
696
1053
|
if (e.altKey)
|
|
697
1054
|
return;
|
|
698
1055
|
const p = this.#state.percent;
|
|
@@ -707,42 +1064,39 @@ export class Viewer {
|
|
|
707
1064
|
e.preventDefault();
|
|
708
1065
|
});
|
|
709
1066
|
}
|
|
710
|
-
/** Touch
|
|
711
|
-
#
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
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
|
+
},
|
|
741
1097
|
};
|
|
742
|
-
this.#on("pointerup", up);
|
|
743
|
-
this.#on("pointercancel", up);
|
|
744
1098
|
}
|
|
745
|
-
/** 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). */
|
|
746
1100
|
#listenGestures() {
|
|
747
1101
|
let gesture;
|
|
748
1102
|
this.#on("gesturestart", (e) => {
|
|
@@ -753,6 +1107,9 @@ export class Viewer {
|
|
|
753
1107
|
if (gesture === undefined)
|
|
754
1108
|
return;
|
|
755
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;
|
|
756
1113
|
const ge = e;
|
|
757
1114
|
this.setZoom(gesture * ge.scale, { anchor: this.#anchorAt(ge.clientX, ge.clientY) });
|
|
758
1115
|
});
|