@laminapdf/viewer 0.2.0-alpha.2 → 0.2.0-alpha.4

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