@laminapdf/viewer 0.2.0-alpha.3 → 0.2.0-alpha.5
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 +102 -8
- package/dist/client.d.ts +75 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +192 -32
- 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/first-draw.d.ts +10 -0
- package/dist/first-draw.d.ts.map +1 -0
- package/dist/first-draw.js +33 -0
- package/dist/first-draw.js.map +1 -0
- package/dist/history/history.d.ts +65 -0
- package/dist/history/history.d.ts.map +1 -0
- package/dist/history/history.js +135 -0
- package/dist/history/history.js.map +1 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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/copy.d.ts +26 -0
- package/dist/layers/copy.d.ts.map +1 -0
- package/dist/layers/copy.js +150 -0
- package/dist/layers/copy.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/link-layer.d.ts +68 -0
- package/dist/layers/link-layer.d.ts.map +1 -0
- package/dist/layers/link-layer.js +279 -0
- package/dist/layers/link-layer.js.map +1 -0
- package/dist/layers/link-url.d.ts +19 -0
- package/dist/layers/link-url.d.ts.map +1 -0
- package/dist/layers/link-url.js +47 -0
- package/dist/layers/link-url.js.map +1 -0
- package/dist/layers/spans.d.ts +36 -0
- package/dist/layers/spans.d.ts.map +1 -0
- package/dist/layers/spans.js +103 -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 +155 -0
- package/dist/layers/structure-layer.js.map +1 -0
- package/dist/layers/text-layer.d.ts +70 -0
- package/dist/layers/text-layer.d.ts.map +1 -0
- package/dist/layers/text-layer.js +576 -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 +193 -6
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +16 -4
- 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/source/opfs.d.ts +35 -0
- package/dist/source/opfs.d.ts.map +1 -0
- package/dist/source/opfs.js +178 -0
- package/dist/source/opfs.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 +324 -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/user-select.d.ts +3 -0
- package/dist/view/user-select.d.ts.map +1 -0
- package/dist/view/user-select.js +10 -0
- package/dist/view/user-select.js.map +1 -0
- package/dist/view/viewer.d.ts +102 -1
- package/dist/view/viewer.d.ts.map +1 -1
- package/dist/view/viewer.js +660 -95
- package/dist/view/viewer.js.map +1 -1
- package/dist/worker/document.d.ts +53 -1
- package/dist/worker/document.d.ts.map +1 -1
- package/dist/worker/document.js +222 -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 +46 -0
- package/dist/worker/find.js.map +1 -0
- package/dist/worker/methods.d.ts.map +1 -1
- package/dist/worker/methods.js +49 -4
- package/dist/worker/methods.js.map +1 -1
- package/dist/worker/navigation.d.ts +10 -0
- package/dist/worker/navigation.d.ts.map +1 -0
- package/dist/worker/navigation.js +34 -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/print.d.ts +14 -0
- package/dist/worker/print.d.ts.map +1 -0
- package/dist/worker/print.js +49 -0
- package/dist/worker/print.js.map +1 -0
- package/dist/worker/recent-pages.d.ts +14 -0
- package/dist/worker/recent-pages.d.ts.map +1 -0
- package/dist/worker/recent-pages.js +53 -0
- package/dist/worker/recent-pages.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 +16 -6
- package/dist/worker/render.js.map +1 -1
- package/dist/worker/text.d.ts +25 -0
- package/dist/worker/text.d.ts.map +1 -0
- package/dist/worker/text.js +82 -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,30 @@
|
|
|
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 { NavigationHistory } from "../history/history.js";
|
|
16
|
+
import { HighlightLayer } from "../layers/highlight-layer.js";
|
|
17
|
+
import { LinkLayer } from "../layers/link-layer.js";
|
|
18
|
+
import { TextLayer } from "../layers/text-layer.js";
|
|
11
19
|
import { isRotation, PageLayout, rotated } from "../layout/page-layout.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
20
|
+
import { range } from "../layout/range.js";
|
|
21
|
+
import { appearanceField, appearanceKey, renderKey, renderKeyFields, } from "../protocol.js";
|
|
22
|
+
import { backdropRatio, bounds, contains, lowScale, sharpScale, tilesFor } from "../render/tiles.js";
|
|
23
|
+
import { destinationView } from "../zoom/destination.js";
|
|
14
24
|
import { contentPosition, percentOfScale, pointAt, scaleFor, scrollToPoint, } from "../zoom/zoom.js";
|
|
25
|
+
import { deviceHints, viewerBudgets } from "./budgets.js";
|
|
26
|
+
import { TouchGestures } from "./touch.js";
|
|
27
|
+
import { setUserSelect } from "./user-select.js";
|
|
15
28
|
/** The built-in layer slots, bottom to top, above the canvas. */
|
|
16
29
|
const SLOTS = ["text", "annot", "highlight"];
|
|
17
30
|
/** The tallest scroll space browsers handle (Firefox stops near 17.9 M CSS px). */
|
|
@@ -19,10 +32,27 @@ const MAX_HEIGHT = 16_000_000;
|
|
|
19
32
|
function isAbort(e) {
|
|
20
33
|
return e instanceof DOMException && e.name === "AbortError";
|
|
21
34
|
}
|
|
35
|
+
/** The state before a document opens. */
|
|
36
|
+
function emptyState(zoom) {
|
|
37
|
+
return {
|
|
38
|
+
pageCount: 0,
|
|
39
|
+
currentPage: 0,
|
|
40
|
+
zoom,
|
|
41
|
+
percent: 100,
|
|
42
|
+
rotation: 0,
|
|
43
|
+
appearance: "original",
|
|
44
|
+
scrollTop: 0,
|
|
45
|
+
visibleRange: { first: 0, last: -1 },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
22
48
|
/** What a painted canvas depends on: a zoom or a rotation makes it stale. */
|
|
23
49
|
function geometryOf(layout) {
|
|
24
50
|
return `${layout.scale}:${layout.rotation}`;
|
|
25
51
|
}
|
|
52
|
+
/** The view rotation a `geometryOf` string was painted for. */
|
|
53
|
+
function rotationOf(geometry) {
|
|
54
|
+
return Number(geometry.slice(geometry.indexOf(":") + 1));
|
|
55
|
+
}
|
|
26
56
|
/** A closed ImageBitmap has no size: one the cache evicted while a pass held it. */
|
|
27
57
|
function isClosed(bitmap) {
|
|
28
58
|
return bitmap.width === 0 || bitmap.height === 0;
|
|
@@ -38,11 +68,34 @@ export class Viewer {
|
|
|
38
68
|
#settleMs;
|
|
39
69
|
#dpr;
|
|
40
70
|
#pool;
|
|
71
|
+
#detailPool;
|
|
72
|
+
#backdropMaxPixels;
|
|
73
|
+
#canvasMaxPixels;
|
|
74
|
+
#touch;
|
|
41
75
|
#cache;
|
|
42
76
|
#root;
|
|
43
77
|
#spacer;
|
|
44
78
|
#pages = new Map();
|
|
45
79
|
#layers = [];
|
|
80
|
+
/** The text layer, unless `text: false`. */
|
|
81
|
+
#text;
|
|
82
|
+
#highlights;
|
|
83
|
+
/** The link layer, unless `links: false`. */
|
|
84
|
+
#links;
|
|
85
|
+
/** Find in this viewer's document: query, options, matches, next and previous. */
|
|
86
|
+
find;
|
|
87
|
+
/**
|
|
88
|
+
* Back and forward through the places jumped from and to: links, `jumpToPage`,
|
|
89
|
+
* `jumpToDestination` and find's matches record a jump; scrolling doesn't.
|
|
90
|
+
*/
|
|
91
|
+
history;
|
|
92
|
+
/** Pages kept mounted whatever the scroll (`keepMounted`): those a selection touches. */
|
|
93
|
+
#pinned = new Set();
|
|
94
|
+
#regions;
|
|
95
|
+
/** The page a jump landed on, focused once it is mounted. */
|
|
96
|
+
#focusTarget;
|
|
97
|
+
/** True while a jump that leaves focus where it is (a find match) runs. */
|
|
98
|
+
#keepFocus = false;
|
|
46
99
|
#listeners = new Set();
|
|
47
100
|
#events = new Map();
|
|
48
101
|
#cleanup = [];
|
|
@@ -65,27 +118,61 @@ export class Viewer {
|
|
|
65
118
|
this.#gap = options.gap ?? 16;
|
|
66
119
|
this.#settleMs = options.settleMs ?? 150;
|
|
67
120
|
this.#dpr = options.dpr ?? (() => globalThis.devicePixelRatio || 1);
|
|
68
|
-
|
|
69
|
-
this.#
|
|
121
|
+
const budgets = viewerBudgets(options.device ?? deviceHints());
|
|
122
|
+
this.#pool = new CanvasPool({ ...budgets.pool, ...options.pool });
|
|
123
|
+
this.#detailPool = new CanvasPool({ ...budgets.detailPool, ...options.detailPool });
|
|
124
|
+
this.#cache = new BitmapCache(options.cacheBytes ?? budgets.cacheBytes);
|
|
125
|
+
this.#backdropMaxPixels = options.backdropMaxPixels ?? budgets.backdropMaxPixels;
|
|
126
|
+
this.#canvasMaxPixels = options.canvasMaxPixels ?? budgets.canvasMaxPixels;
|
|
127
|
+
this.#touch = new TouchGestures(this.#touchHost());
|
|
70
128
|
this.#zoom = options.zoom ?? "fit-width";
|
|
71
129
|
this.#root = document.createElement("div");
|
|
72
130
|
this.#root.className = "lamina-viewer";
|
|
73
131
|
this.#root.tabIndex = 0;
|
|
74
|
-
|
|
132
|
+
// `touch-action: none`: the viewer pans and zooms under a finger itself, so the browser's own
|
|
133
|
+
// page zoom never fights it, and zooming the chrome stays the browser's (UI/UX plan §5.4).
|
|
134
|
+
Object.assign(this.#root.style, {
|
|
135
|
+
position: "relative",
|
|
136
|
+
overflow: "auto",
|
|
137
|
+
width: "100%",
|
|
138
|
+
height: "100%",
|
|
139
|
+
touchAction: "none",
|
|
140
|
+
});
|
|
75
141
|
this.#spacer = document.createElement("div");
|
|
76
142
|
this.#spacer.className = "lamina-viewer-content";
|
|
77
143
|
Object.assign(this.#spacer.style, { position: "relative" });
|
|
78
144
|
this.#root.append(this.#spacer);
|
|
79
|
-
this.#state =
|
|
80
|
-
pageCount: 0,
|
|
81
|
-
currentPage: 0,
|
|
82
|
-
zoom: this.#zoom,
|
|
83
|
-
percent: 100,
|
|
84
|
-
rotation: 0,
|
|
85
|
-
scrollTop: 0,
|
|
86
|
-
visibleRange: { first: 0, last: -1 },
|
|
87
|
-
};
|
|
145
|
+
this.#state = emptyState(this.#zoom);
|
|
88
146
|
this.#listen();
|
|
147
|
+
this.#text = this.#textLayer(options.text);
|
|
148
|
+
if (this.#text !== undefined)
|
|
149
|
+
this.addLayer(this.#text);
|
|
150
|
+
this.history = new NavigationHistory({ place: () => this.#place(), show: (p) => this.#show(p) }, options.history);
|
|
151
|
+
this.#regions = options.pages ?? {};
|
|
152
|
+
if (this.#regions.focusOnJump === true) {
|
|
153
|
+
this.history.onEntry((entry, replaced) => {
|
|
154
|
+
if (replaced || this.#keepFocus)
|
|
155
|
+
return;
|
|
156
|
+
this.#focusTarget = entry.point.page;
|
|
157
|
+
this.#schedule();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
this.find = new FindController({
|
|
161
|
+
find: (query, find, pages, signal) => this.#client.find(query, find, pages, { signal }),
|
|
162
|
+
pageCount: () => this.#sizes.length,
|
|
163
|
+
currentPage: () => this.#state.currentPage,
|
|
164
|
+
// Each match find moves to is a jump, so Back returns to where the search started.
|
|
165
|
+
// A match is a jump too, but focus stays in the find field.
|
|
166
|
+
reveal: (page, quads) => this.#jumpKeepingFocus(() => this.#reveal(page, quads)),
|
|
167
|
+
redraw: (pages) => this.#highlights.refresh(pages),
|
|
168
|
+
});
|
|
169
|
+
this.#highlights = new HighlightLayer({
|
|
170
|
+
matchesOn: (page) => this.find.matchesOn(page),
|
|
171
|
+
points: (page) => this.#sizes[page],
|
|
172
|
+
rotation: () => this.#rotation,
|
|
173
|
+
});
|
|
174
|
+
this.addLayer(this.#highlights);
|
|
175
|
+
this.#links = this.#linkLayer(options.links);
|
|
89
176
|
}
|
|
90
177
|
/** The scroll container (append it, or use {@link mount}). */
|
|
91
178
|
get element() {
|
|
@@ -101,6 +188,11 @@ export class Viewer {
|
|
|
101
188
|
this.#unmountAll();
|
|
102
189
|
this.#cache.clear();
|
|
103
190
|
this.#document = document;
|
|
191
|
+
this.#pinned = new Set();
|
|
192
|
+
this.#focusTarget = undefined;
|
|
193
|
+
this.find.reset();
|
|
194
|
+
this.history.reset();
|
|
195
|
+
this.#text?.open(document);
|
|
104
196
|
this.#sizes = document.pages.map((p) => ({ width: p.width, height: p.height }));
|
|
105
197
|
this.#layout = undefined;
|
|
106
198
|
this.#root.scrollTop = 0;
|
|
@@ -128,6 +220,35 @@ export class Viewer {
|
|
|
128
220
|
set.add(l);
|
|
129
221
|
return () => set.delete(l);
|
|
130
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Copies the text selection to the clipboard: by paragraph in reading order, or exact, as laid
|
|
225
|
+
* out. Call it in a user gesture (a click, a key). Resolves false when nothing is selected or
|
|
226
|
+
* the clipboard refused.
|
|
227
|
+
*/
|
|
228
|
+
copySelection(mode = "paragraph") {
|
|
229
|
+
return this.#text?.copy(mode) ?? Promise.resolve(false);
|
|
230
|
+
}
|
|
231
|
+
/** Whether a plain drag selects a rectangle, as an Alt/Option-drag does (column selection). */
|
|
232
|
+
get columnSelect() {
|
|
233
|
+
return this.#text?.columnMode ?? false;
|
|
234
|
+
}
|
|
235
|
+
/** Turns column selection by plain drag on or off; Alt/Option-drag selects a rectangle either way. */
|
|
236
|
+
set columnSelect(on) {
|
|
237
|
+
if (this.#text !== undefined)
|
|
238
|
+
this.#text.columnMode = on;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Keeps `pages` mounted wherever the view scrolls, until the next call replaces them: the pages
|
|
242
|
+
* a text selection touches, so scrolling never takes the selection away. A pinned page out of
|
|
243
|
+
* view keeps its shell and its text; its canvas goes back to the pool like any other.
|
|
244
|
+
*/
|
|
245
|
+
keepMounted(pages) {
|
|
246
|
+
const next = new Set(pages);
|
|
247
|
+
if (next.size === this.#pinned.size && [...next].every((p) => this.#pinned.has(p)))
|
|
248
|
+
return;
|
|
249
|
+
this.#pinned = next;
|
|
250
|
+
this.#schedule();
|
|
251
|
+
}
|
|
131
252
|
/** Adds a layer to every page, mounted now and later; returns a function that removes it. */
|
|
132
253
|
addLayer(layer) {
|
|
133
254
|
this.#layers.push(layer);
|
|
@@ -143,6 +264,7 @@ export class Viewer {
|
|
|
143
264
|
}
|
|
144
265
|
/** Scrolls page `index` to the top of the viewport (or its centre). */
|
|
145
266
|
goToPage(index, options = {}) {
|
|
267
|
+
this.#touch.stopMomentum();
|
|
146
268
|
const layout = this.#layout;
|
|
147
269
|
if (layout === undefined || layout.pageCount === 0)
|
|
148
270
|
return;
|
|
@@ -154,6 +276,36 @@ export class Viewer {
|
|
|
154
276
|
: Math.max(0, top);
|
|
155
277
|
this.#schedule();
|
|
156
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Goes to `target` (an outline item's, a named destination's or a link's, from
|
|
281
|
+
* `ViewerClient.outline` or `namedDestination`): its zoom if it sets one, then its point at the
|
|
282
|
+
* viewport's top-left, or its page's top when it names no point (ISO 32000-2 §12.3.2.2).
|
|
283
|
+
*/
|
|
284
|
+
goToDestination(target) {
|
|
285
|
+
this.#touch.stopMomentum();
|
|
286
|
+
const size = this.#sizes[target.page];
|
|
287
|
+
if (this.#layout === undefined || size === undefined)
|
|
288
|
+
return;
|
|
289
|
+
const view = destinationView(target, size, this.#rotation, this.#viewport(), this.#gap);
|
|
290
|
+
if (view.zoom !== undefined) {
|
|
291
|
+
this.#zoom = view.zoom;
|
|
292
|
+
this.#rezoom(undefined);
|
|
293
|
+
}
|
|
294
|
+
const layout = this.#layout;
|
|
295
|
+
const at = contentPosition(layout, this.#root.clientWidth, { page: view.page, x: view.x ?? 0, y: view.y ?? 0 });
|
|
296
|
+
this.#root.scrollTop = Math.max(0, view.y === undefined ? layout.offsetOf(view.page) - this.#gap / 2 : at.y);
|
|
297
|
+
if (view.x !== undefined)
|
|
298
|
+
this.#root.scrollLeft = Math.max(0, at.x);
|
|
299
|
+
this.#schedule();
|
|
300
|
+
}
|
|
301
|
+
/** {@link goToPage} as a history jump: Back returns to where the view was. */
|
|
302
|
+
jumpToPage(index) {
|
|
303
|
+
this.history.jump(() => this.goToPage(index));
|
|
304
|
+
}
|
|
305
|
+
/** {@link goToDestination} as a history jump: Back returns to where the view was. */
|
|
306
|
+
jumpToDestination(target) {
|
|
307
|
+
this.history.jump(() => this.goToDestination(target));
|
|
308
|
+
}
|
|
157
309
|
/**
|
|
158
310
|
* Sets the zoom (a mode, or a percentage clamped to 10–800 %), keeping the document point at
|
|
159
311
|
* `anchor` (viewport CSS px; default its centre) where it is.
|
|
@@ -173,6 +325,36 @@ export class Viewer {
|
|
|
173
325
|
throw new RangeError(`rotate() takes a multiple of 90, got ${delta}`);
|
|
174
326
|
this.#rotation = next;
|
|
175
327
|
this.#rezoom(undefined);
|
|
328
|
+
// A half turn keeps every page's size, so no layer hears of it from a resize.
|
|
329
|
+
this.#text?.refresh();
|
|
330
|
+
this.#highlights.refresh();
|
|
331
|
+
this.#links?.refresh();
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Draws the pages in `appearance` (Sepia, Dark, High contrast or their own colours), keeping the
|
|
335
|
+
* scroll position: pages in view paint again, a quick pass then sharp tiles, and none of their
|
|
336
|
+
* old pixels is kept; other pages give their canvas back and paint when they come into view.
|
|
337
|
+
* The thumbnails follow.
|
|
338
|
+
*/
|
|
339
|
+
setAppearance(appearance) {
|
|
340
|
+
if (appearanceKey(appearance) === appearanceKey(this.#state.appearance))
|
|
341
|
+
return;
|
|
342
|
+
const { first, last } = this.#state.visibleRange;
|
|
343
|
+
for (const view of [...this.#pages.values()]) {
|
|
344
|
+
this.#abort(view);
|
|
345
|
+
view.tiles.clear();
|
|
346
|
+
view.detail?.tiles.clear();
|
|
347
|
+
if (view.index >= first && view.index <= last) {
|
|
348
|
+
view.appearance = appearanceKey(appearance);
|
|
349
|
+
view.painted = "";
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
// Its shell shows the new paper until it is seen (`#readyForAppearance`).
|
|
353
|
+
this.#releaseCanvas(view);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
this.#setState({ appearance });
|
|
357
|
+
this.#schedule();
|
|
176
358
|
}
|
|
177
359
|
/** The document point at viewport position (`x`, `y`) in CSS px. */
|
|
178
360
|
pointAt(x, y) {
|
|
@@ -201,6 +383,7 @@ export class Viewer {
|
|
|
201
383
|
canvases: this.#pool.inUse,
|
|
202
384
|
peakCanvases: this.#pool.peak,
|
|
203
385
|
maxCanvases: this.#pool.max,
|
|
386
|
+
detailCanvases: this.#detailPool.inUse,
|
|
204
387
|
drawsOnUnmounted: this.#drawsOnUnmounted,
|
|
205
388
|
dropped: this.#dropped,
|
|
206
389
|
aborted: this.#aborted,
|
|
@@ -216,17 +399,118 @@ export class Viewer {
|
|
|
216
399
|
clearTimeout(this.#settling);
|
|
217
400
|
this.#unmountAll();
|
|
218
401
|
this.#cache.clear();
|
|
402
|
+
this.find.reset();
|
|
403
|
+
this.#text?.destroy();
|
|
404
|
+
this.#links?.destroy();
|
|
219
405
|
for (const f of this.#cleanup.splice(0))
|
|
220
406
|
f();
|
|
221
407
|
this.#root.remove();
|
|
222
408
|
}
|
|
223
409
|
// --- internals ---
|
|
410
|
+
/** The link layer, added to every page, unless `links` is `false`. */
|
|
411
|
+
#linkLayer(links) {
|
|
412
|
+
if (links === false)
|
|
413
|
+
return undefined;
|
|
414
|
+
const layer = new LinkLayer({
|
|
415
|
+
root: this.#root,
|
|
416
|
+
client: this.#client,
|
|
417
|
+
points: (page) => this.#sizes[page],
|
|
418
|
+
rotation: () => this.#rotation,
|
|
419
|
+
pageCount: () => this.#sizes.length,
|
|
420
|
+
goTo: (target) => this.jumpToDestination(target),
|
|
421
|
+
goToPage: (page) => this.jumpToPage(page),
|
|
422
|
+
}, links === true || links === undefined ? {} : links);
|
|
423
|
+
this.addLayer(layer);
|
|
424
|
+
return layer;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Where the view is, for the history: the document point a gap below the viewport's top-left (so
|
|
428
|
+
* a page that `goToPage` put at the top, half a gap down, is the one named), and the zoom.
|
|
429
|
+
*/
|
|
430
|
+
#place() {
|
|
431
|
+
const layout = this.#layout;
|
|
432
|
+
if (layout === undefined || layout.pageCount === 0)
|
|
433
|
+
return undefined;
|
|
434
|
+
return { point: pointAt(layout, this.#root.clientWidth, this.#scroll(), { x: 0, y: this.#gap }), zoom: this.#zoom };
|
|
435
|
+
}
|
|
436
|
+
/** Puts the view back at a history `place`: its zoom, then its point a gap below the viewport's top-left. */
|
|
437
|
+
#show(place) {
|
|
438
|
+
this.#touch.stopMomentum();
|
|
439
|
+
if (place.zoom !== this.#zoom) {
|
|
440
|
+
this.#zoom = place.zoom;
|
|
441
|
+
this.#rezoom(undefined);
|
|
442
|
+
}
|
|
443
|
+
const layout = this.#layout;
|
|
444
|
+
if (layout === undefined || place.point.page >= layout.pageCount)
|
|
445
|
+
return;
|
|
446
|
+
const at = contentPosition(layout, this.#root.clientWidth, place.point);
|
|
447
|
+
this.#root.scrollTop = Math.max(0, at.y - this.#gap);
|
|
448
|
+
this.#root.scrollLeft = Math.max(0, at.x);
|
|
449
|
+
this.#schedule();
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Scrolls so that `quads` (a find match, in page `page`'s points) are in view: nothing moves
|
|
453
|
+
* when they already are, else they come to the viewport's centre.
|
|
454
|
+
*/
|
|
455
|
+
#reveal(page, quads) {
|
|
456
|
+
const layout = this.#layout;
|
|
457
|
+
const size = this.#sizes[page];
|
|
458
|
+
if (layout === undefined || size === undefined || page >= layout.pageCount || quads.length < 8)
|
|
459
|
+
return;
|
|
460
|
+
this.#touch.stopMomentum();
|
|
461
|
+
// The corners as fractions of the page, turned with the view (clockwise).
|
|
462
|
+
const turn = (fx, fy) => ({ 0: [fx, fy], 90: [1 - fy, fx], 180: [1 - fx, 1 - fy], 270: [fy, 1 - fx] })[this.#rotation];
|
|
463
|
+
const xs = [];
|
|
464
|
+
const ys = [];
|
|
465
|
+
for (let at = 0; at + 1 < quads.length; at += 2) {
|
|
466
|
+
const [x, y] = turn((quads[at] ?? 0) / size.width, (quads[at + 1] ?? 0) / size.height);
|
|
467
|
+
xs.push(x);
|
|
468
|
+
ys.push(y);
|
|
469
|
+
}
|
|
470
|
+
const viewportWidth = this.#root.clientWidth;
|
|
471
|
+
const pageLeft = layout.leftOf(page, viewportWidth);
|
|
472
|
+
const pageTop = layout.offsetOf(page);
|
|
473
|
+
const width = layout.widths[page] ?? 0;
|
|
474
|
+
const height = layout.heights[page] ?? 0;
|
|
475
|
+
const left = pageLeft + Math.min(...xs) * width;
|
|
476
|
+
const right = pageLeft + Math.max(...xs) * width;
|
|
477
|
+
const top = pageTop + Math.min(...ys) * height;
|
|
478
|
+
const bottom = pageTop + Math.max(...ys) * height;
|
|
479
|
+
const view = this.#scroll();
|
|
480
|
+
const viewportHeight = this.#root.clientHeight;
|
|
481
|
+
if (top < view.top || bottom > view.top + viewportHeight) {
|
|
482
|
+
this.#root.scrollTop = Math.max(0, (top + bottom) / 2 - viewportHeight / 2);
|
|
483
|
+
}
|
|
484
|
+
if (left < view.left || right > view.left + viewportWidth) {
|
|
485
|
+
this.#root.scrollLeft = Math.max(0, (left + right) / 2 - viewportWidth / 2);
|
|
486
|
+
}
|
|
487
|
+
this.#schedule();
|
|
488
|
+
}
|
|
224
489
|
#scroll() {
|
|
225
490
|
return { left: this.#root.scrollLeft, top: this.#root.scrollTop };
|
|
226
491
|
}
|
|
227
492
|
#viewport() {
|
|
228
493
|
return { width: this.#root.clientWidth || 800, height: this.#root.clientHeight || 600 };
|
|
229
494
|
}
|
|
495
|
+
/** The text layer `option` asks for, or none (`false`); it reports the selection as an event too. */
|
|
496
|
+
#textLayer(option) {
|
|
497
|
+
if (option === false)
|
|
498
|
+
return undefined;
|
|
499
|
+
const host = {
|
|
500
|
+
root: this.#root,
|
|
501
|
+
client: this.#client,
|
|
502
|
+
rotation: () => this.#rotation,
|
|
503
|
+
keepMounted: (pages) => this.keepMounted(pages),
|
|
504
|
+
};
|
|
505
|
+
const textOptions = option === true || option === undefined ? {} : option;
|
|
506
|
+
return new TextLayer(host, {
|
|
507
|
+
...textOptions,
|
|
508
|
+
onSelection: (selection) => {
|
|
509
|
+
textOptions.onSelection?.(selection);
|
|
510
|
+
this.#emit("selectionchange", { selection });
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
}
|
|
230
514
|
#emit(event, data) {
|
|
231
515
|
for (const l of this.#events.get(event) ?? [])
|
|
232
516
|
l(data);
|
|
@@ -286,6 +570,8 @@ export class Viewer {
|
|
|
286
570
|
let unsharpened = false;
|
|
287
571
|
for (const view of this.#pages.values()) {
|
|
288
572
|
this.#abort(view);
|
|
573
|
+
// The new geometry needs other canvases: a page that waited for one tries again.
|
|
574
|
+
view.waiting = false;
|
|
289
575
|
if (view.state === "error")
|
|
290
576
|
view.state = "placeholder";
|
|
291
577
|
if (view.state === "sharp") {
|
|
@@ -327,6 +613,7 @@ export class Viewer {
|
|
|
327
613
|
const frame = {
|
|
328
614
|
layout,
|
|
329
615
|
top: this.#root.scrollTop,
|
|
616
|
+
left: this.#root.scrollLeft,
|
|
330
617
|
width: this.#root.clientWidth,
|
|
331
618
|
height: this.#root.clientHeight,
|
|
332
619
|
};
|
|
@@ -334,6 +621,7 @@ export class Viewer {
|
|
|
334
621
|
const [first, last] = this.#mountRange(frame);
|
|
335
622
|
// Writes.
|
|
336
623
|
this.#placeShells(frame, first, last);
|
|
624
|
+
this.#focusJumpTarget();
|
|
337
625
|
this.#setState({
|
|
338
626
|
currentPage: layout.pageAt(frame.top + frame.height / 2),
|
|
339
627
|
scrollTop: frame.top,
|
|
@@ -344,13 +632,17 @@ export class Viewer {
|
|
|
344
632
|
this.#paintRange(frame, visible, first, last);
|
|
345
633
|
this.#emit("renderstats", this.stats());
|
|
346
634
|
}
|
|
347
|
-
/**
|
|
635
|
+
/**
|
|
636
|
+
* Unmounts the shells outside `first..last` (but the pinned ones), mounts the missing ones and
|
|
637
|
+
* positions them all.
|
|
638
|
+
*/
|
|
348
639
|
#placeShells(frame, first, last) {
|
|
349
640
|
const { layout, width } = frame;
|
|
641
|
+
const pinned = [...this.#pinned].filter((i) => i >= 0 && i < layout.pageCount && (i < first || i > last));
|
|
350
642
|
for (const [i, view] of this.#pages)
|
|
351
|
-
if (i < first || i > last)
|
|
643
|
+
if ((i < first || i > last) && !this.#pinned.has(i))
|
|
352
644
|
this.#unmount(view);
|
|
353
|
-
for (
|
|
645
|
+
for (const i of [...pinned, ...range(first, last)]) {
|
|
354
646
|
const view = this.#pages.get(i) ?? this.#mount(i);
|
|
355
647
|
const w = layout.widths[i];
|
|
356
648
|
const h = layout.heights[i];
|
|
@@ -366,6 +658,24 @@ export class Viewer {
|
|
|
366
658
|
if (this.#pages.size > this.#peakMounted)
|
|
367
659
|
this.#peakMounted = this.#pages.size;
|
|
368
660
|
}
|
|
661
|
+
#jumpKeepingFocus(go) {
|
|
662
|
+
this.#keepFocus = true;
|
|
663
|
+
try {
|
|
664
|
+
this.history.jump(go);
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
this.#keepFocus = false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/** Focuses the page the last jump landed on, once it is mounted, keeping the scroll where it is. */
|
|
671
|
+
#focusJumpTarget() {
|
|
672
|
+
const target = this.#focusTarget;
|
|
673
|
+
const view = target === undefined ? undefined : this.#pages.get(target);
|
|
674
|
+
if (view === undefined)
|
|
675
|
+
return;
|
|
676
|
+
this.#focusTarget = undefined;
|
|
677
|
+
view.shell.focus({ preventScroll: true });
|
|
678
|
+
}
|
|
369
679
|
#notifyResize(view, width, height) {
|
|
370
680
|
for (const layer of this.#layers)
|
|
371
681
|
layer.onZoom?.(view.index, this.#slot(view, layer.name), { width, height });
|
|
@@ -383,7 +693,17 @@ export class Viewer {
|
|
|
383
693
|
const shell = document.createElement("div");
|
|
384
694
|
shell.className = "lamina-page";
|
|
385
695
|
shell.dataset.pageIndex = String(index);
|
|
386
|
-
|
|
696
|
+
// A region for assistive technology, and a place focus can land after a jump.
|
|
697
|
+
shell.setAttribute("role", "region");
|
|
698
|
+
shell.setAttribute("aria-roledescription", this.#regions.roleDescription ?? "page");
|
|
699
|
+
shell.setAttribute("aria-label", this.#pageName(index));
|
|
700
|
+
shell.tabIndex = -1;
|
|
701
|
+
// The paper shows before the page is drawn: white, or the page appearance's (--lamina-page-paper).
|
|
702
|
+
Object.assign(shell.style, {
|
|
703
|
+
position: "absolute",
|
|
704
|
+
contain: "strict",
|
|
705
|
+
background: "var(--lamina-page-paper, #fff)",
|
|
706
|
+
});
|
|
387
707
|
this.#spacer.append(shell);
|
|
388
708
|
const view = {
|
|
389
709
|
index,
|
|
@@ -393,7 +713,10 @@ export class Viewer {
|
|
|
393
713
|
controller: new AbortController(),
|
|
394
714
|
state: "placeholder",
|
|
395
715
|
painted: "",
|
|
716
|
+
appearance: appearanceKey(this.#state.appearance),
|
|
717
|
+
scale: 0,
|
|
396
718
|
tiles: new Set(),
|
|
719
|
+
detail: undefined,
|
|
397
720
|
busy: false,
|
|
398
721
|
waiting: false,
|
|
399
722
|
pass: 0,
|
|
@@ -412,6 +735,14 @@ export class Viewer {
|
|
|
412
735
|
slot = document.createElement("div");
|
|
413
736
|
slot.className = `lamina-layer-${name}`;
|
|
414
737
|
Object.assign(slot.style, { position: "absolute", inset: "0" });
|
|
738
|
+
// The slots above the text cover the whole page: taking the pointer, they would sit between
|
|
739
|
+
// every press and the spans, and no drag could select text. Nor are they part of a selection:
|
|
740
|
+
// each element the viewer adds that holds nothing to select sets `none` itself, never an
|
|
741
|
+
// ancestor of the spans (user-select.ts).
|
|
742
|
+
if (name !== "text") {
|
|
743
|
+
slot.style.pointerEvents = "none";
|
|
744
|
+
setUserSelect(slot, "none");
|
|
745
|
+
}
|
|
415
746
|
view.slots.set(name, slot);
|
|
416
747
|
view.shell.append(slot);
|
|
417
748
|
}
|
|
@@ -424,6 +755,10 @@ export class Viewer {
|
|
|
424
755
|
view.pass++;
|
|
425
756
|
view.busy = false;
|
|
426
757
|
}
|
|
758
|
+
#pageName(index) {
|
|
759
|
+
const count = this.#sizes.length;
|
|
760
|
+
return this.#regions.name?.(index, count) ?? `Page ${index + 1} of ${count}`;
|
|
761
|
+
}
|
|
427
762
|
#unmount(view) {
|
|
428
763
|
this.#abort(view);
|
|
429
764
|
for (const layer of this.#layers)
|
|
@@ -437,6 +772,7 @@ export class Viewer {
|
|
|
437
772
|
this.#unmount(view);
|
|
438
773
|
}
|
|
439
774
|
#releaseCanvas(view) {
|
|
775
|
+
this.#releaseDetail(view);
|
|
440
776
|
if (view.canvas !== undefined)
|
|
441
777
|
this.#pool.release(view.canvas);
|
|
442
778
|
view.canvas = undefined;
|
|
@@ -449,45 +785,112 @@ export class Viewer {
|
|
|
449
785
|
other.waiting = false;
|
|
450
786
|
this.#schedule();
|
|
451
787
|
}
|
|
452
|
-
/** The
|
|
453
|
-
#
|
|
454
|
-
const { layout, top, height } = frame;
|
|
455
|
-
const documentId = this.#document?.id ?? 0;
|
|
788
|
+
/** The part of page `index` in view in `frame`, in CSS px from the page's top-left. */
|
|
789
|
+
#inView(index, frame) {
|
|
790
|
+
const { layout, top, left, width, height } = frame;
|
|
456
791
|
const cssW = layout.widths[index];
|
|
457
792
|
const cssH = layout.heights[index];
|
|
458
793
|
const pageTop = layout.offsetOf(index);
|
|
794
|
+
const pageLeft = layout.leftOf(index, width);
|
|
795
|
+
const x0 = Math.max(0, left - pageLeft);
|
|
796
|
+
const x1 = Math.min(cssW, left + width - pageLeft);
|
|
459
797
|
const y0 = Math.max(0, top - pageTop);
|
|
460
798
|
const y1 = Math.min(cssH, top + height - pageTop);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
799
|
+
return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* The sharp tiles of page `index` over `area` (CSS px from the page's top-left) at `scale` (device
|
|
803
|
+
* px per pt), with their render requests and keys. They lie inside the page as the renderer sizes
|
|
804
|
+
* it at that scale.
|
|
805
|
+
*/
|
|
806
|
+
#tiles(index, layout, scale, area) {
|
|
807
|
+
const page = pagePixels(this.#sizes[index], scale, layout.rotation);
|
|
808
|
+
return tilesFor(area, page, scale / layout.scale).map((clip) => this.#tile(index, layout, scale, clip));
|
|
809
|
+
}
|
|
810
|
+
/** Page `index` whole at `scale`, as one tile. */
|
|
811
|
+
#wholePage(index, layout, scale) {
|
|
812
|
+
const page = pagePixels(this.#sizes[index], scale, layout.rotation);
|
|
813
|
+
return this.#tile(index, layout, scale, { x: 0, y: 0, ...page });
|
|
814
|
+
}
|
|
815
|
+
/** The tile `clip` of page `index` at `scale`: its render request and cache key. */
|
|
816
|
+
#tile(index, layout, scale, clip) {
|
|
817
|
+
const request = { page: index, scale, rotation: layout.rotation, clip, ...appearanceField(this.#state.appearance) };
|
|
818
|
+
return { clip, request, key: renderKey(request, this.#document?.id ?? 0) };
|
|
819
|
+
}
|
|
820
|
+
/** The device px per pt of a sharp tile: the layout's scale × the device pixel ratio. */
|
|
821
|
+
#fullScale(layout) {
|
|
822
|
+
return layout.scale * this.#dpr();
|
|
823
|
+
}
|
|
824
|
+
/** Whether `view`'s canvas is below the full pixel ratio, so the part in view needs a detail canvas. */
|
|
825
|
+
#needsDetail(view, layout) {
|
|
826
|
+
return view.scale < this.#fullScale(layout) * (1 - 1e-9);
|
|
827
|
+
}
|
|
828
|
+
/** Every sharp tile now in view is drawn: into the detail canvas, or else into the page canvas. */
|
|
829
|
+
#sharpInView(view, frame) {
|
|
830
|
+
const { layout } = frame;
|
|
831
|
+
const area = this.#inView(view.index, frame);
|
|
832
|
+
if (!this.#needsDetail(view, layout)) {
|
|
833
|
+
return this.#tiles(view.index, layout, view.scale, area).every((t) => view.tiles.has(t.key));
|
|
834
|
+
}
|
|
835
|
+
const tiles = this.#tiles(view.index, layout, this.#fullScale(layout), area);
|
|
836
|
+
// Nothing of the page in view (off to one side, or only the gap below it): nothing to
|
|
837
|
+
// sharpen, and its detail canvas goes back to the pool for a page that has.
|
|
838
|
+
if (tiles.length === 0) {
|
|
839
|
+
this.#releaseDetail(view);
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
const detail = view.detail;
|
|
843
|
+
if (detail === undefined || detail.geometry !== geometryOf(layout))
|
|
844
|
+
return false;
|
|
845
|
+
return tiles.every((t) => detail.tiles.has(t.key));
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Whether `view` may paint now, catching it up with the appearance: a page out of view when the
|
|
849
|
+
* appearance changed waits until it is seen (`prefetch` doesn't paint it), then starts afresh.
|
|
850
|
+
*/
|
|
851
|
+
#readyForAppearance(view, priority) {
|
|
852
|
+
const appearance = appearanceKey(this.#state.appearance);
|
|
853
|
+
if (view.appearance === appearance)
|
|
854
|
+
return true;
|
|
855
|
+
if (priority === "prefetch")
|
|
856
|
+
return false;
|
|
857
|
+
view.appearance = appearance;
|
|
858
|
+
view.painted = "";
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Whether `view`'s canvas needs nothing for `frame`: painted at its geometry and, in view, with
|
|
863
|
+
* every tile in view drawn (then it is sharp). A margin page painted at its geometry waits.
|
|
864
|
+
*/
|
|
865
|
+
#upToDate(view, priority, frame) {
|
|
866
|
+
if (view.painted !== geometryOf(frame.layout))
|
|
867
|
+
return false;
|
|
868
|
+
if (priority === "prefetch")
|
|
869
|
+
return true;
|
|
870
|
+
// Sharp means every tile now in view is drawn: scrolling down a tall page sharpens the rest.
|
|
871
|
+
if (view.scale > 0 && this.#sharpInView(view, frame)) {
|
|
872
|
+
if (view.state !== "sharp") {
|
|
873
|
+
view.state = "sharp";
|
|
874
|
+
this.#emit("renderstats", this.stats());
|
|
875
|
+
}
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
// Tiles in view are missing (the page scrolled): it is no longer sharp until they are drawn.
|
|
879
|
+
if (view.state === "sharp")
|
|
880
|
+
view.state = "low";
|
|
881
|
+
return false;
|
|
466
882
|
}
|
|
467
883
|
/** Starts page `index`'s pass if it isn't busy and its canvas isn't done for `frame` yet. */
|
|
468
884
|
#paint(index, priority, frame) {
|
|
469
885
|
const view = this.#pages.get(index);
|
|
886
|
+
// A page in the margin gives its detail canvas back for the pages in view.
|
|
887
|
+
if (view !== undefined && priority === "prefetch")
|
|
888
|
+
this.#releaseDetail(view);
|
|
470
889
|
if (view === undefined || this.#document === undefined || view.busy || view.waiting || view.state === "error") {
|
|
471
890
|
return;
|
|
472
891
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if (priority === "prefetch")
|
|
476
|
-
return;
|
|
477
|
-
const canvas = view.canvas;
|
|
478
|
-
const ratio = canvas === undefined ? 0 : canvas.width / layout.widths[index];
|
|
479
|
-
// Sharp means every tile now in view is drawn: scrolling down a tall page sharpens the rest.
|
|
480
|
-
if (ratio > 0 && this.#tilesInView(index, frame, ratio).every((t) => view.tiles.has(t.key))) {
|
|
481
|
-
if (view.state !== "sharp") {
|
|
482
|
-
view.state = "sharp";
|
|
483
|
-
this.#emit("renderstats", this.stats());
|
|
484
|
-
}
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
// Tiles in view are missing (the page scrolled): it is no longer sharp until they are drawn.
|
|
488
|
-
if (view.state === "sharp")
|
|
489
|
-
view.state = "low";
|
|
490
|
-
}
|
|
892
|
+
if (!this.#readyForAppearance(view, priority) || this.#upToDate(view, priority, frame))
|
|
893
|
+
return;
|
|
491
894
|
view.busy = true;
|
|
492
895
|
const pass = view.pass;
|
|
493
896
|
void this.#pass(view, pass, priority, frame)
|
|
@@ -524,7 +927,15 @@ export class Viewer {
|
|
|
524
927
|
return;
|
|
525
928
|
if (priority === "prefetch")
|
|
526
929
|
return;
|
|
527
|
-
|
|
930
|
+
if (!this.#needsDetail(view, layout)) {
|
|
931
|
+
this.#releaseDetail(view);
|
|
932
|
+
await this.#paintTiles(pass_, this.#tiles(index, layout, view.scale, this.#inView(index, frame)));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
// The part in view first, sharp; then the whole backdrop in one render (it is capped at
|
|
936
|
+
// BACKDROP_MAX_PIXELS, below the renderer's limit), for what scrolls into view next.
|
|
937
|
+
await this.#paintDetail(pass_, frame);
|
|
938
|
+
await this.#paintTiles(pass_, [this.#wholePage(index, layout, view.scale)]);
|
|
528
939
|
}
|
|
529
940
|
/** The blurry pass: resizes the canvas and stretches a low-resolution page over it. */
|
|
530
941
|
async #paintLow(pass) {
|
|
@@ -534,7 +945,12 @@ export class Viewer {
|
|
|
534
945
|
const cssW = layout.widths[index];
|
|
535
946
|
const cssH = layout.heights[index];
|
|
536
947
|
const shown = rotated(this.#sizes[index], layout.rotation);
|
|
537
|
-
const request = {
|
|
948
|
+
const request = {
|
|
949
|
+
page: index,
|
|
950
|
+
scale: lowScale(layout.scale * dpr, shown.width),
|
|
951
|
+
rotation: layout.rotation,
|
|
952
|
+
...appearanceField(this.#state.appearance),
|
|
953
|
+
};
|
|
538
954
|
const low = await this.#bitmap(request, renderKey(request, this.#document?.id ?? 0), priority, signal);
|
|
539
955
|
if (!live()) {
|
|
540
956
|
this.#drop(low);
|
|
@@ -546,22 +962,65 @@ export class Viewer {
|
|
|
546
962
|
this.#drop(low, false);
|
|
547
963
|
return false;
|
|
548
964
|
}
|
|
549
|
-
|
|
965
|
+
// The canvas's scale: the device pixel ratio, unless the backdrop's pixel cap or the pool's fit
|
|
966
|
+
// lowers it (then a detail canvas takes the part in view). The canvas is the page at that scale
|
|
967
|
+
// exactly as the renderer sizes it, so every tile fits inside.
|
|
968
|
+
const css = { width: cssW, height: cssH };
|
|
969
|
+
const ratio = backdropRatio(dpr, css, this.#backdropMaxPixels);
|
|
970
|
+
// The pool is asked for the page in whole pixels, as the renderer sizes it at that ratio.
|
|
971
|
+
const wanted = pagePixels(this.#sizes[index], layout.scale * ratio, layout.rotation);
|
|
972
|
+
const fitted = this.#pool.fit(canvas, wanted.width, wanted.height);
|
|
973
|
+
const scale = sharpScale(layout.scale, ratio, css, fitted, wanted);
|
|
974
|
+
const { width, height } = pagePixels(this.#sizes[index], scale, layout.rotation);
|
|
975
|
+
// What the canvas shows now, if it is this page in this orientation at another zoom: drawn back
|
|
976
|
+
// at the new size, so a re-zoom never trades sharp pixels for blurry ones.
|
|
977
|
+
const before = await this.#snapshot(view, layout, width, height);
|
|
978
|
+
if (!live()) {
|
|
979
|
+
before?.close();
|
|
980
|
+
this.#drop(low);
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
550
983
|
// Resizing clears the canvas: it happens only now, with the new pixels ready (no white flash).
|
|
551
984
|
canvas.width = width;
|
|
552
985
|
canvas.height = height;
|
|
553
|
-
|
|
986
|
+
// The old pixels cover the whole page, so the low pass is drawn only when there are none.
|
|
987
|
+
const drawn = this.#draw(view, canvas, before ?? low.bitmap, 0, 0, width, height);
|
|
988
|
+
before?.close();
|
|
554
989
|
this.#release(low);
|
|
555
990
|
// Not drawn (evicted meanwhile): the canvas stays unpainted, and the next frame tries again.
|
|
556
991
|
if (!drawn)
|
|
557
992
|
return false;
|
|
558
993
|
view.painted = geometryOf(layout);
|
|
994
|
+
view.scale = scale;
|
|
559
995
|
view.tiles.clear();
|
|
560
996
|
view.state = "low";
|
|
561
|
-
this.#pruneOtherGeometries(index, layout, new Set([request.scale, layout.scale *
|
|
997
|
+
this.#pruneOtherGeometries(index, layout, new Set([request.scale, scale, layout.scale * dpr]));
|
|
562
998
|
this.#emit("renderstats", this.stats());
|
|
563
999
|
return true;
|
|
564
1000
|
}
|
|
1001
|
+
/**
|
|
1002
|
+
* A copy of `view`'s page canvas when it holds the page in `layout`'s rotation at another zoom (the
|
|
1003
|
+
* pixels the user sees stretched), no larger than the new `width` × `height`, for #paintLow to
|
|
1004
|
+
* draw back at the new size; else undefined.
|
|
1005
|
+
*/
|
|
1006
|
+
async #snapshot(view, layout, width, height) {
|
|
1007
|
+
const canvas = view.canvas;
|
|
1008
|
+
if (canvas === undefined || view.painted === "" || rotationOf(view.painted) !== layout.rotation)
|
|
1009
|
+
return undefined;
|
|
1010
|
+
try {
|
|
1011
|
+
// No larger than the new canvas: zooming out shrinks the copy rather than doubling the memory.
|
|
1012
|
+
if (width >= canvas.width && height >= canvas.height)
|
|
1013
|
+
return await createImageBitmap(canvas);
|
|
1014
|
+
return await createImageBitmap(canvas, {
|
|
1015
|
+
resizeWidth: Math.min(width, canvas.width),
|
|
1016
|
+
resizeHeight: Math.min(height, canvas.height),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
catch {
|
|
1020
|
+
// No copy (out of memory, or a browser without resize options): the low pass stands in.
|
|
1021
|
+
return undefined;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
565
1024
|
/** Frees page `index`'s bitmaps of other scales or rotations at once, rather than at the budget. */
|
|
566
1025
|
#pruneOtherGeometries(index, layout, scales) {
|
|
567
1026
|
this.#cache.prune(index, (key) => {
|
|
@@ -569,13 +1028,13 @@ export class Viewer {
|
|
|
569
1028
|
return scales.has(scale) && rotation === layout.rotation;
|
|
570
1029
|
});
|
|
571
1030
|
}
|
|
572
|
-
/**
|
|
573
|
-
async #paintTiles(pass,
|
|
574
|
-
const { view,
|
|
1031
|
+
/** `tiles` (at the canvas's scale) into the page's canvas. */
|
|
1032
|
+
async #paintTiles(pass, tiles) {
|
|
1033
|
+
const { view, priority, signal, live } = pass;
|
|
575
1034
|
const canvas = view.canvas;
|
|
576
1035
|
if (canvas === undefined)
|
|
577
1036
|
return;
|
|
578
|
-
for (const tile of
|
|
1037
|
+
for (const tile of tiles) {
|
|
579
1038
|
if (view.tiles.has(tile.key))
|
|
580
1039
|
continue;
|
|
581
1040
|
const got = await this.#bitmap(tile.request, tile.key, priority, signal);
|
|
@@ -583,12 +1042,115 @@ export class Viewer {
|
|
|
583
1042
|
this.#drop(got);
|
|
584
1043
|
return;
|
|
585
1044
|
}
|
|
586
|
-
const drawn = this.#draw(view, got.bitmap, tile.clip.x, tile.clip.y);
|
|
1045
|
+
const drawn = this.#draw(view, canvas, got.bitmap, tile.clip.x, tile.clip.y);
|
|
587
1046
|
this.#release(got);
|
|
588
1047
|
if (drawn)
|
|
589
1048
|
view.tiles.add(tile.key);
|
|
590
1049
|
}
|
|
591
1050
|
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Sharp tiles over the part in view at the full pixel ratio, into the detail canvas: placed (or
|
|
1053
|
+
* moved) to cover them, its cached tiles drawn at once so it never shows empty, then the rest.
|
|
1054
|
+
*/
|
|
1055
|
+
async #paintDetail(pass, frame) {
|
|
1056
|
+
const { view, layout, priority, signal, live } = pass;
|
|
1057
|
+
const tiles = this.#tiles(view.index, layout, this.#fullScale(layout), this.#inView(view.index, frame));
|
|
1058
|
+
const rect = bounds(tiles.map((t) => t.clip));
|
|
1059
|
+
if (rect === undefined) {
|
|
1060
|
+
this.#releaseDetail(view);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const detail = this.#placeDetail(view, layout, rect);
|
|
1064
|
+
if (detail === undefined) {
|
|
1065
|
+
// No detail canvas to be had: the page shows its backdrop and waits until one is released,
|
|
1066
|
+
// rather than try again every frame.
|
|
1067
|
+
this.#releaseDetail(view);
|
|
1068
|
+
view.waiting = true;
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const missing = tiles.filter((tile) => {
|
|
1072
|
+
if (detail.tiles.has(tile.key))
|
|
1073
|
+
return false;
|
|
1074
|
+
const cached = this.#cache.get(tile.key);
|
|
1075
|
+
if (cached === undefined || !this.#drawDetail(view, detail, cached, tile.clip))
|
|
1076
|
+
return true;
|
|
1077
|
+
detail.tiles.add(tile.key);
|
|
1078
|
+
return false;
|
|
1079
|
+
});
|
|
1080
|
+
for (const tile of missing) {
|
|
1081
|
+
const got = await this.#bitmap(tile.request, tile.key, priority, signal);
|
|
1082
|
+
if (!live() || view.detail !== detail) {
|
|
1083
|
+
this.#drop(got);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const drawn = this.#drawDetail(view, detail, got.bitmap, tile.clip);
|
|
1087
|
+
this.#release(got);
|
|
1088
|
+
if (drawn)
|
|
1089
|
+
detail.tiles.add(tile.key);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* `view`'s detail canvas covering `rect` (device px at the full scale): the one it has, if it
|
|
1094
|
+
* already covers it at this geometry, else one sized and placed for `rect`. Undefined when the
|
|
1095
|
+
* detail pool has no canvas or not the pixels (the page then shows its backdrop).
|
|
1096
|
+
*/
|
|
1097
|
+
#placeDetail(view, layout, rect) {
|
|
1098
|
+
const scale = this.#fullScale(layout);
|
|
1099
|
+
const geometry = geometryOf(layout);
|
|
1100
|
+
const old = view.detail;
|
|
1101
|
+
if (old !== undefined && old.geometry === geometry && old.scale === scale && contains(old.rect, rect))
|
|
1102
|
+
return old;
|
|
1103
|
+
// Past the device's cap for one canvas (a large tablet's viewport), the page keeps its backdrop.
|
|
1104
|
+
if (rect.width * rect.height > this.#canvasMaxPixels)
|
|
1105
|
+
return undefined;
|
|
1106
|
+
const canvas = old?.canvas ?? this.#detailPool.acquire();
|
|
1107
|
+
if (canvas === undefined)
|
|
1108
|
+
return undefined;
|
|
1109
|
+
const fitted = this.#detailPool.fit(canvas, rect.width, rect.height);
|
|
1110
|
+
if (fitted.width < rect.width || fitted.height < rect.height) {
|
|
1111
|
+
if (old === undefined)
|
|
1112
|
+
this.#detailPool.release(canvas);
|
|
1113
|
+
return undefined;
|
|
1114
|
+
}
|
|
1115
|
+
// Resizing clears it: #paintDetail draws the cached tiles back before the next frame.
|
|
1116
|
+
canvas.width = rect.width;
|
|
1117
|
+
canvas.height = rect.height;
|
|
1118
|
+
// In percentages of the shell, so it stretches with the page while a zoom gesture runs.
|
|
1119
|
+
const page = pagePixels(this.#sizes[view.index], scale, layout.rotation);
|
|
1120
|
+
const percent = (n, of) => `${(100 * n) / of}%`;
|
|
1121
|
+
Object.assign(canvas.style, {
|
|
1122
|
+
position: "absolute",
|
|
1123
|
+
left: percent(rect.x, page.width),
|
|
1124
|
+
top: percent(rect.y, page.height),
|
|
1125
|
+
width: percent(rect.width, page.width),
|
|
1126
|
+
height: percent(rect.height, page.height),
|
|
1127
|
+
});
|
|
1128
|
+
if (old === undefined) {
|
|
1129
|
+
canvas.className = "lamina-page-detail";
|
|
1130
|
+
setUserSelect(canvas, "none");
|
|
1131
|
+
if (view.canvas === undefined)
|
|
1132
|
+
view.shell.prepend(canvas);
|
|
1133
|
+
else
|
|
1134
|
+
view.canvas.after(canvas);
|
|
1135
|
+
}
|
|
1136
|
+
const detail = { canvas, rect, scale, geometry, tiles: new Set() };
|
|
1137
|
+
view.detail = detail;
|
|
1138
|
+
return detail;
|
|
1139
|
+
}
|
|
1140
|
+
#releaseDetail(view) {
|
|
1141
|
+
if (view.detail === undefined)
|
|
1142
|
+
return;
|
|
1143
|
+
this.#detailPool.release(view.detail.canvas);
|
|
1144
|
+
view.detail = undefined;
|
|
1145
|
+
// A detail canvas is free again: pages that waited for one try now.
|
|
1146
|
+
for (const other of this.#pages.values())
|
|
1147
|
+
other.waiting = false;
|
|
1148
|
+
this.#schedule();
|
|
1149
|
+
}
|
|
1150
|
+
/** Draws a sharp tile at `clip` (device px of the page) into `detail`, offset by its rectangle. */
|
|
1151
|
+
#drawDetail(view, detail, bitmap, clip) {
|
|
1152
|
+
return this.#draw(view, detail.canvas, bitmap, clip.x - detail.rect.x, clip.y - detail.rect.y);
|
|
1153
|
+
}
|
|
592
1154
|
/**
|
|
593
1155
|
* A bitmap for `request`, from the cache or the worker. `owned` is true when the cache didn't
|
|
594
1156
|
* keep it, so the caller closes it after drawing. A cached bitmap another page's pass evicted
|
|
@@ -612,9 +1174,9 @@ export class Viewer {
|
|
|
612
1174
|
if (got.owned)
|
|
613
1175
|
got.bitmap.close();
|
|
614
1176
|
}
|
|
615
|
-
/** The one place pixels reach a canvas
|
|
616
|
-
#draw(view, bitmap, x, y, width, height) {
|
|
617
|
-
const ctx =
|
|
1177
|
+
/** The one place pixels reach a canvas of `view`: it counts a draw onto a shell out of the DOM. */
|
|
1178
|
+
#draw(view, canvas, bitmap, x, y, width, height) {
|
|
1179
|
+
const ctx = canvas.getContext("2d");
|
|
618
1180
|
// A bitmap another pass's cache write evicted (closed) after it was fetched: not drawn.
|
|
619
1181
|
if (ctx === undefined || ctx === null || isClosed(bitmap))
|
|
620
1182
|
return false;
|
|
@@ -655,6 +1217,7 @@ export class Viewer {
|
|
|
655
1217
|
if (canvas === undefined)
|
|
656
1218
|
return undefined;
|
|
657
1219
|
canvas.className = "lamina-page-canvas";
|
|
1220
|
+
setUserSelect(canvas, "none");
|
|
658
1221
|
Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
|
|
659
1222
|
view.shell.prepend(canvas);
|
|
660
1223
|
view.canvas = canvas;
|
|
@@ -675,13 +1238,14 @@ export class Viewer {
|
|
|
675
1238
|
this.#on("scroll", () => this.#schedule(), { passive: true });
|
|
676
1239
|
this.#listenWheel();
|
|
677
1240
|
this.#listenKeys();
|
|
678
|
-
this.#
|
|
1241
|
+
this.#listenTouch();
|
|
679
1242
|
this.#listenGestures();
|
|
680
1243
|
this.#listenResize();
|
|
681
1244
|
}
|
|
682
1245
|
/** Ctrl/cmd+wheel and trackpad pinch (which browsers send as ctrl+wheel): zoom at the cursor. */
|
|
683
1246
|
#listenWheel() {
|
|
684
1247
|
this.#on("wheel", (e) => {
|
|
1248
|
+
this.#touch.stopMomentum();
|
|
685
1249
|
if (!e.ctrlKey && !e.metaKey)
|
|
686
1250
|
return;
|
|
687
1251
|
e.preventDefault();
|
|
@@ -693,6 +1257,7 @@ export class Viewer {
|
|
|
693
1257
|
/** `+` and `-` zoom by a quarter, `0` goes to actual size. */
|
|
694
1258
|
#listenKeys() {
|
|
695
1259
|
this.#on("keydown", (e) => {
|
|
1260
|
+
this.#touch.stopMomentum();
|
|
696
1261
|
if (e.altKey)
|
|
697
1262
|
return;
|
|
698
1263
|
const p = this.#state.percent;
|
|
@@ -707,42 +1272,39 @@ export class Viewer {
|
|
|
707
1272
|
e.preventDefault();
|
|
708
1273
|
});
|
|
709
1274
|
}
|
|
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;
|
|
1275
|
+
/** Touch and pen: pan with momentum, pinch around the fingers, double-tap zoom (view/touch.ts). */
|
|
1276
|
+
#listenTouch() {
|
|
1277
|
+
const gestures = this.#touch;
|
|
1278
|
+
this.#on("pointerdown", (e) => gestures.down(e));
|
|
1279
|
+
this.#on("pointermove", (e) => gestures.move(e));
|
|
1280
|
+
for (const end of ["pointerup", "pointercancel", "lostpointercapture"]) {
|
|
1281
|
+
this.#on(end, (e) => gestures.up(e));
|
|
1282
|
+
}
|
|
1283
|
+
this.#cleanup.push(() => gestures.stop());
|
|
1284
|
+
}
|
|
1285
|
+
/** The gestures' view of this viewer; it reads the root when called, so it may be made before the root. */
|
|
1286
|
+
#touchHost() {
|
|
1287
|
+
return {
|
|
1288
|
+
percent: () => this.#state.percent,
|
|
1289
|
+
fitsWidth: () => this.#zoom === "fit-width",
|
|
1290
|
+
zoom: (to, anchor) => this.setZoom(to, { anchor }),
|
|
1291
|
+
pointAt: (at) => this.pointAt(at.x, at.y),
|
|
1292
|
+
positionOf: (point) => this.positionOf(point),
|
|
1293
|
+
scrollBy: (dx, dy) => this.#root.scrollBy({ left: dx, top: dy, behavior: "instant" }),
|
|
1294
|
+
local: (e) => this.#anchorAt(e.clientX, e.clientY),
|
|
1295
|
+
frame: (callback) => requestAnimationFrame(callback),
|
|
1296
|
+
cancelFrame: (id) => cancelAnimationFrame(id),
|
|
1297
|
+
capture: (id) => {
|
|
1298
|
+
try {
|
|
1299
|
+
this.#root.setPointerCapture(id);
|
|
1300
|
+
}
|
|
1301
|
+
catch {
|
|
1302
|
+
// The pointer is already up: nothing to keep.
|
|
1303
|
+
}
|
|
1304
|
+
},
|
|
741
1305
|
};
|
|
742
|
-
this.#on("pointerup", up);
|
|
743
|
-
this.#on("pointercancel", up);
|
|
744
1306
|
}
|
|
745
|
-
/** Safari's trackpad pinch: gesture events with a scale. */
|
|
1307
|
+
/** Safari's trackpad pinch: gesture events with a scale (and iOS's touch pinch, which the touch gestures take). */
|
|
746
1308
|
#listenGestures() {
|
|
747
1309
|
let gesture;
|
|
748
1310
|
this.#on("gesturestart", (e) => {
|
|
@@ -753,6 +1315,9 @@ export class Viewer {
|
|
|
753
1315
|
if (gesture === undefined)
|
|
754
1316
|
return;
|
|
755
1317
|
e.preventDefault();
|
|
1318
|
+
// iOS sends these for a two-finger touch pinch too: the touch gestures zoom it then.
|
|
1319
|
+
if (this.#touch.pinching())
|
|
1320
|
+
return;
|
|
756
1321
|
const ge = e;
|
|
757
1322
|
this.setZoom(gesture * ge.scale, { anchor: this.#anchorAt(ge.clientX, ge.clientY) });
|
|
758
1323
|
});
|