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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +71 -5
  2. package/dist/client.d.ts +48 -2
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +89 -29
  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 +115 -2
  51. package/dist/protocol.d.ts.map +1 -1
  52. package/dist/protocol.js.map +1 -1
  53. package/dist/render/tiles.d.ts +43 -3
  54. package/dist/render/tiles.d.ts.map +1 -1
  55. package/dist/render/tiles.js +62 -10
  56. package/dist/render/tiles.js.map +1 -1
  57. package/dist/source/http-range.d.ts +59 -0
  58. package/dist/source/http-range.d.ts.map +1 -0
  59. package/dist/source/http-range.js +284 -0
  60. package/dist/source/http-range.js.map +1 -0
  61. package/dist/thumbnails/strip.d.ts +37 -0
  62. package/dist/thumbnails/strip.d.ts.map +1 -0
  63. package/dist/thumbnails/strip.js +313 -0
  64. package/dist/thumbnails/strip.js.map +1 -0
  65. package/dist/view/budgets.d.ts +29 -0
  66. package/dist/view/budgets.d.ts.map +1 -0
  67. package/dist/view/budgets.js +63 -0
  68. package/dist/view/budgets.js.map +1 -0
  69. package/dist/view/touch.d.ts +71 -0
  70. package/dist/view/touch.d.ts.map +1 -0
  71. package/dist/view/touch.js +220 -0
  72. package/dist/view/touch.js.map +1 -0
  73. package/dist/view/viewer.d.ts +42 -1
  74. package/dist/view/viewer.d.ts.map +1 -1
  75. package/dist/view/viewer.js +425 -68
  76. package/dist/view/viewer.js.map +1 -1
  77. package/dist/worker/document.d.ts +30 -0
  78. package/dist/worker/document.d.ts.map +1 -1
  79. package/dist/worker/document.js +176 -24
  80. package/dist/worker/document.js.map +1 -1
  81. package/dist/worker/entry.d.ts +1 -1
  82. package/dist/worker/entry.d.ts.map +1 -1
  83. package/dist/worker/entry.js +2 -0
  84. package/dist/worker/entry.js.map +1 -1
  85. package/dist/worker/find.d.ts +7 -0
  86. package/dist/worker/find.d.ts.map +1 -0
  87. package/dist/worker/find.js +59 -0
  88. package/dist/worker/find.js.map +1 -0
  89. package/dist/worker/methods.d.ts.map +1 -1
  90. package/dist/worker/methods.js +28 -2
  91. package/dist/worker/methods.js.map +1 -1
  92. package/dist/worker/navigation.d.ts +6 -0
  93. package/dist/worker/navigation.d.ts.map +1 -0
  94. package/dist/worker/navigation.js +16 -0
  95. package/dist/worker/navigation.js.map +1 -0
  96. package/dist/worker/network.d.ts +4 -0
  97. package/dist/worker/network.d.ts.map +1 -0
  98. package/dist/worker/network.js +24 -0
  99. package/dist/worker/network.js.map +1 -0
  100. package/dist/worker/render.d.ts +5 -2
  101. package/dist/worker/render.d.ts.map +1 -1
  102. package/dist/worker/render.js +13 -6
  103. package/dist/worker/render.js.map +1 -1
  104. package/dist/worker/text.d.ts +17 -0
  105. package/dist/worker/text.d.ts.map +1 -0
  106. package/dist/worker/text.js +75 -0
  107. package/dist/worker/text.js.map +1 -0
  108. package/dist/zoom/destination.d.ts +18 -0
  109. package/dist/zoom/destination.d.ts.map +1 -0
  110. package/dist/zoom/destination.js +56 -0
  111. package/dist/zoom/destination.js.map +1 -0
  112. package/package.json +5 -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
  }
