@laminapdf/viewer 0.0.0-stage → 0.2.0-alpha.3
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 +176 -2
- package/dist/canvas/cache.d.ts +35 -0
- package/dist/canvas/cache.d.ts.map +1 -0
- package/dist/canvas/cache.js +89 -0
- package/dist/canvas/cache.js.map +1 -0
- package/dist/canvas/pool.d.ts +35 -0
- package/dist/canvas/pool.d.ts.map +1 -0
- package/dist/canvas/pool.js +65 -0
- package/dist/canvas/pool.js.map +1 -0
- package/dist/client.d.ts +114 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +240 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/page-layout.d.ts +58 -0
- package/dist/layout/page-layout.d.ts.map +1 -0
- package/dist/layout/page-layout.js +110 -0
- package/dist/layout/page-layout.js.map +1 -0
- package/dist/protocol.d.ts +107 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +36 -0
- package/dist/protocol.js.map +1 -0
- package/dist/render/tiles.d.ts +17 -0
- package/dist/render/tiles.d.ts.map +1 -0
- package/dist/render/tiles.js +29 -0
- package/dist/render/tiles.js.map +1 -0
- package/dist/source.d.ts +9 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +27 -0
- package/dist/source.js.map +1 -0
- package/dist/view/viewer.d.ts +135 -0
- package/dist/view/viewer.d.ts.map +1 -0
- package/dist/view/viewer.js +781 -0
- package/dist/view/viewer.js.map +1 -0
- package/dist/worker/document.d.ts +22 -0
- package/dist/worker/document.d.ts.map +1 -0
- package/dist/worker/document.js +122 -0
- package/dist/worker/document.js.map +1 -0
- package/dist/worker/entry.d.ts +2 -0
- package/dist/worker/entry.d.ts.map +1 -0
- package/dist/worker/entry.js +7 -0
- package/dist/worker/entry.js.map +1 -0
- package/dist/worker/methods.d.ts +4 -0
- package/dist/worker/methods.d.ts.map +1 -0
- package/dist/worker/methods.js +19 -0
- package/dist/worker/methods.js.map +1 -0
- package/dist/worker/render.d.ts +7 -0
- package/dist/worker/render.d.ts.map +1 -0
- package/dist/worker/render.js +22 -0
- package/dist/worker/render.js.map +1 -0
- package/dist/zoom/zoom.d.ts +44 -0
- package/dist/zoom/zoom.d.ts.map +1 -0
- package/dist/zoom/zoom.js +56 -0
- package/dist/zoom/zoom.js.map +1 -0
- package/package.json +55 -3
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
// The viewer: a scroll container whose spacer is the whole document's height, and page
|
|
2
|
+
// shells for only the pages in view plus a margin. Each mounted page borrows a canvas from a
|
|
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
|
|
5
|
+
// that leaves the margin aborts its renders, and a result that arrives for it anyway is dropped,
|
|
6
|
+
// never drawn. Zoom keeps the document point under the cursor (or the viewport centre) where it
|
|
7
|
+
// is; during a gesture the canvases are stretched by CSS and the sharp re-render waits until the
|
|
8
|
+
// gesture settles. Framework-free: UI layers read `getState()`, `subscribe()` and the events.
|
|
9
|
+
import { BitmapCache, DEFAULT_CACHE_BYTES } from "../canvas/cache.js";
|
|
10
|
+
import { CanvasPool } from "../canvas/pool.js";
|
|
11
|
+
import { isRotation, PageLayout, rotated } from "../layout/page-layout.js";
|
|
12
|
+
import { renderKey, renderKeyFields } from "../protocol.js";
|
|
13
|
+
import { lowScale, tilesFor } from "../render/tiles.js";
|
|
14
|
+
import { contentPosition, percentOfScale, pointAt, scaleFor, scrollToPoint, } from "../zoom/zoom.js";
|
|
15
|
+
/** The built-in layer slots, bottom to top, above the canvas. */
|
|
16
|
+
const SLOTS = ["text", "annot", "highlight"];
|
|
17
|
+
/** The tallest scroll space browsers handle (Firefox stops near 17.9 M CSS px). */
|
|
18
|
+
const MAX_HEIGHT = 16_000_000;
|
|
19
|
+
function isAbort(e) {
|
|
20
|
+
return e instanceof DOMException && e.name === "AbortError";
|
|
21
|
+
}
|
|
22
|
+
/** What a painted canvas depends on: a zoom or a rotation makes it stale. */
|
|
23
|
+
function geometryOf(layout) {
|
|
24
|
+
return `${layout.scale}:${layout.rotation}`;
|
|
25
|
+
}
|
|
26
|
+
/** A closed ImageBitmap has no size: one the cache evicted while a pass held it. */
|
|
27
|
+
function isClosed(bitmap) {
|
|
28
|
+
return bitmap.width === 0 || bitmap.height === 0;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The viewer: a scroll container over one document's pages. Make one with {@link createViewer},
|
|
32
|
+
* `mount` it, then `open` a document from the client.
|
|
33
|
+
*/
|
|
34
|
+
export class Viewer {
|
|
35
|
+
#client;
|
|
36
|
+
#margin;
|
|
37
|
+
#gap;
|
|
38
|
+
#settleMs;
|
|
39
|
+
#dpr;
|
|
40
|
+
#pool;
|
|
41
|
+
#cache;
|
|
42
|
+
#root;
|
|
43
|
+
#spacer;
|
|
44
|
+
#pages = new Map();
|
|
45
|
+
#layers = [];
|
|
46
|
+
#listeners = new Set();
|
|
47
|
+
#events = new Map();
|
|
48
|
+
#cleanup = [];
|
|
49
|
+
#document;
|
|
50
|
+
#sizes = [];
|
|
51
|
+
#layout;
|
|
52
|
+
#zoom;
|
|
53
|
+
#rotation = 0;
|
|
54
|
+
#state;
|
|
55
|
+
#frame;
|
|
56
|
+
#settling;
|
|
57
|
+
#peakMounted = 0;
|
|
58
|
+
#drawsOnUnmounted = 0;
|
|
59
|
+
#dropped = 0;
|
|
60
|
+
#aborted = 0;
|
|
61
|
+
#destroyed = false;
|
|
62
|
+
constructor(options) {
|
|
63
|
+
this.#client = options.client;
|
|
64
|
+
this.#margin = options.margin ?? 2;
|
|
65
|
+
this.#gap = options.gap ?? 16;
|
|
66
|
+
this.#settleMs = options.settleMs ?? 150;
|
|
67
|
+
this.#dpr = options.dpr ?? (() => globalThis.devicePixelRatio || 1);
|
|
68
|
+
this.#pool = new CanvasPool(options.pool);
|
|
69
|
+
this.#cache = new BitmapCache(options.cacheBytes ?? DEFAULT_CACHE_BYTES);
|
|
70
|
+
this.#zoom = options.zoom ?? "fit-width";
|
|
71
|
+
this.#root = document.createElement("div");
|
|
72
|
+
this.#root.className = "lamina-viewer";
|
|
73
|
+
this.#root.tabIndex = 0;
|
|
74
|
+
Object.assign(this.#root.style, { position: "relative", overflow: "auto", width: "100%", height: "100%" });
|
|
75
|
+
this.#spacer = document.createElement("div");
|
|
76
|
+
this.#spacer.className = "lamina-viewer-content";
|
|
77
|
+
Object.assign(this.#spacer.style, { position: "relative" });
|
|
78
|
+
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
|
+
};
|
|
88
|
+
this.#listen();
|
|
89
|
+
}
|
|
90
|
+
/** The scroll container (append it, or use {@link mount}). */
|
|
91
|
+
get element() {
|
|
92
|
+
return this.#root;
|
|
93
|
+
}
|
|
94
|
+
/** Puts the viewer into `container` (which gives it its size). */
|
|
95
|
+
mount(container) {
|
|
96
|
+
container.append(this.#root);
|
|
97
|
+
this.#schedule();
|
|
98
|
+
}
|
|
99
|
+
/** Shows `document` (an `open()` result of the client), at the initial zoom, from page 0. */
|
|
100
|
+
open(document) {
|
|
101
|
+
this.#unmountAll();
|
|
102
|
+
this.#cache.clear();
|
|
103
|
+
this.#document = document;
|
|
104
|
+
this.#sizes = document.pages.map((p) => ({ width: p.width, height: p.height }));
|
|
105
|
+
this.#layout = undefined;
|
|
106
|
+
this.#root.scrollTop = 0;
|
|
107
|
+
this.#root.scrollLeft = 0;
|
|
108
|
+
this.#relayout(this.#viewport());
|
|
109
|
+
this.#schedule();
|
|
110
|
+
}
|
|
111
|
+
/** The current view state (a new object after each change). */
|
|
112
|
+
getState() {
|
|
113
|
+
return this.#state;
|
|
114
|
+
}
|
|
115
|
+
/** Calls `listener` after every state change (for `useSyncExternalStore`). */
|
|
116
|
+
subscribe(listener) {
|
|
117
|
+
this.#listeners.add(listener);
|
|
118
|
+
return () => this.#listeners.delete(listener);
|
|
119
|
+
}
|
|
120
|
+
/** Listens for `event`; returns a function that stops listening. */
|
|
121
|
+
on(event, listener) {
|
|
122
|
+
let set = this.#events.get(event);
|
|
123
|
+
if (set === undefined) {
|
|
124
|
+
set = new Set();
|
|
125
|
+
this.#events.set(event, set);
|
|
126
|
+
}
|
|
127
|
+
const l = listener;
|
|
128
|
+
set.add(l);
|
|
129
|
+
return () => set.delete(l);
|
|
130
|
+
}
|
|
131
|
+
/** Adds a layer to every page, mounted now and later; returns a function that removes it. */
|
|
132
|
+
addLayer(layer) {
|
|
133
|
+
this.#layers.push(layer);
|
|
134
|
+
for (const view of this.#pages.values())
|
|
135
|
+
layer.onMount?.(view.index, this.#slot(view, layer.name));
|
|
136
|
+
return () => {
|
|
137
|
+
const i = this.#layers.indexOf(layer);
|
|
138
|
+
if (i >= 0)
|
|
139
|
+
this.#layers.splice(i, 1);
|
|
140
|
+
for (const view of this.#pages.values())
|
|
141
|
+
layer.onUnmount?.(view.index, this.#slot(view, layer.name));
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Scrolls page `index` to the top of the viewport (or its centre). */
|
|
145
|
+
goToPage(index, options = {}) {
|
|
146
|
+
const layout = this.#layout;
|
|
147
|
+
if (layout === undefined || layout.pageCount === 0)
|
|
148
|
+
return;
|
|
149
|
+
const page = Math.min(layout.pageCount - 1, Math.max(0, Math.trunc(index)));
|
|
150
|
+
const top = layout.offsetOf(page) - this.#gap / 2;
|
|
151
|
+
this.#root.scrollTop =
|
|
152
|
+
options.anchor === "center"
|
|
153
|
+
? top + layout.heights[page] / 2 - this.#root.clientHeight / 2
|
|
154
|
+
: Math.max(0, top);
|
|
155
|
+
this.#schedule();
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Sets the zoom (a mode, or a percentage clamped to 10–800 %), keeping the document point at
|
|
159
|
+
* `anchor` (viewport CSS px; default its centre) where it is.
|
|
160
|
+
*/
|
|
161
|
+
setZoom(zoom, options = {}) {
|
|
162
|
+
this.#zoom = zoom;
|
|
163
|
+
this.#rezoom(options.anchor);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Rotates the view by `delta` degrees, keeping the viewport's centre.
|
|
167
|
+
*
|
|
168
|
+
* @throws RangeError when `delta` isn't a multiple of 90.
|
|
169
|
+
*/
|
|
170
|
+
rotate(delta) {
|
|
171
|
+
const next = (((this.#rotation + delta) % 360) + 360) % 360;
|
|
172
|
+
if (!isRotation(next))
|
|
173
|
+
throw new RangeError(`rotate() takes a multiple of 90, got ${delta}`);
|
|
174
|
+
this.#rotation = next;
|
|
175
|
+
this.#rezoom(undefined);
|
|
176
|
+
}
|
|
177
|
+
/** The document point at viewport position (`x`, `y`) in CSS px. */
|
|
178
|
+
pointAt(x, y) {
|
|
179
|
+
const layout = this.#layout;
|
|
180
|
+
if (layout === undefined || layout.pageCount === 0)
|
|
181
|
+
return undefined;
|
|
182
|
+
return pointAt(layout, this.#root.clientWidth, this.#scroll(), { x, y });
|
|
183
|
+
}
|
|
184
|
+
/** Where `point` is now, in viewport CSS px. */
|
|
185
|
+
positionOf(point) {
|
|
186
|
+
const layout = this.#layout;
|
|
187
|
+
if (layout === undefined)
|
|
188
|
+
return undefined;
|
|
189
|
+
const at = contentPosition(layout, this.#root.clientWidth, point);
|
|
190
|
+
const scroll = this.#scroll();
|
|
191
|
+
return { x: at.x - scroll.left, y: at.y - scroll.top };
|
|
192
|
+
}
|
|
193
|
+
/** The counters (see RenderStats). */
|
|
194
|
+
stats() {
|
|
195
|
+
const pages = {};
|
|
196
|
+
for (const [i, v] of this.#pages)
|
|
197
|
+
pages[i] = v.state;
|
|
198
|
+
return {
|
|
199
|
+
mounted: this.#pages.size,
|
|
200
|
+
peakMounted: this.#peakMounted,
|
|
201
|
+
canvases: this.#pool.inUse,
|
|
202
|
+
peakCanvases: this.#pool.peak,
|
|
203
|
+
maxCanvases: this.#pool.max,
|
|
204
|
+
drawsOnUnmounted: this.#drawsOnUnmounted,
|
|
205
|
+
dropped: this.#dropped,
|
|
206
|
+
aborted: this.#aborted,
|
|
207
|
+
cacheBytes: this.#cache.bytes,
|
|
208
|
+
pages,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** Takes the viewer down: renders aborted, canvases and bitmaps freed, listeners removed. */
|
|
212
|
+
destroy() {
|
|
213
|
+
this.#destroyed = true;
|
|
214
|
+
if (this.#frame !== undefined)
|
|
215
|
+
cancelAnimationFrame(this.#frame);
|
|
216
|
+
clearTimeout(this.#settling);
|
|
217
|
+
this.#unmountAll();
|
|
218
|
+
this.#cache.clear();
|
|
219
|
+
for (const f of this.#cleanup.splice(0))
|
|
220
|
+
f();
|
|
221
|
+
this.#root.remove();
|
|
222
|
+
}
|
|
223
|
+
// --- internals ---
|
|
224
|
+
#scroll() {
|
|
225
|
+
return { left: this.#root.scrollLeft, top: this.#root.scrollTop };
|
|
226
|
+
}
|
|
227
|
+
#viewport() {
|
|
228
|
+
return { width: this.#root.clientWidth || 800, height: this.#root.clientHeight || 600 };
|
|
229
|
+
}
|
|
230
|
+
#emit(event, data) {
|
|
231
|
+
for (const l of this.#events.get(event) ?? [])
|
|
232
|
+
l(data);
|
|
233
|
+
}
|
|
234
|
+
#setState(next) {
|
|
235
|
+
const prev = this.#state;
|
|
236
|
+
this.#state = { ...prev, ...next };
|
|
237
|
+
if (next.currentPage !== undefined && next.currentPage !== prev.currentPage) {
|
|
238
|
+
this.#emit("pagechange", { page: next.currentPage });
|
|
239
|
+
}
|
|
240
|
+
for (const l of this.#listeners)
|
|
241
|
+
l();
|
|
242
|
+
}
|
|
243
|
+
/** The reference page for fit modes: the current one. */
|
|
244
|
+
#reference() {
|
|
245
|
+
return this.#sizes[this.#state.currentPage] ?? this.#sizes[0] ?? { width: 612, height: 792 };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Builds the layout for the current zoom and rotation in `viewport` (read by the caller, before
|
|
249
|
+
* any write), capped below the height browsers can scroll, and writes the spacer's size.
|
|
250
|
+
*/
|
|
251
|
+
#relayout(viewport) {
|
|
252
|
+
let scale = scaleFor(this.#zoom, this.#reference(), this.#rotation, viewport, this.#gap);
|
|
253
|
+
let layout = new PageLayout(this.#sizes, { scale, rotation: this.#rotation, gap: this.#gap });
|
|
254
|
+
if (layout.height > MAX_HEIGHT) {
|
|
255
|
+
// Past what browsers can scroll: cap the zoom for this document.
|
|
256
|
+
scale *= MAX_HEIGHT / layout.height;
|
|
257
|
+
layout = new PageLayout(this.#sizes, { scale, rotation: this.#rotation, gap: this.#gap });
|
|
258
|
+
}
|
|
259
|
+
const zoomed = this.#layout === undefined || this.#layout.scale !== layout.scale || this.#layout.rotation !== layout.rotation;
|
|
260
|
+
this.#layout = layout;
|
|
261
|
+
this.#spacer.style.height = `${layout.height}px`;
|
|
262
|
+
this.#spacer.style.width = `${layout.contentWidth(viewport.width)}px`;
|
|
263
|
+
if (zoomed) {
|
|
264
|
+
const percent = percentOfScale(layout.scale);
|
|
265
|
+
this.#setState({ pageCount: layout.pageCount, zoom: this.#zoom, percent, rotation: this.#rotation });
|
|
266
|
+
this.#emit("zoomchange", { zoom: this.#zoom, percent });
|
|
267
|
+
}
|
|
268
|
+
return layout;
|
|
269
|
+
}
|
|
270
|
+
#rezoom(anchor) {
|
|
271
|
+
// Every read before the first write.
|
|
272
|
+
const viewport = this.#viewport();
|
|
273
|
+
const before = this.#layout;
|
|
274
|
+
if (before === undefined || before.pageCount === 0) {
|
|
275
|
+
this.#relayout(viewport);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const at = anchor ?? { x: viewport.width / 2, y: viewport.height / 2 };
|
|
279
|
+
const point = pointAt(before, viewport.width, this.#scroll(), at);
|
|
280
|
+
const after = this.#relayout(viewport);
|
|
281
|
+
const scroll = scrollToPoint(after, viewport.width, point, at);
|
|
282
|
+
this.#root.scrollTop = scroll.top;
|
|
283
|
+
this.#root.scrollLeft = scroll.left;
|
|
284
|
+
// Renders for the old geometry are stale: abort them (a page that failed gets another try);
|
|
285
|
+
// the canvases stretch until the gesture settles and the new pass paints, so no page is sharp.
|
|
286
|
+
let unsharpened = false;
|
|
287
|
+
for (const view of this.#pages.values()) {
|
|
288
|
+
this.#abort(view);
|
|
289
|
+
if (view.state === "error")
|
|
290
|
+
view.state = "placeholder";
|
|
291
|
+
if (view.state === "sharp") {
|
|
292
|
+
view.state = "low";
|
|
293
|
+
unsharpened = true;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (unsharpened)
|
|
297
|
+
this.#emit("renderstats", this.stats());
|
|
298
|
+
clearTimeout(this.#settling);
|
|
299
|
+
this.#settling = setTimeout(() => {
|
|
300
|
+
this.#settling = undefined;
|
|
301
|
+
this.#schedule();
|
|
302
|
+
}, this.#settleMs);
|
|
303
|
+
this.#schedule();
|
|
304
|
+
}
|
|
305
|
+
#schedule() {
|
|
306
|
+
if (this.#frame !== undefined || this.#destroyed)
|
|
307
|
+
return;
|
|
308
|
+
this.#frame = requestAnimationFrame(() => {
|
|
309
|
+
this.#frame = undefined;
|
|
310
|
+
this.#update();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/** The mounted range: visible ± margin pages, and at least a viewport above and below. */
|
|
314
|
+
#mountRange(frame) {
|
|
315
|
+
const { layout, top, height } = frame;
|
|
316
|
+
const visible = layout.rangeIn(top, top + height);
|
|
317
|
+
const first = Math.max(0, Math.min(visible.first - this.#margin, layout.pageAt(top - height)));
|
|
318
|
+
const last = Math.min(layout.pageCount - 1, Math.max(visible.last + this.#margin, layout.pageAt(top + 2 * height)));
|
|
319
|
+
return [first, last];
|
|
320
|
+
}
|
|
321
|
+
/** One frame: every read first, then every write. */
|
|
322
|
+
#update() {
|
|
323
|
+
const layout = this.#layout;
|
|
324
|
+
if (layout === undefined || layout.pageCount === 0 || this.#destroyed)
|
|
325
|
+
return;
|
|
326
|
+
// Reads.
|
|
327
|
+
const frame = {
|
|
328
|
+
layout,
|
|
329
|
+
top: this.#root.scrollTop,
|
|
330
|
+
width: this.#root.clientWidth,
|
|
331
|
+
height: this.#root.clientHeight,
|
|
332
|
+
};
|
|
333
|
+
const visible = layout.rangeIn(frame.top, frame.top + frame.height);
|
|
334
|
+
const [first, last] = this.#mountRange(frame);
|
|
335
|
+
// Writes.
|
|
336
|
+
this.#placeShells(frame, first, last);
|
|
337
|
+
this.#setState({
|
|
338
|
+
currentPage: layout.pageAt(frame.top + frame.height / 2),
|
|
339
|
+
scrollTop: frame.top,
|
|
340
|
+
visibleRange: visible,
|
|
341
|
+
});
|
|
342
|
+
// Renders: nothing while a zoom gesture is under way.
|
|
343
|
+
if (this.#settling === undefined)
|
|
344
|
+
this.#paintRange(frame, visible, first, last);
|
|
345
|
+
this.#emit("renderstats", this.stats());
|
|
346
|
+
}
|
|
347
|
+
/** Unmounts the shells outside `first..last`, mounts the missing ones and positions them all. */
|
|
348
|
+
#placeShells(frame, first, last) {
|
|
349
|
+
const { layout, width } = frame;
|
|
350
|
+
for (const [i, view] of this.#pages)
|
|
351
|
+
if (i < first || i > last)
|
|
352
|
+
this.#unmount(view);
|
|
353
|
+
for (let i = first; i <= last; i++) {
|
|
354
|
+
const view = this.#pages.get(i) ?? this.#mount(i);
|
|
355
|
+
const w = layout.widths[i];
|
|
356
|
+
const h = layout.heights[i];
|
|
357
|
+
const s = view.shell.style;
|
|
358
|
+
const resized = s.width !== `${w}px`;
|
|
359
|
+
s.top = `${layout.offsetOf(i)}px`;
|
|
360
|
+
s.left = `${layout.leftOf(i, width)}px`;
|
|
361
|
+
s.width = `${w}px`;
|
|
362
|
+
s.height = `${h}px`;
|
|
363
|
+
if (resized)
|
|
364
|
+
this.#notifyResize(view, w, h);
|
|
365
|
+
}
|
|
366
|
+
if (this.#pages.size > this.#peakMounted)
|
|
367
|
+
this.#peakMounted = this.#pages.size;
|
|
368
|
+
}
|
|
369
|
+
#notifyResize(view, width, height) {
|
|
370
|
+
for (const layer of this.#layers)
|
|
371
|
+
layer.onZoom?.(view.index, this.#slot(view, layer.name), { width, height });
|
|
372
|
+
}
|
|
373
|
+
/** Starts the passes: visible pages first, then the margin. */
|
|
374
|
+
#paintRange(frame, visible, first, last) {
|
|
375
|
+
for (let i = visible.first; i <= visible.last; i++)
|
|
376
|
+
this.#paint(i, "visible", frame);
|
|
377
|
+
for (let i = first; i < visible.first; i++)
|
|
378
|
+
this.#paint(i, "prefetch", frame);
|
|
379
|
+
for (let i = visible.last + 1; i <= last; i++)
|
|
380
|
+
this.#paint(i, "prefetch", frame);
|
|
381
|
+
}
|
|
382
|
+
#mount(index) {
|
|
383
|
+
const shell = document.createElement("div");
|
|
384
|
+
shell.className = "lamina-page";
|
|
385
|
+
shell.dataset.pageIndex = String(index);
|
|
386
|
+
Object.assign(shell.style, { position: "absolute", contain: "strict", background: "#fff" });
|
|
387
|
+
this.#spacer.append(shell);
|
|
388
|
+
const view = {
|
|
389
|
+
index,
|
|
390
|
+
shell,
|
|
391
|
+
slots: new Map(),
|
|
392
|
+
canvas: undefined,
|
|
393
|
+
controller: new AbortController(),
|
|
394
|
+
state: "placeholder",
|
|
395
|
+
painted: "",
|
|
396
|
+
tiles: new Set(),
|
|
397
|
+
busy: false,
|
|
398
|
+
waiting: false,
|
|
399
|
+
pass: 0,
|
|
400
|
+
};
|
|
401
|
+
for (const name of SLOTS)
|
|
402
|
+
this.#slot(view, name);
|
|
403
|
+
this.#pages.set(index, view);
|
|
404
|
+
for (const layer of this.#layers)
|
|
405
|
+
layer.onMount?.(index, this.#slot(view, layer.name));
|
|
406
|
+
return view;
|
|
407
|
+
}
|
|
408
|
+
/** The page's slot `name`, made (above the others) the first time it is asked for. */
|
|
409
|
+
#slot(view, name) {
|
|
410
|
+
let slot = view.slots.get(name);
|
|
411
|
+
if (slot === undefined) {
|
|
412
|
+
slot = document.createElement("div");
|
|
413
|
+
slot.className = `lamina-layer-${name}`;
|
|
414
|
+
Object.assign(slot.style, { position: "absolute", inset: "0" });
|
|
415
|
+
view.slots.set(name, slot);
|
|
416
|
+
view.shell.append(slot);
|
|
417
|
+
}
|
|
418
|
+
return slot;
|
|
419
|
+
}
|
|
420
|
+
/** Stops the page's renders: their results will be dropped. */
|
|
421
|
+
#abort(view) {
|
|
422
|
+
view.controller.abort();
|
|
423
|
+
view.controller = new AbortController();
|
|
424
|
+
view.pass++;
|
|
425
|
+
view.busy = false;
|
|
426
|
+
}
|
|
427
|
+
#unmount(view) {
|
|
428
|
+
this.#abort(view);
|
|
429
|
+
for (const layer of this.#layers)
|
|
430
|
+
layer.onUnmount?.(view.index, this.#slot(view, layer.name));
|
|
431
|
+
this.#releaseCanvas(view);
|
|
432
|
+
view.shell.remove();
|
|
433
|
+
this.#pages.delete(view.index);
|
|
434
|
+
}
|
|
435
|
+
#unmountAll() {
|
|
436
|
+
for (const view of [...this.#pages.values()])
|
|
437
|
+
this.#unmount(view);
|
|
438
|
+
}
|
|
439
|
+
#releaseCanvas(view) {
|
|
440
|
+
if (view.canvas !== undefined)
|
|
441
|
+
this.#pool.release(view.canvas);
|
|
442
|
+
view.canvas = undefined;
|
|
443
|
+
view.painted = "";
|
|
444
|
+
view.tiles.clear();
|
|
445
|
+
if (view.state !== "error")
|
|
446
|
+
view.state = "placeholder";
|
|
447
|
+
// A canvas is free again: pages that waited for one try now.
|
|
448
|
+
for (const other of this.#pages.values())
|
|
449
|
+
other.waiting = false;
|
|
450
|
+
this.#schedule();
|
|
451
|
+
}
|
|
452
|
+
/** The sharp tiles of page `index` in view in `frame`, with their render requests and keys. */
|
|
453
|
+
#tilesInView(index, frame, ratio) {
|
|
454
|
+
const { layout, top, height } = frame;
|
|
455
|
+
const documentId = this.#document?.id ?? 0;
|
|
456
|
+
const cssW = layout.widths[index];
|
|
457
|
+
const cssH = layout.heights[index];
|
|
458
|
+
const pageTop = layout.offsetOf(index);
|
|
459
|
+
const y0 = Math.max(0, top - pageTop);
|
|
460
|
+
const y1 = Math.min(cssH, top + height - pageTop);
|
|
461
|
+
const inView = { x: 0, y: y0, width: cssW, height: Math.max(0, y1 - y0) };
|
|
462
|
+
return tilesFor(inView, { width: cssW, height: cssH }, ratio).map((clip) => {
|
|
463
|
+
const request = { page: index, scale: layout.scale * ratio, rotation: layout.rotation, clip };
|
|
464
|
+
return { clip, request, key: renderKey(request, documentId) };
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
/** Starts page `index`'s pass if it isn't busy and its canvas isn't done for `frame` yet. */
|
|
468
|
+
#paint(index, priority, frame) {
|
|
469
|
+
const view = this.#pages.get(index);
|
|
470
|
+
if (view === undefined || this.#document === undefined || view.busy || view.waiting || view.state === "error") {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const { layout } = frame;
|
|
474
|
+
if (view.painted === geometryOf(layout)) {
|
|
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
|
+
}
|
|
491
|
+
view.busy = true;
|
|
492
|
+
const pass = view.pass;
|
|
493
|
+
void this.#pass(view, pass, priority, frame)
|
|
494
|
+
.catch((e) => {
|
|
495
|
+
if (isAbort(e)) {
|
|
496
|
+
this.#aborted++;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (pass !== view.pass)
|
|
500
|
+
return;
|
|
501
|
+
view.state = "error";
|
|
502
|
+
this.#emit("renderstats", this.stats());
|
|
503
|
+
})
|
|
504
|
+
.finally(() => {
|
|
505
|
+
if (pass !== view.pass)
|
|
506
|
+
return;
|
|
507
|
+
view.busy = false;
|
|
508
|
+
// The view may have moved while the pass ran: check again on the next frame.
|
|
509
|
+
this.#schedule();
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
/** One pass: the blurry whole page when the canvas shows another geometry, then the sharp tiles in view. */
|
|
513
|
+
async #pass(view, pass, priority, frame) {
|
|
514
|
+
const { index } = view;
|
|
515
|
+
const { layout } = frame;
|
|
516
|
+
const pass_ = {
|
|
517
|
+
view,
|
|
518
|
+
layout,
|
|
519
|
+
priority,
|
|
520
|
+
signal: view.controller.signal,
|
|
521
|
+
live: () => pass === view.pass && this.#pages.get(index) === view && this.#layout === layout,
|
|
522
|
+
};
|
|
523
|
+
if (view.painted !== geometryOf(layout) && !(await this.#paintLow(pass_)))
|
|
524
|
+
return;
|
|
525
|
+
if (priority === "prefetch")
|
|
526
|
+
return;
|
|
527
|
+
await this.#paintTiles(pass_, frame);
|
|
528
|
+
}
|
|
529
|
+
/** The blurry pass: resizes the canvas and stretches a low-resolution page over it. */
|
|
530
|
+
async #paintLow(pass) {
|
|
531
|
+
const { view, layout, priority, signal, live } = pass;
|
|
532
|
+
const { index } = view;
|
|
533
|
+
const dpr = this.#dpr();
|
|
534
|
+
const cssW = layout.widths[index];
|
|
535
|
+
const cssH = layout.heights[index];
|
|
536
|
+
const shown = rotated(this.#sizes[index], layout.rotation);
|
|
537
|
+
const request = { page: index, scale: lowScale(layout.scale * dpr, shown.width), rotation: layout.rotation };
|
|
538
|
+
const low = await this.#bitmap(request, renderKey(request, this.#document?.id ?? 0), priority, signal);
|
|
539
|
+
if (!live()) {
|
|
540
|
+
this.#drop(low);
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
const canvas = view.canvas ?? this.#attachCanvas(view, priority);
|
|
544
|
+
if (canvas === undefined) {
|
|
545
|
+
view.waiting = true;
|
|
546
|
+
this.#drop(low, false);
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
const { width, height } = this.#pool.fit(canvas, cssW * dpr, cssH * dpr);
|
|
550
|
+
// Resizing clears the canvas: it happens only now, with the new pixels ready (no white flash).
|
|
551
|
+
canvas.width = width;
|
|
552
|
+
canvas.height = height;
|
|
553
|
+
const drawn = this.#draw(view, low.bitmap, 0, 0, width, height);
|
|
554
|
+
this.#release(low);
|
|
555
|
+
// Not drawn (evicted meanwhile): the canvas stays unpainted, and the next frame tries again.
|
|
556
|
+
if (!drawn)
|
|
557
|
+
return false;
|
|
558
|
+
view.painted = geometryOf(layout);
|
|
559
|
+
view.tiles.clear();
|
|
560
|
+
view.state = "low";
|
|
561
|
+
this.#pruneOtherGeometries(index, layout, new Set([request.scale, layout.scale * (width / cssW)]));
|
|
562
|
+
this.#emit("renderstats", this.stats());
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
/** Frees page `index`'s bitmaps of other scales or rotations at once, rather than at the budget. */
|
|
566
|
+
#pruneOtherGeometries(index, layout, scales) {
|
|
567
|
+
this.#cache.prune(index, (key) => {
|
|
568
|
+
const { scale, rotation } = renderKeyFields(key);
|
|
569
|
+
return scales.has(scale) && rotation === layout.rotation;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/** Sharp tiles over the part in view, at the canvas's own pixel ratio (the pool may cap it). */
|
|
573
|
+
async #paintTiles(pass, frame) {
|
|
574
|
+
const { view, layout, priority, signal, live } = pass;
|
|
575
|
+
const canvas = view.canvas;
|
|
576
|
+
if (canvas === undefined)
|
|
577
|
+
return;
|
|
578
|
+
for (const tile of this.#tilesInView(view.index, frame, canvas.width / layout.widths[view.index])) {
|
|
579
|
+
if (view.tiles.has(tile.key))
|
|
580
|
+
continue;
|
|
581
|
+
const got = await this.#bitmap(tile.request, tile.key, priority, signal);
|
|
582
|
+
if (!live()) {
|
|
583
|
+
this.#drop(got);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const drawn = this.#draw(view, got.bitmap, tile.clip.x, tile.clip.y);
|
|
587
|
+
this.#release(got);
|
|
588
|
+
if (drawn)
|
|
589
|
+
view.tiles.add(tile.key);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* A bitmap for `request`, from the cache or the worker. `owned` is true when the cache didn't
|
|
594
|
+
* keep it, so the caller closes it after drawing. A cached bitmap another page's pass evicted
|
|
595
|
+
* (closed) meanwhile is rendered again.
|
|
596
|
+
*/
|
|
597
|
+
async #bitmap(request, key, priority, signal) {
|
|
598
|
+
const cached = this.#cache.get(key);
|
|
599
|
+
if (cached !== undefined && !isClosed(cached))
|
|
600
|
+
return { bitmap: cached, owned: false };
|
|
601
|
+
const { bitmap } = await this.#client.render(request, { priority, signal });
|
|
602
|
+
const kept = this.#cache.set(key, request.page, bitmap, this.#state.currentPage);
|
|
603
|
+
return { bitmap, owned: !kept };
|
|
604
|
+
}
|
|
605
|
+
/** A result that arrived too late: counted, and closed if nobody else holds it. */
|
|
606
|
+
#drop(got, late = true) {
|
|
607
|
+
if (late)
|
|
608
|
+
this.#dropped++;
|
|
609
|
+
this.#release(got);
|
|
610
|
+
}
|
|
611
|
+
#release(got) {
|
|
612
|
+
if (got.owned)
|
|
613
|
+
got.bitmap.close();
|
|
614
|
+
}
|
|
615
|
+
/** The one place pixels reach a canvas: it counts a draw onto a shell out of the DOM. */
|
|
616
|
+
#draw(view, bitmap, x, y, width, height) {
|
|
617
|
+
const ctx = view.canvas?.getContext("2d");
|
|
618
|
+
// A bitmap another pass's cache write evicted (closed) after it was fetched: not drawn.
|
|
619
|
+
if (ctx === undefined || ctx === null || isClosed(bitmap))
|
|
620
|
+
return false;
|
|
621
|
+
if (!view.shell.isConnected)
|
|
622
|
+
this.#drawsOnUnmounted++;
|
|
623
|
+
if (width === undefined || height === undefined)
|
|
624
|
+
ctx.drawImage(bitmap, x, y);
|
|
625
|
+
else
|
|
626
|
+
ctx.drawImage(bitmap, x, y, width, height);
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* A canvas for `view` from the pool. When the pool is empty, a visible page takes the canvas of
|
|
631
|
+
* the margin page farthest from the current one, so visible pages never wait on prefetched ones.
|
|
632
|
+
*/
|
|
633
|
+
/** The margin page with a canvas farthest from the current page, if any. */
|
|
634
|
+
#farthestMarginCanvas() {
|
|
635
|
+
const { first, last } = this.#state.visibleRange;
|
|
636
|
+
const current = this.#state.currentPage;
|
|
637
|
+
let victim;
|
|
638
|
+
for (const other of this.#pages.values()) {
|
|
639
|
+
const inView = other.index >= first && other.index <= last;
|
|
640
|
+
if (other.canvas === undefined || inView)
|
|
641
|
+
continue;
|
|
642
|
+
if (victim === undefined || Math.abs(other.index - current) > Math.abs(victim.index - current))
|
|
643
|
+
victim = other;
|
|
644
|
+
}
|
|
645
|
+
return victim;
|
|
646
|
+
}
|
|
647
|
+
#attachCanvas(view, priority) {
|
|
648
|
+
let canvas = this.#pool.acquire();
|
|
649
|
+
const victim = canvas === undefined && priority === "visible" ? this.#farthestMarginCanvas() : undefined;
|
|
650
|
+
if (victim !== undefined) {
|
|
651
|
+
this.#abort(victim);
|
|
652
|
+
this.#releaseCanvas(victim);
|
|
653
|
+
canvas = this.#pool.acquire();
|
|
654
|
+
}
|
|
655
|
+
if (canvas === undefined)
|
|
656
|
+
return undefined;
|
|
657
|
+
canvas.className = "lamina-page-canvas";
|
|
658
|
+
Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
|
|
659
|
+
view.shell.prepend(canvas);
|
|
660
|
+
view.canvas = canvas;
|
|
661
|
+
return canvas;
|
|
662
|
+
}
|
|
663
|
+
/** Adds `listener` to the root for `type`, removed by destroy(). Safari's gesture events take any name. */
|
|
664
|
+
#on(type, f, options) {
|
|
665
|
+
const root = this.#root;
|
|
666
|
+
root.addEventListener(type, f, options);
|
|
667
|
+
this.#cleanup.push(() => root.removeEventListener(type, f, options));
|
|
668
|
+
}
|
|
669
|
+
/** `clientX`/`clientY` as a viewport position. */
|
|
670
|
+
#anchorAt(clientX, clientY) {
|
|
671
|
+
const rect = this.#root.getBoundingClientRect();
|
|
672
|
+
return { x: clientX - rect.left, y: clientY - rect.top };
|
|
673
|
+
}
|
|
674
|
+
#listen() {
|
|
675
|
+
this.#on("scroll", () => this.#schedule(), { passive: true });
|
|
676
|
+
this.#listenWheel();
|
|
677
|
+
this.#listenKeys();
|
|
678
|
+
this.#listenTouchPinch();
|
|
679
|
+
this.#listenGestures();
|
|
680
|
+
this.#listenResize();
|
|
681
|
+
}
|
|
682
|
+
/** Ctrl/cmd+wheel and trackpad pinch (which browsers send as ctrl+wheel): zoom at the cursor. */
|
|
683
|
+
#listenWheel() {
|
|
684
|
+
this.#on("wheel", (e) => {
|
|
685
|
+
if (!e.ctrlKey && !e.metaKey)
|
|
686
|
+
return;
|
|
687
|
+
e.preventDefault();
|
|
688
|
+
this.setZoom(this.#state.percent * Math.exp(-e.deltaY * 0.01), {
|
|
689
|
+
anchor: this.#anchorAt(e.clientX, e.clientY),
|
|
690
|
+
});
|
|
691
|
+
}, { passive: false });
|
|
692
|
+
}
|
|
693
|
+
/** `+` and `-` zoom by a quarter, `0` goes to actual size. */
|
|
694
|
+
#listenKeys() {
|
|
695
|
+
this.#on("keydown", (e) => {
|
|
696
|
+
if (e.altKey)
|
|
697
|
+
return;
|
|
698
|
+
const p = this.#state.percent;
|
|
699
|
+
if (e.key === "+" || e.key === "=")
|
|
700
|
+
this.setZoom(p * 1.25);
|
|
701
|
+
else if (e.key === "-")
|
|
702
|
+
this.setZoom(p / 1.25);
|
|
703
|
+
else if (e.key === "0")
|
|
704
|
+
this.setZoom("actual-size");
|
|
705
|
+
else
|
|
706
|
+
return;
|
|
707
|
+
e.preventDefault();
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
/** Touch pinch: two pointers; the zoom follows their distance, anchored at their midpoint. */
|
|
711
|
+
#listenTouchPinch() {
|
|
712
|
+
const pointers = new Map();
|
|
713
|
+
let pinch;
|
|
714
|
+
const spread = () => {
|
|
715
|
+
const [a, b] = [...pointers.values()];
|
|
716
|
+
return a === undefined || b === undefined
|
|
717
|
+
? undefined
|
|
718
|
+
: { d: Math.hypot(a.x - b.x, a.y - b.y), x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
719
|
+
};
|
|
720
|
+
this.#on("pointerdown", (e) => {
|
|
721
|
+
if (e.pointerType !== "touch")
|
|
722
|
+
return;
|
|
723
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
724
|
+
const s = spread();
|
|
725
|
+
if (s !== undefined)
|
|
726
|
+
pinch = { distance: s.d, percent: this.#state.percent };
|
|
727
|
+
});
|
|
728
|
+
this.#on("pointermove", (e) => {
|
|
729
|
+
if (!pointers.has(e.pointerId))
|
|
730
|
+
return;
|
|
731
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
732
|
+
const s = spread();
|
|
733
|
+
if (s === undefined || pinch === undefined || pinch.distance === 0)
|
|
734
|
+
return;
|
|
735
|
+
this.setZoom(pinch.percent * (s.d / pinch.distance), { anchor: this.#anchorAt(s.x, s.y) });
|
|
736
|
+
});
|
|
737
|
+
const up = (e) => {
|
|
738
|
+
pointers.delete(e.pointerId);
|
|
739
|
+
if (pointers.size < 2)
|
|
740
|
+
pinch = undefined;
|
|
741
|
+
};
|
|
742
|
+
this.#on("pointerup", up);
|
|
743
|
+
this.#on("pointercancel", up);
|
|
744
|
+
}
|
|
745
|
+
/** Safari's trackpad pinch: gesture events with a scale. */
|
|
746
|
+
#listenGestures() {
|
|
747
|
+
let gesture;
|
|
748
|
+
this.#on("gesturestart", (e) => {
|
|
749
|
+
e.preventDefault();
|
|
750
|
+
gesture = this.#state.percent;
|
|
751
|
+
});
|
|
752
|
+
this.#on("gesturechange", (e) => {
|
|
753
|
+
if (gesture === undefined)
|
|
754
|
+
return;
|
|
755
|
+
e.preventDefault();
|
|
756
|
+
const ge = e;
|
|
757
|
+
this.setZoom(gesture * ge.scale, { anchor: this.#anchorAt(ge.clientX, ge.clientY) });
|
|
758
|
+
});
|
|
759
|
+
this.#on("gestureend", () => {
|
|
760
|
+
gesture = undefined;
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
/** A resized viewport re-fits a fit mode (keeping the centre) and re-lays out. */
|
|
764
|
+
#listenResize() {
|
|
765
|
+
const resize = new ResizeObserver(() => {
|
|
766
|
+
if (this.#layout === undefined)
|
|
767
|
+
return;
|
|
768
|
+
if (typeof this.#zoom === "string")
|
|
769
|
+
this.#rezoom(undefined);
|
|
770
|
+
else
|
|
771
|
+
this.#schedule();
|
|
772
|
+
});
|
|
773
|
+
resize.observe(this.#root);
|
|
774
|
+
this.#cleanup.push(() => resize.disconnect());
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/** Creates a viewer over `options.client`'s document worker. */
|
|
778
|
+
export function createViewer(options) {
|
|
779
|
+
return new Viewer(options);
|
|
780
|
+
}
|
|
781
|
+
//# sourceMappingURL=viewer.js.map
|