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

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