@@ -286,6 +429,8 @@ export class Viewer {
286
429
  let unsharpened = false;
287
430
  for (const view of this.#pages.values()) {
288
431
  this.#abort(view);
432
+ // The new geometry needs other canvases: a page that waited for one tries again.
433
+ view.waiting = false;
289
434
  if (view.state === "error")
290
435
  view.state = "placeholder";
291
436
  if (view.state === "sharp") {
@@ -327,6 +472,7 @@ export class Viewer {
327
472
  const frame = {
328
473
  layout,
329
474
  top: this.#root.scrollTop,
475
+ left: this.#root.scrollLeft,
330
476
  width: this.#root.clientWidth,
331
477
  height: this.#root.clientHeight,
332
478
  };
@@ -344,13 +490,17 @@ export class Viewer {
344
490
  this.#paintRange(frame, visible, first, last);
345
491
  this.#emit("renderstats", this.stats());
346
492
  }
347
- /** 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
+ */
348
497
  #placeShells(frame, first, last) {
349
498
  const { layout, width } = frame;
499
+ const pinned = [...this.#pinned].filter((i) => i >= 0 && i < layout.pageCount && (i < first || i > last));
350
500
  for (const [i, view] of this.#pages)
351
- if (i < first || i > last)
501
+ if ((i < first || i > last) && !this.#pinned.has(i))
352
502
  this.#unmount(view);
353
- for (let i = first; i <= last; i++) {
503
+ for (const i of [...pinned, ...range(first, last)]) {
354
504
  const view = this.#pages.get(i) ?? this.#mount(i);
355
505
  const w = layout.widths[i];
356
506
  const h = layout.heights[i];
@@ -393,7 +543,9 @@ export class Viewer {
393
543
  controller: new AbortController(),
394
544
  state: "placeholder",
395
545
  painted: "",
546
+ scale: 0,
396
547
  tiles: new Set(),
548
+ detail: undefined,
397
549
  busy: false,
398
550
  waiting: false,
399
551
  pass: 0,
@@ -412,6 +564,10 @@ export class Viewer {
412
564
  slot = document.createElement("div");
413
565
  slot.className = `lamina-layer-${name}`;
414
566
  Object.assign(slot.style, { position: "absolute", inset: "0" });
567
+ // The slots above the text cover the whole page: taking the pointer, they would sit between
568
+ // every press and the spans, and no drag could select text.
569
+ if (name !== "text")
570
+ slot.style.pointerEvents = "none";
415
571
  view.slots.set(name, slot);
416
572
  view.shell.append(slot);
417
573
  }
@@ -437,6 +593,7 @@ export class Viewer {
437
593
  this.#unmount(view);
438
594
  }
439
595
  #releaseCanvas(view) {
596
+ this.#releaseDetail(view);
440
597
  if (view.canvas !== undefined)
441
598
  this.#pool.release(view.canvas);
442
599
  view.canvas = undefined;
@@ -449,24 +606,71 @@ export class Viewer {
449
606
  other.waiting = false;
450
607
  this.#schedule();
451
608
  }
452
- /** The 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;
609
+ /** The part of page `index` in view in `frame`, in CSS px from the page's top-left. */
610
+ #inView(index, frame) {
611
+ const { layout, top, left, width, height } = frame;
456
612
  const cssW = layout.widths[index];
457
613
  const cssH = layout.heights[index];
458
614
  const pageTop = layout.offsetOf(index);
615
+ const pageLeft = layout.leftOf(index, width);
616
+ const x0 = Math.max(0, left - pageLeft);
617
+ const x1 = Math.min(cssW, left + width - pageLeft);
459
618
  const y0 = Math.max(0, top - pageTop);
460
619
  const y1 = Math.min(cssH, top + height - pageTop);
461
- 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
- });
620
+ return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
621
+ }
622
+ /**
623
+ * The sharp tiles of page `index` over `area` (CSS px from the page's top-left) at `scale` (device
624
+ * px per pt), with their render requests and keys. They lie inside the page as the renderer sizes
625
+ * it at that scale.
626
+ */
627
+ #tiles(index, layout, scale, area) {
628
+ const page = pagePixels(this.#sizes[index], scale, layout.rotation);
629
+ return tilesFor(area, page, scale / layout.scale).map((clip) => this.#tile(index, layout, scale, clip));
630
+ }
631
+ /** Page `index` whole at `scale`, as one tile. */
632
+ #wholePage(index, layout, scale) {
633
+ const page = pagePixels(this.#sizes[index], scale, layout.rotation);
634
+ return this.#tile(index, layout, scale, { x: 0, y: 0, ...page });
635
+ }
636
+ /** The tile `clip` of page `index` at `scale`: its render request and cache key. */
637
+ #tile(index, layout, scale, clip) {
638
+ const request = { page: index, scale, rotation: layout.rotation, clip };
639
+ return { clip, request, key: renderKey(request, this.#document?.id ?? 0) };
640
+ }
641
+ /** The device px per pt of a sharp tile: the layout's scale × the device pixel ratio. */
642
+ #fullScale(layout) {
643
+ return layout.scale * this.#dpr();
644
+ }
645
+ /** Whether `view`'s canvas is below the full pixel ratio, so the part in view needs a detail canvas. */
646
+ #needsDetail(view, layout) {
647
+ return view.scale < this.#fullScale(layout) * (1 - 1e-9);
648
+ }
649
+ /** Every sharp tile now in view is drawn: into the detail canvas, or else into the page canvas. */
650
+ #sharpInView(view, frame) {
651
+ const { layout } = frame;
652
+ const area = this.#inView(view.index, frame);
653
+ if (!this.#needsDetail(view, layout)) {
654
+ return this.#tiles(view.index, layout, view.scale, area).every((t) => view.tiles.has(t.key));
655
+ }
656
+ const tiles = this.#tiles(view.index, layout, this.#fullScale(layout), area);
657
+ // Nothing of the page in view (off to one side, or only the gap below it): nothing to
658
+ // sharpen, and its detail canvas goes back to the pool for a page that has.
659
+ if (tiles.length === 0) {
660
+ this.#releaseDetail(view);
661
+ return true;
662
+ }
663
+ const detail = view.detail;
664
+ if (detail === undefined || detail.geometry !== geometryOf(layout))
665
+ return false;
666
+ return tiles.every((t) => detail.tiles.has(t.key));
466
667
  }
467
668
  /** Starts page `index`'s pass if it isn't busy and its canvas isn't done for `frame` yet. */
468
669
  #paint(index, priority, frame) {
469
670
  const view = this.#pages.get(index);
671
+ // A page in the margin gives its detail canvas back for the pages in view.
672
+ if (view !== undefined && priority === "prefetch")
673
+ this.#releaseDetail(view);
470
674
  if (view === undefined || this.#document === undefined || view.busy || view.waiting || view.state === "error") {
471
675
  return;
472
676
  }
@@ -474,10 +678,8 @@ export class Viewer {
474
678
  if (view.painted === geometryOf(layout)) {
475
679
  if (priority === "prefetch")
476
680
  return;
477
- const canvas = view.canvas;
478
- const ratio = canvas === undefined ? 0 : canvas.width / layout.widths[index];
479
681
  // Sharp means every tile now in view is drawn: scrolling down a tall page sharpens the rest.
480
- if (ratio > 0 && this.#tilesInView(index, frame, ratio).every((t) => view.tiles.has(t.key))) {
682
+ if (view.scale > 0 && this.#sharpInView(view, frame)) {
481
683
  if (view.state !== "sharp") {
482
684
  view.state = "sharp";
483
685
  this.#emit("renderstats", this.stats());
@@ -524,7 +726,15 @@ export class Viewer {
524
726
  return;
525
727
  if (priority === "prefetch")
526
728
  return;
527
- 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)]);
528
738
  }
529
739
  /** The blurry pass: resizes the canvas and stretches a low-resolution page over it. */
530
740
  async #paintLow(pass) {
@@ -546,22 +756,65 @@ export class Viewer {
546
756
  this.#drop(low, false);
547
757
  return false;
548
758
  }
549
- 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
+ }
550
777
  // Resizing clears the canvas: it happens only now, with the new pixels ready (no white flash).
551
778
  canvas.width = width;
552
779
  canvas.height = height;
553
- 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();
554
783
  this.#release(low);
555
784
  // Not drawn (evicted meanwhile): the canvas stays unpainted, and the next frame tries again.
556
785
  if (!drawn)
557
786
  return false;
558
787
  view.painted = geometryOf(layout);
788
+ view.scale = scale;
559
789
  view.tiles.clear();
560
790
  view.state = "low";
561
- this.#pruneOtherGeometries(index, layout, new Set([request.scale, layout.scale * (width / cssW)]));
791
+ this.#pruneOtherGeometries(index, layout, new Set([request.scale, scale, layout.scale * dpr]));
562
792
  this.#emit("renderstats", this.stats());
563
793
  return true;
564
794
  }
795
+ /**
796
+ * A copy of `view`'s page canvas when it holds the page in `layout`'s rotation at another zoom (the
797
+ * pixels the user sees stretched), no larger than the new `width` × `height`, for #paintLow to
798
+ * draw back at the new size; else undefined.
799
+ */
800
+ async #snapshot(view, layout, width, height) {
801
+ const canvas = view.canvas;
802
+ if (canvas === undefined || view.painted === "" || rotationOf(view.painted) !== layout.rotation)
803
+ return undefined;
804
+ try {
805
+ // No larger than the new canvas: zooming out shrinks the copy rather than doubling the memory.
806
+ if (width >= canvas.width && height >= canvas.height)
807
+ return await createImageBitmap(canvas);
808
+ return await createImageBitmap(canvas, {
809
+ resizeWidth: Math.min(width, canvas.width),
810
+ resizeHeight: Math.min(height, canvas.height),
811
+ });
812
+ }
813
+ catch {
814
+ // No copy (out of memory, or a browser without resize options): the low pass stands in.
815
+ return undefined;
816
+ }
817
+ }
565
818
  /** Frees page `index`'s bitmaps of other scales or rotations at once, rather than at the budget. */
566
819
  #pruneOtherGeometries(index, layout, scales) {
567
820
  this.#cache.prune(index, (key) => {
@@ -569,13 +822,13 @@ export class Viewer {
569
822
  return scales.has(scale) && rotation === layout.rotation;
570
823
  });
571
824
  }
572
- /** 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;
825
+ /** `tiles` (at the canvas's scale) into the page's canvas. */
826
+ async #paintTiles(pass, tiles) {
827
+ const { view, priority, signal, live } = pass;
575
828
  const canvas = view.canvas;
576
829
  if (canvas === undefined)
577
830
  return;
578
- for (const tile of this.#tilesInView(view.index, frame, canvas.width / layout.widths[view.index])) {
831
+ for (const tile of tiles) {
579
832
  if (view.tiles.has(tile.key))
580
833
  continue;
581
834
  const got = await this.#bitmap(tile.request, tile.key, priority, signal);
@@ -583,12 +836,114 @@ export class Viewer {
583
836
  this.#drop(got);
584
837
  return;
585
838
  }
586
- const drawn = this.#draw(view, got.bitmap, tile.clip.x, tile.clip.y);
839
+ const drawn = this.#draw(view, canvas, got.bitmap, tile.clip.x, tile.clip.y);
587
840
  this.#release(got);
588
841
  if (drawn)
589
842
  view.tiles.add(tile.key);
590
843
  }
591
844
  }
845
+ /**
846
+ * Sharp tiles over the part in view at the full pixel ratio, into the detail canvas: placed (or
847
+ * moved) to cover them, its cached tiles drawn at once so it never shows empty, then the rest.
848
+ */
849
+ async #paintDetail(pass, frame) {
850
+ const { view, layout, priority, signal, live } = pass;
851
+ const tiles = this.#tiles(view.index, layout, this.#fullScale(layout), this.#inView(view.index, frame));
852
+ const rect = bounds(tiles.map((t) => t.clip));
853
+ if (rect === undefined) {
854
+ this.#releaseDetail(view);
855
+ return;
856
+ }
857
+ const detail = this.#placeDetail(view, layout, rect);
858
+ if (detail === undefined) {
859
+ // No detail canvas to be had: the page shows its backdrop and waits until one is released,
860
+ // rather than try again every frame.
861
+ this.#releaseDetail(view);
862
+ view.waiting = true;
863
+ return;
864
+ }
865
+ const missing = tiles.filter((tile) => {
866
+ if (detail.tiles.has(tile.key))
867
+ return false;
868
+ const cached = this.#cache.get(tile.key);
869
+ if (cached === undefined || !this.#drawDetail(view, detail, cached, tile.clip))
870
+ return true;
871
+ detail.tiles.add(tile.key);
872
+ return false;
873
+ });
874
+ for (const tile of missing) {
875
+ const got = await this.#bitmap(tile.request, tile.key, priority, signal);
876
+ if (!live() || view.detail !== detail) {
877
+ this.#drop(got);
878
+ return;
879
+ }
880
+ const drawn = this.#drawDetail(view, detail, got.bitmap, tile.clip);
881
+ this.#release(got);
882
+ if (drawn)
883
+ detail.tiles.add(tile.key);
884
+ }
885
+ }
886
+ /**
887
+ * `view`'s detail canvas covering `rect` (device px at the full scale): the one it has, if it
888
+ * already covers it at this geometry, else one sized and placed for `rect`. Undefined when the
889
+ * detail pool has no canvas or not the pixels (the page then shows its backdrop).
890
+ */
891
+ #placeDetail(view, layout, rect) {
892
+ const scale = this.#fullScale(layout);
893
+ const geometry = geometryOf(layout);
894
+ const old = view.detail;
895
+ if (old !== undefined && old.geometry === geometry && old.scale === scale && contains(old.rect, rect))
896
+ return old;
897
+ // Past the device's cap for one canvas (a large tablet's viewport), the page keeps its backdrop.
898
+ if (rect.width * rect.height > this.#canvasMaxPixels)
899
+ return undefined;
900
+ const canvas = old?.canvas ?? this.#detailPool.acquire();
901
+ if (canvas === undefined)
902
+ return undefined;
903
+ const fitted = this.#detailPool.fit(canvas, rect.width, rect.height);
904
+ if (fitted.width < rect.width || fitted.height < rect.height) {
905
+ if (old === undefined)
906
+ this.#detailPool.release(canvas);
907
+ return undefined;
908
+ }
909
+ // Resizing clears it: #paintDetail draws the cached tiles back before the next frame.
910
+ canvas.width = rect.width;
911
+ canvas.height = rect.height;
912
+ // In percentages of the shell, so it stretches with the page while a zoom gesture runs.
913
+ const page = pagePixels(this.#sizes[view.index], scale, layout.rotation);
914
+ const percent = (n, of) => `${(100 * n) / of}%`;
915
+ Object.assign(canvas.style, {
916
+ position: "absolute",
917
+ left: percent(rect.x, page.width),
918
+ top: percent(rect.y, page.height),
919
+ width: percent(rect.width, page.width),
920
+ height: percent(rect.height, page.height),
921
+ });
922
+ if (old === undefined) {
923
+ canvas.className = "lamina-page-detail";
924
+ if (view.canvas === undefined)
925
+ view.shell.prepend(canvas);
926
+ else
927
+ view.canvas.after(canvas);
928
+ }
929
+ const detail = { canvas, rect, scale, geometry, tiles: new Set() };
930
+ view.detail = detail;
931
+ return detail;
932
+ }
933
+ #releaseDetail(view) {
934
+ if (view.detail === undefined)
935
+ return;
936
+ this.#detailPool.release(view.detail.canvas);
937
+ view.detail = undefined;
938
+ // A detail canvas is free again: pages that waited for one try now.
939
+ for (const other of this.#pages.values())
940
+ other.waiting = false;
941
+ this.#schedule();
942
+ }
943
+ /** Draws a sharp tile at `clip` (device px of the page) into `detail`, offset by its rectangle. */
944
+ #drawDetail(view, detail, bitmap, clip) {
945
+ return this.#draw(view, detail.canvas, bitmap, clip.x - detail.rect.x, clip.y - detail.rect.y);
946
+ }
592
947
  /**
593
948
  * A bitmap for `request`, from the cache or the worker. `owned` is true when the cache didn't
594
949
  * keep it, so the caller closes it after drawing. A cached bitmap another page's pass evicted
@@ -612,9 +967,9 @@ export class Viewer {
612
967
  if (got.owned)
613
968
  got.bitmap.close();
614
969
  }
615
- /** The one place pixels reach a canvas: 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");
970
+ /** The one place pixels reach a canvas of `view`: it counts a draw onto a shell out of the DOM. */
971
+ #draw(view, canvas, bitmap, x, y, width, height) {
972
+ const ctx = canvas.getContext("2d");
618
973
  // A bitmap another pass's cache write evicted (closed) after it was fetched: not drawn.
619
974
  if (ctx === undefined || ctx === null || isClosed(bitmap))
620
975
  return false;
@@ -675,13 +1030,14 @@ export class Viewer {
675
1030
  this.#on("scroll", () => this.#schedule(), { passive: true });
676
1031
  this.#listenWheel();
677
1032
  this.#listenKeys();
678
- this.#listenTouchPinch();
1033
+ this.#listenTouch();
679
1034
  this.#listenGestures();
680
1035
  this.#listenResize();
681
1036
  }
682
1037
  /** Ctrl/cmd+wheel and trackpad pinch (which browsers send as ctrl+wheel): zoom at the cursor. */
683
1038
  #listenWheel() {
684
1039
  this.#on("wheel", (e) => {
1040
+ this.#touch.stopMomentum();
685
1041
  if (!e.ctrlKey && !e.metaKey)
686
1042
  return;
687
1043
  e.preventDefault();
@@ -693,6 +1049,7 @@ export class Viewer {
693
1049
  /** `+` and `-` zoom by a quarter, `0` goes to actual size. */
694
1050
  #listenKeys() {
695
1051
  this.#on("keydown", (e) => {
1052
+ this.#touch.stopMomentum();
696
1053
  if (e.altKey)
697
1054
  return;
698
1055
  const p = this.#state.percent;
@@ -707,42 +1064,39 @@ export class Viewer {
707
1064
  e.preventDefault();
708
1065
  });
709
1066
  }
710
- /** Touch 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;
1067
+ /** Touch and pen: pan with momentum, pinch around the fingers, double-tap zoom (view/touch.ts). */
1068
+ #listenTouch() {
1069
+ const gestures = this.#touch;
1070
+ this.#on("pointerdown", (e) => gestures.down(e));
1071
+ this.#on("pointermove", (e) => gestures.move(e));
1072
+ for (const end of ["pointerup", "pointercancel", "lostpointercapture"]) {
1073
+ this.#on(end, (e) => gestures.up(e));
1074
+ }
1075
+ this.#cleanup.push(() => gestures.stop());
1076
+ }
1077
+ /** The gestures' view of this viewer; it reads the root when called, so it may be made before the root. */
1078
+ #touchHost() {
1079
+ return {
1080
+ percent: () => this.#state.percent,
1081
+ fitsWidth: () => this.#zoom === "fit-width",
1082
+ zoom: (to, anchor) => this.setZoom(to, { anchor }),
1083
+ pointAt: (at) => this.pointAt(at.x, at.y),
1084
+ positionOf: (point) => this.positionOf(point),
1085
+ scrollBy: (dx, dy) => this.#root.scrollBy({ left: dx, top: dy, behavior: "instant" }),
1086
+ local: (e) => this.#anchorAt(e.clientX, e.clientY),
1087
+ frame: (callback) => requestAnimationFrame(callback),
1088
+ cancelFrame: (id) => cancelAnimationFrame(id),
1089
+ capture: (id) => {
1090
+ try {
1091
+ this.#root.setPointerCapture(id);
1092
+ }
1093
+ catch {
1094
+ // The pointer is already up: nothing to keep.
1095
+ }
1096
+ },
741
1097
  };
742
- this.#on("pointerup", up);
743
- this.#on("pointercancel", up);
744
1098
  }
745
- /** Safari's trackpad pinch: gesture events with a scale. */
1099
+ /** Safari's trackpad pinch: gesture events with a scale (and iOS's touch pinch, which the touch gestures take). */
746
1100
  #listenGestures() {
747
1101
  let gesture;
748
1102
  this.#on("gesturestart", (e) => {
@@ -753,6 +1107,9 @@ export class Viewer {
753
1107
  if (gesture === undefined)
754
1108
  return;
755
1109
  e.preventDefault();
1110
+ // iOS sends these for a two-finger touch pinch too: the touch gestures zoom it then.
1111
+ if (this.#touch.pinching())
1112
+ return;
756
1113
  const ge = e;
757
1114
  this.setZoom(gesture * ge.scale, { anchor: this.#anchorAt(ge.clientX, ge.clientY) });
758
1115
  });