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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +107 -7
  2. package/dist/client.d.ts +51 -3
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +94 -30
  5. package/dist/client.js.map +1 -1
  6. package/dist/find/controller.d.ts +73 -0
  7. package/dist/find/controller.d.ts.map +1 -0
  8. package/dist/find/controller.js +227 -0
  9. package/dist/find/controller.js.map +1 -0
  10. package/dist/find/fold.d.ts +23 -0
  11. package/dist/find/fold.d.ts.map +1 -0
  12. package/dist/find/fold.js +99 -0
  13. package/dist/find/fold.js.map +1 -0
  14. package/dist/find/page.d.ts +44 -0
  15. package/dist/find/page.d.ts.map +1 -0
  16. package/dist/find/page.js +173 -0
  17. package/dist/find/page.js.map +1 -0
  18. package/dist/index.d.ts +7 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/layers/box.d.ts +10 -0
  23. package/dist/layers/box.d.ts.map +1 -0
  24. package/dist/layers/box.js +19 -0
  25. package/dist/layers/box.js.map +1 -0
  26. package/dist/layers/highlight-layer.d.ts +31 -0
  27. package/dist/layers/highlight-layer.d.ts.map +1 -0
  28. package/dist/layers/highlight-layer.js +95 -0
  29. package/dist/layers/highlight-layer.js.map +1 -0
  30. package/dist/layers/spans.d.ts +42 -0
  31. package/dist/layers/spans.d.ts.map +1 -0
  32. package/dist/layers/spans.js +121 -0
  33. package/dist/layers/spans.js.map +1 -0
  34. package/dist/layers/structure-layer.d.ts +15 -0
  35. package/dist/layers/structure-layer.d.ts.map +1 -0
  36. package/dist/layers/structure-layer.js +152 -0
  37. package/dist/layers/structure-layer.js.map +1 -0
  38. package/dist/layers/text-layer.d.ts +53 -0
  39. package/dist/layers/text-layer.d.ts.map +1 -0
  40. package/dist/layers/text-layer.js +292 -0
  41. package/dist/layers/text-layer.js.map +1 -0
  42. package/dist/layout/range.d.ts +3 -0
  43. package/dist/layout/range.d.ts.map +1 -0
  44. package/dist/layout/range.js +5 -0
  45. package/dist/layout/range.js.map +1 -0
  46. package/dist/network/watch.d.ts +30 -0
  47. package/dist/network/watch.d.ts.map +1 -0
  48. package/dist/network/watch.js +181 -0
  49. package/dist/network/watch.js.map +1 -0
  50. package/dist/protocol.d.ts +127 -2
  51. package/dist/protocol.d.ts.map +1 -1
  52. package/dist/protocol.js +2 -2
  53. package/dist/protocol.js.map +1 -1
  54. package/dist/render/tiles.d.ts +43 -3
  55. package/dist/render/tiles.d.ts.map +1 -1
  56. package/dist/render/tiles.js +62 -10
  57. package/dist/render/tiles.js.map +1 -1
  58. package/dist/source/http-range.d.ts +59 -0
  59. package/dist/source/http-range.d.ts.map +1 -0
  60. package/dist/source/http-range.js +284 -0
  61. package/dist/source/http-range.js.map +1 -0
  62. package/dist/thumbnails/strip.d.ts +37 -0
  63. package/dist/thumbnails/strip.d.ts.map +1 -0
  64. package/dist/thumbnails/strip.js +313 -0
  65. package/dist/thumbnails/strip.js.map +1 -0
  66. package/dist/view/budgets.d.ts +29 -0
  67. package/dist/view/budgets.d.ts.map +1 -0
  68. package/dist/view/budgets.js +63 -0
  69. package/dist/view/budgets.js.map +1 -0
  70. package/dist/view/touch.d.ts +71 -0
  71. package/dist/view/touch.d.ts.map +1 -0
  72. package/dist/view/touch.js +220 -0
  73. package/dist/view/touch.js.map +1 -0
  74. package/dist/view/viewer.d.ts +42 -1
  75. package/dist/view/viewer.d.ts.map +1 -1
  76. package/dist/view/viewer.js +433 -69
  77. package/dist/view/viewer.js.map +1 -1
  78. package/dist/worker/document.d.ts +33 -1
  79. package/dist/worker/document.d.ts.map +1 -1
  80. package/dist/worker/document.js +181 -24
  81. package/dist/worker/document.js.map +1 -1
  82. package/dist/worker/entry.d.ts +1 -1
  83. package/dist/worker/entry.d.ts.map +1 -1
  84. package/dist/worker/entry.js +3 -1
  85. package/dist/worker/entry.js.map +1 -1
  86. package/dist/worker/find.d.ts +7 -0
  87. package/dist/worker/find.d.ts.map +1 -0
  88. package/dist/worker/find.js +59 -0
  89. package/dist/worker/find.js.map +1 -0
  90. package/dist/worker/methods.d.ts.map +1 -1
  91. package/dist/worker/methods.js +31 -4
  92. package/dist/worker/methods.js.map +1 -1
  93. package/dist/worker/navigation.d.ts +6 -0
  94. package/dist/worker/navigation.d.ts.map +1 -0
  95. package/dist/worker/navigation.js +16 -0
  96. package/dist/worker/navigation.js.map +1 -0
  97. package/dist/worker/network.d.ts +4 -0
  98. package/dist/worker/network.d.ts.map +1 -0
  99. package/dist/worker/network.js +24 -0
  100. package/dist/worker/network.js.map +1 -0
  101. package/dist/worker/render.d.ts +5 -2
  102. package/dist/worker/render.d.ts.map +1 -1
  103. package/dist/worker/render.js +14 -7
  104. package/dist/worker/render.js.map +1 -1
  105. package/dist/worker/text.d.ts +17 -0
  106. package/dist/worker/text.d.ts.map +1 -0
  107. package/dist/worker/text.js +75 -0
  108. package/dist/worker/text.js.map +1 -0
  109. package/dist/zoom/destination.d.ts +18 -0
  110. package/dist/zoom/destination.d.ts.map +1 -0
  111. package/dist/zoom/destination.js +56 -0
  112. package/dist/zoom/destination.js.map +1 -0
  113. package/package.json +6 -5
@@ -0,0 +1,284 @@
1
+ // A remote PDF read in the document worker with HTTP Range requests: `@laminapdf/sdk`'s
2
+ // httpRangeSource (tail-first probe, If-Range and ETag checks, the 200 fallback) with what a
3
+ // reader needs on top:
4
+ //
5
+ // - privacy: every request omits credentials and the referrer (ADR 0011 §3), and by default skips
6
+ // the HTTP cache, so the file is not kept on disk after the tab closes;
7
+ // - a block cache that the render backend reads synchronously (`readSync`, `RenderSource`): PDFium
8
+ // asks for bytes while it draws, and a miss makes the render throw `data-pending`, after which
9
+ // the worker fills the ranges asked for and draws again;
10
+ // - readahead, a byte budget (least recently used blocks go first) and progress;
11
+ // - a cap on the whole download a server without range support makes, until large files can be
12
+ // streamed to OPFS.
13
+ import { httpRangeSource, isLaminaPdfError, LaminaPdfError } from "@laminapdf/sdk";
14
+ // Only docsUrl: the package has no side effects, so the explain pages stay out of the worker.
15
+ import { docsUrl } from "@laminapdf/sdk/explain";
16
+ const DEFAULTS = { blockSize: 65_536, readahead: 1, cacheBytes: 64 * 1024 * 1024, maxWholeBytes: 256 * 1024 * 1024 };
17
+ /**
18
+ * The fetch every request of the source goes through: no cookies or credentials, no referrer, no
19
+ * HTTP cache unless asked, CORS mode. Each answer's body is counted as it arrives (the source's
20
+ * progress), and a full answer is cut off past `maxWholeBytes`, whether it says its length or not
21
+ * (chunked, or compressed so that its length isn't the file's).
22
+ */
23
+ function privateFetch(base, noStore, maxWholeBytes, hooks) {
24
+ return async (input, init) => {
25
+ const response = await base(input, {
26
+ ...init,
27
+ credentials: "omit",
28
+ referrerPolicy: "no-referrer",
29
+ mode: "cors",
30
+ ...(noStore ? { cache: "no-store" } : {}),
31
+ });
32
+ const whole = response.status === 200;
33
+ if (whole) {
34
+ const length = Number(response.headers.get("Content-Length") ?? Number.NaN);
35
+ if (Number.isFinite(length) && length > maxWholeBytes) {
36
+ await response.body?.cancel();
37
+ throw tooLarge(length, maxWholeBytes);
38
+ }
39
+ hooks.whole();
40
+ }
41
+ if (response.body === null)
42
+ return response;
43
+ let total = 0;
44
+ const counted = response.body.pipeThrough(new TransformStream({
45
+ transform(chunk, controller) {
46
+ total += chunk.length;
47
+ if (whole && total > maxWholeBytes) {
48
+ controller.error(tooLarge(total, maxWholeBytes));
49
+ return;
50
+ }
51
+ hooks.received(chunk.length);
52
+ controller.enqueue(chunk);
53
+ },
54
+ }));
55
+ const { status, statusText, headers } = response;
56
+ return new Response(counted, { status, statusText, headers });
57
+ };
58
+ }
59
+ function tooLarge(size, budget) {
60
+ return new LaminaPdfError({
61
+ code: "source-budget-exceeded",
62
+ kind: "limit",
63
+ message: "the server doesn't send parts of the file, and the whole file is larger than the reader downloads",
64
+ data: { reason: "whole-download", size, budget },
65
+ docsUrl: docsUrl("source-budget-exceeded"),
66
+ });
67
+ }
68
+ /** httpRangeSource reports a throwing fetch as `fetch-failed`; the cap's own error is its cause. */
69
+ function unwrapCap(e) {
70
+ return isLaminaPdfError(e, "fetch-failed") && isLaminaPdfError(e.cause, "source-budget-exceeded") ? e.cause : e;
71
+ }
72
+ /** A remote file as a `RangeSource` (for Lamina PDF) and a `RenderSource` (for the render backend). */
73
+ export class HttpRangeByteSource {
74
+ size;
75
+ blockSize;
76
+ #inner;
77
+ #readahead;
78
+ #cacheBytes;
79
+ #onProgress;
80
+ /** Cached blocks by index, least recently used first (a Map keeps insertion order). */
81
+ #blocks = new Map();
82
+ #cached = 0;
83
+ /** Blocks being fetched, so two fills never fetch one block twice. */
84
+ #inFlight = new Map();
85
+ /** The whole file, when the server sent it whole. */
86
+ #whole;
87
+ #loaded = 0;
88
+ constructor(inner, options) {
89
+ this.#inner = inner;
90
+ this.size = inner.size;
91
+ this.blockSize = Math.max(1, Math.floor(options.blockSize ?? DEFAULTS.blockSize));
92
+ this.#readahead = Math.max(0, Math.floor(options.readahead ?? DEFAULTS.readahead));
93
+ this.#cacheBytes = options.cacheBytes ?? DEFAULTS.cacheBytes;
94
+ this.#onProgress = options.onProgress;
95
+ }
96
+ /**
97
+ * Probes `url` (its last 64 KiB: the size, and the part a reader reads first) and returns the
98
+ * source. A server that ignores `Range` sends the whole file, which is kept.
99
+ *
100
+ * @throws LaminaPdfError `fetch-failed` (`data.reason` `network` covers a refused CORS check,
101
+ * which the browser doesn't tell apart from being offline), `source-changed`,
102
+ * `source-budget-exceeded`, or the signal's reason.
103
+ */
104
+ static async open(url, options = {}) {
105
+ let whole = false;
106
+ let received = 0;
107
+ let source;
108
+ const fetcher = privateFetch(options.fetch ?? fetch, options.noStore ?? true, options.maxWholeBytes ?? DEFAULTS.maxWholeBytes, {
109
+ received: (bytes) => {
110
+ received += bytes;
111
+ if (source !== undefined)
112
+ source.#loaded = received;
113
+ },
114
+ whole: () => {
115
+ whole = true;
116
+ },
117
+ });
118
+ const inner = await httpRangeSource(url, {
119
+ fetch: fetcher,
120
+ blockSize: options.blockSize ?? DEFAULTS.blockSize,
121
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
122
+ }).catch((e) => {
123
+ throw unwrapCap(e);
124
+ });
125
+ source = new HttpRangeByteSource(inner, options);
126
+ source.#loaded = received;
127
+ // The probe's answer was the file: httpRangeSource holds it, and this reads it back once.
128
+ if (whole)
129
+ source.#whole = await inner.read(0, inner.size);
130
+ source.#progress();
131
+ return source;
132
+ }
133
+ /** The whole file, when the server sent it whole (then every read is answered from it). */
134
+ get whole() {
135
+ return this.#whole;
136
+ }
137
+ /** Bytes received from the network so far (the probe's included), re-fetches of evicted blocks too. */
138
+ get loaded() {
139
+ return this.#loaded;
140
+ }
141
+ /** The `length` bytes at `offset` if every block is cached, else undefined (`RenderSource`). */
142
+ readSync(offset, length) {
143
+ const end = Math.min(this.size, offset + length);
144
+ if (offset < 0 || end < offset)
145
+ return undefined;
146
+ if (this.#whole !== undefined)
147
+ return this.#whole.subarray(offset, end);
148
+ const first = Math.floor(offset / this.blockSize);
149
+ const last = Math.floor((end - 1) / this.blockSize);
150
+ if (end === offset)
151
+ return new Uint8Array();
152
+ if (first === last) {
153
+ const block = this.#touch(first);
154
+ if (block === undefined)
155
+ return undefined;
156
+ const at = first * this.blockSize;
157
+ return block.subarray(offset - at, end - at);
158
+ }
159
+ const out = new Uint8Array(end - offset);
160
+ for (let index = first; index <= last; index++) {
161
+ const block = this.#touch(index);
162
+ if (block === undefined)
163
+ return undefined;
164
+ const at = index * this.blockSize;
165
+ const from = Math.max(offset, at) - at;
166
+ const to = Math.min(end, at + block.length) - at;
167
+ out.set(block.subarray(from, to), at + from - offset);
168
+ }
169
+ return out;
170
+ }
171
+ /**
172
+ * Fetches the blocks `ranges` touch (and the readahead after each) that aren't cached: the
173
+ * ranges of a render backend's `data-pending`.
174
+ */
175
+ async fill(ranges, signal) {
176
+ if (this.#whole !== undefined)
177
+ return;
178
+ const wanted = this.#blocksFor(ranges);
179
+ const waits = [];
180
+ let run = [];
181
+ const flush = () => {
182
+ if (run.length > 0)
183
+ waits.push(this.#fetchRun(run, signal));
184
+ run = [];
185
+ };
186
+ for (const index of wanted) {
187
+ const pending = this.#inFlight.get(index);
188
+ if (pending !== undefined || this.#blocks.has(index)) {
189
+ flush();
190
+ if (pending !== undefined)
191
+ waits.push(pending);
192
+ continue;
193
+ }
194
+ if (run.length > 0 && run[run.length - 1] !== index - 1)
195
+ flush();
196
+ run.push(index);
197
+ }
198
+ flush();
199
+ await Promise.all(waits);
200
+ // Keep what this fill asked for: evicting it now would make the caller miss again.
201
+ this.#evict(new Set(wanted));
202
+ }
203
+ /** The blocks `ranges` touch, with the readahead after each, in order and once each. */
204
+ #blocksFor(ranges) {
205
+ const wanted = new Set();
206
+ const lastBlock = Math.max(0, Math.ceil(this.size / this.blockSize) - 1);
207
+ for (const [offset, length] of ranges) {
208
+ if (length <= 0)
209
+ continue;
210
+ const first = Math.floor(offset / this.blockSize);
211
+ const last = Math.min(lastBlock, Math.floor((offset + length - 1) / this.blockSize) + this.#readahead);
212
+ for (let index = first; index <= last; index++)
213
+ wanted.add(index);
214
+ }
215
+ return [...wanted].sort((a, b) => a - b);
216
+ }
217
+ /** `RangeSource.read`: fills, then reads from the cache. */
218
+ async read(offset, length, options) {
219
+ const cached = this.readSync(offset, length);
220
+ if (cached !== undefined && cached.length === length)
221
+ return cached.slice();
222
+ await this.fill([[offset, length]], options?.signal);
223
+ const bytes = this.readSync(offset, length);
224
+ if (bytes === undefined)
225
+ throw new Error("a range read missed blocks it had just fetched");
226
+ return bytes.slice();
227
+ }
228
+ /** Fetches the consecutive blocks `run` in one request. */
229
+ #fetchRun(run, signal) {
230
+ const first = run[0] ?? 0;
231
+ const last = run[run.length - 1] ?? first;
232
+ const start = first * this.blockSize;
233
+ const end = Math.min(this.size, (last + 1) * this.blockSize);
234
+ const fetching = this.#inner.read(start, end - start, signal === undefined ? {} : { signal }).then((bytes) => {
235
+ for (const index of run) {
236
+ const at = index * this.blockSize - start;
237
+ this.#store(index, bytes.slice(at, Math.min(bytes.length, at + this.blockSize)));
238
+ }
239
+ this.#progress();
240
+ }, (e) => {
241
+ throw unwrapCap(e);
242
+ });
243
+ const done = fetching.finally(() => {
244
+ for (const index of run)
245
+ if (this.#inFlight.get(index) === done)
246
+ this.#inFlight.delete(index);
247
+ });
248
+ for (const index of run)
249
+ this.#inFlight.set(index, done);
250
+ return done;
251
+ }
252
+ #store(index, block) {
253
+ const old = this.#blocks.get(index);
254
+ if (old !== undefined)
255
+ this.#cached -= old.length;
256
+ this.#blocks.delete(index);
257
+ this.#blocks.set(index, block);
258
+ this.#cached += block.length;
259
+ }
260
+ /** Block `index`, marked as used now; undefined when it isn't cached. */
261
+ #touch(index) {
262
+ const block = this.#blocks.get(index);
263
+ if (block !== undefined) {
264
+ this.#blocks.delete(index);
265
+ this.#blocks.set(index, block);
266
+ }
267
+ return block;
268
+ }
269
+ /** Drops the least recently used blocks over the budget, never one in `keep`. */
270
+ #evict(keep) {
271
+ for (const [index, block] of this.#blocks) {
272
+ if (this.#cached <= this.#cacheBytes)
273
+ return;
274
+ if (keep.has(index))
275
+ continue;
276
+ this.#blocks.delete(index);
277
+ this.#cached -= block.length;
278
+ }
279
+ }
280
+ #progress() {
281
+ this.#onProgress?.({ loaded: this.#loaded, total: this.size });
282
+ }
283
+ }
284
+ //# sourceMappingURL=http-range.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-range.js","sourceRoot":"","sources":["../../src/source/http-range.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ import type { PageInfo } from "@laminapdf/sdk";
2
+ import type { ViewerClient } from "../client.js";
3
+ import type { OpenedDocument } from "../protocol.js";
4
+ import type { Viewer } from "../view/viewer.js";
5
+ /** How a strip looks and how much it keeps. */
6
+ export interface ThumbnailStripOptions {
7
+ /** The widest page's thumbnail width in CSS px (default 120). */
8
+ readonly width?: number;
9
+ /** Thumbnails mounted past each end of the view (default 4). */
10
+ readonly margin?: number;
11
+ /** The bitmap cache's budget in bytes (default 32 MB). */
12
+ readonly cacheBytes?: number;
13
+ /**
14
+ * A thumbnail's accessible name and visible caption: by default its page label, and the
15
+ * name adds "Page … of …".
16
+ */
17
+ readonly label?: (page: PageInfo, count: number) => {
18
+ readonly caption: string;
19
+ readonly name: string;
20
+ };
21
+ /** The list's own accessible name (default "Pages"). */
22
+ readonly name?: string;
23
+ }
24
+ /** The pages as thumbnails. Make one with `new ThumbnailStrip(client, viewer)`, then `mount` and `open`. */
25
+ export declare class ThumbnailStrip {
26
+ #private;
27
+ /** The scrolling list (`role=listbox`); size it with CSS. */
28
+ readonly element: HTMLElement;
29
+ constructor(client: ViewerClient, viewer: Viewer, options?: ThumbnailStripOptions);
30
+ /** Puts the strip in `container`. */
31
+ mount(container: HTMLElement): void;
32
+ /** Shows `document`'s pages (the one the viewer opened). */
33
+ open(document: OpenedDocument): void;
34
+ /** Takes the strip down: renders aborted, canvases and bitmaps freed, listeners removed. */
35
+ destroy(): void;
36
+ }
37
+ //# sourceMappingURL=strip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strip.d.ts","sourceRoot":"","sources":["../../src/thumbnails/strip.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAG/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACrD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAEhD,+CAA+C;AAC/C,MAAM,WAAW,qBAAqB;IACpC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,KAAK;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACxG,wDAAwD;IACxD,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAyBD,4GAA4G;AAC5G,qBAAa,cAAc;;IACzB,6DAA6D;IAC7D,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAoB9B,YAAY,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,EA4BpF;IAED,qCAAqC;IACrC,KAAK,CAAC,SAAS,EAAE,WAAW,GAAG,IAAI,CAGlC;IAED,4DAA4D;IAC5D,IAAI,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CASnC;IAED,4FAA4F;IAC5F,OAAO,IAAI,IAAI,CAMd;CAkNF"}
@@ -0,0 +1,313 @@
1
+ // The thumbnails strip: one small picture per page in a scrolling list, the
2
+ // current page marked, a click or Enter going there. Like the viewer it mounts only the
3
+ // thumbnails in view and a few either side (the same `PageLayout` at thumbnail scale), each with
4
+ // a canvas from its own small pool, rendered at `background` priority so the main view's pages
5
+ // always come first; a thumbnail scrolled away aborts its render. Bitmaps stay in a cache of their
6
+ // own (32 MB by default), so scrolling back costs nothing. Framework-free; styles are set through
7
+ // the CSSOM, which a `style-src 'self'` policy allows.
8
+ import { BitmapCache } from "../canvas/cache.js";
9
+ import { CanvasPool } from "../canvas/pool.js";
10
+ import { PageLayout, rotated } from "../layout/page-layout.js";
11
+ /** Space for the caption under each thumbnail, and between thumbnails, in CSS px. */
12
+ const CAPTION = 20;
13
+ const GAP = 12;
14
+ /** The default caption (the page label, else the number) and name. */
15
+ function defaultLabel(page, count) {
16
+ const number = String(page.index + 1);
17
+ const caption = page.label ?? number;
18
+ const name = caption === number ? `Page ${number} of ${count}` : `Page ${caption} (${number} of ${count})`;
19
+ return { caption, name };
20
+ }
21
+ /** The pages as thumbnails. Make one with `new ThumbnailStrip(client, viewer)`, then `mount` and `open`. */
22
+ export class ThumbnailStrip {
23
+ /** The scrolling list (`role=listbox`); size it with CSS. */
24
+ element;
25
+ #client;
26
+ #viewer;
27
+ #width;
28
+ #margin;
29
+ #label;
30
+ #spacer;
31
+ #pool = new CanvasPool({ max: 32, maxPixels: 8 * 1024 * 1024 });
32
+ #cache;
33
+ #items = new Map();
34
+ #cleanup = [];
35
+ #document;
36
+ #layout;
37
+ #rotation = 0;
38
+ /** The page the viewer shows, marked selected. */
39
+ #current = 0;
40
+ /** The page that takes Tab (roving focus). */
41
+ #focus = 0;
42
+ #frame;
43
+ constructor(client, viewer, options = {}) {
44
+ this.#client = client;
45
+ this.#viewer = viewer;
46
+ this.#width = options.width ?? 120;
47
+ this.#margin = options.margin ?? 4;
48
+ this.#label = options.label ?? defaultLabel;
49
+ this.#cache = new BitmapCache(options.cacheBytes ?? 32 * 1024 * 1024);
50
+ const root = document.createElement("div");
51
+ root.className = "lamina-thumbnails";
52
+ root.setAttribute("role", "listbox");
53
+ root.setAttribute("aria-label", options.name ?? "Pages");
54
+ Object.assign(root.style, { position: "relative", overflowY: "auto", overflowX: "hidden" });
55
+ this.#spacer = document.createElement("div");
56
+ this.#spacer.style.position = "relative";
57
+ root.append(this.#spacer);
58
+ this.element = root;
59
+ const on = (type, f) => {
60
+ root.addEventListener(type, f);
61
+ this.#cleanup.push(() => root.removeEventListener(type, f));
62
+ };
63
+ on("scroll", () => this.#schedule());
64
+ on("click", (e) => this.#clicked(e));
65
+ on("keydown", (e) => this.#key(e));
66
+ this.#cleanup.push(viewer.subscribe(() => this.#viewChanged()));
67
+ // A strip shown later (its tab opened, its sheet raised) mounts what it now shows.
68
+ const resized = new ResizeObserver(() => this.#schedule());
69
+ resized.observe(root);
70
+ this.#cleanup.push(() => resized.disconnect());
71
+ }
72
+ /** Puts the strip in `container`. */
73
+ mount(container) {
74
+ container.append(this.element);
75
+ this.#schedule();
76
+ }
77
+ /** Shows `document`'s pages (the one the viewer opened). */
78
+ open(document) {
79
+ this.#unmountAll();
80
+ this.#cache.clear();
81
+ this.#document = document;
82
+ this.#current = 0;
83
+ this.#focus = 0;
84
+ this.#rotation = this.#viewer.getState().rotation;
85
+ this.element.scrollTop = 0;
86
+ this.#relayout();
87
+ }
88
+ /** Takes the strip down: renders aborted, canvases and bitmaps freed, listeners removed. */
89
+ destroy() {
90
+ if (this.#frame !== undefined)
91
+ cancelAnimationFrame(this.#frame);
92
+ this.#unmountAll();
93
+ this.#cache.clear();
94
+ for (const f of this.#cleanup.splice(0))
95
+ f();
96
+ this.element.remove();
97
+ }
98
+ // --- internals ---
99
+ #relayout() {
100
+ const pages = this.#document?.pages ?? [];
101
+ const widest = Math.max(1, ...pages.map((p) => rotated(p, this.#rotation).width));
102
+ this.#layout = new PageLayout(pages, { scale: this.#width / widest, rotation: this.#rotation, gap: GAP + CAPTION });
103
+ this.#spacer.style.height = `${this.#layout.height}px`;
104
+ this.#unmountAll();
105
+ this.#schedule();
106
+ }
107
+ #viewChanged() {
108
+ const state = this.#viewer.getState();
109
+ if (state.rotation !== this.#rotation) {
110
+ this.#rotation = state.rotation;
111
+ this.#relayout();
112
+ }
113
+ if (state.currentPage === this.#current || this.#document === undefined)
114
+ return;
115
+ this.#current = state.currentPage;
116
+ // Tab lands on the current page, unless focus is already in the list.
117
+ if (!this.element.contains(document.activeElement))
118
+ this.#focus = this.#current;
119
+ this.#reveal(this.#current);
120
+ this.#schedule();
121
+ }
122
+ /** Scrolls the strip just enough to show page `index`'s thumbnail. */
123
+ #reveal(index) {
124
+ const layout = this.#layout;
125
+ if (layout === undefined || index >= layout.pageCount)
126
+ return;
127
+ const top = layout.offsetOf(index) - GAP;
128
+ const bottom = top + layout.heights[index] + CAPTION + 2 * GAP;
129
+ const view = this.element;
130
+ if (top < view.scrollTop)
131
+ view.scrollTop = top;
132
+ else if (bottom > view.scrollTop + view.clientHeight)
133
+ view.scrollTop = bottom - view.clientHeight;
134
+ }
135
+ #schedule() {
136
+ if (this.#frame !== undefined)
137
+ return;
138
+ this.#frame = requestAnimationFrame(() => {
139
+ this.#frame = undefined;
140
+ this.#update();
141
+ });
142
+ }
143
+ #update() {
144
+ const layout = this.#layout;
145
+ if (layout === undefined || layout.pageCount === 0)
146
+ return;
147
+ // Nothing renders while the strip is out of the page or hidden (its tab not shown).
148
+ if (!this.element.isConnected || this.element.clientHeight === 0)
149
+ return;
150
+ const top = this.element.scrollTop;
151
+ const view = layout.rangeIn(top, top + Math.max(1, this.element.clientHeight));
152
+ const first = Math.max(0, view.first - this.#margin);
153
+ const last = Math.min(layout.pageCount - 1, view.last + this.#margin);
154
+ for (const [index, item] of this.#items)
155
+ if (index < first || index > last)
156
+ this.#unmount(item);
157
+ for (let index = first; index <= last; index++) {
158
+ const item = this.#items.get(index) ?? this.#mount(index, layout);
159
+ this.#mark(item);
160
+ if (!item.done && item.abort === undefined)
161
+ void this.#draw(item, layout);
162
+ }
163
+ }
164
+ #mount(index, layout) {
165
+ const page = this.#document?.pages[index];
166
+ const { caption, name } = this.#label(page, layout.pageCount);
167
+ const root = document.createElement("div");
168
+ root.className = "lamina-thumbnail";
169
+ root.setAttribute("role", "option");
170
+ root.setAttribute("aria-label", name);
171
+ root.setAttribute("aria-posinset", String(index + 1));
172
+ root.setAttribute("aria-setsize", String(layout.pageCount));
173
+ root.dataset.pageIndex = String(index);
174
+ const width = layout.widths[index];
175
+ const height = layout.heights[index];
176
+ Object.assign(root.style, {
177
+ position: "absolute",
178
+ top: `${layout.offsetOf(index) - GAP / 2}px`,
179
+ left: "0",
180
+ right: "0",
181
+ height: `${height + CAPTION + GAP}px`,
182
+ display: "flex",
183
+ flexDirection: "column",
184
+ alignItems: "center",
185
+ paddingTop: `${GAP / 2}px`,
186
+ });
187
+ const frame = document.createElement("div");
188
+ frame.className = "lamina-thumbnail-page";
189
+ Object.assign(frame.style, { width: `${width}px`, height: `${height}px`, flex: "none" });
190
+ const label = document.createElement("div");
191
+ label.className = "lamina-thumbnail-label";
192
+ label.textContent = caption;
193
+ label.setAttribute("aria-hidden", "true");
194
+ root.append(frame, label);
195
+ this.#spacer.append(root);
196
+ const item = { index, root, frame, canvas: undefined, abort: undefined, done: false };
197
+ this.#items.set(index, item);
198
+ return item;
199
+ }
200
+ /** Sets `item`'s selected state and whether Tab reaches it. */
201
+ #mark(item) {
202
+ item.root.setAttribute("aria-selected", String(item.index === this.#current));
203
+ item.root.tabIndex = item.index === this.#focus ? 0 : -1;
204
+ }
205
+ async #draw(item, layout) {
206
+ const ratio = globalThis.devicePixelRatio || 1;
207
+ const scale = layout.scale * ratio;
208
+ const key = `${this.#client.document?.id ?? 0}:${item.index}:${layout.rotation}:${scale.toFixed(4)}`;
209
+ let bitmap = this.#cache.get(key);
210
+ let owned = false;
211
+ if (bitmap === undefined) {
212
+ const abort = new AbortController();
213
+ item.abort = abort;
214
+ try {
215
+ const rendered = await this.#client.render({ page: item.index, scale, rotation: layout.rotation }, { priority: "background", signal: abort.signal });
216
+ bitmap = rendered.bitmap;
217
+ }
218
+ catch (error) {
219
+ // Scrolled away or a new document: drawn again if it comes back into view.
220
+ if (abort.signal.aborted)
221
+ return;
222
+ // A failed render keeps its placeholder, and isn't asked for again while mounted.
223
+ item.done = true;
224
+ reportError(error);
225
+ return;
226
+ }
227
+ finally {
228
+ if (item.abort === abort)
229
+ item.abort = undefined;
230
+ }
231
+ if (abort.signal.aborted || this.#items.get(item.index) !== item || this.#layout !== layout) {
232
+ bitmap.close();
233
+ return;
234
+ }
235
+ owned = !this.#cache.set(key, item.index, bitmap, this.#current);
236
+ }
237
+ this.#paint(item, bitmap, layout);
238
+ if (owned)
239
+ bitmap.close();
240
+ }
241
+ #paint(item, bitmap, layout) {
242
+ const canvas = item.canvas ?? this.#pool.acquire();
243
+ if (canvas === undefined)
244
+ return;
245
+ item.canvas = canvas;
246
+ const size = this.#pool.fit(canvas, bitmap.width, bitmap.height);
247
+ canvas.width = size.width;
248
+ canvas.height = size.height;
249
+ Object.assign(canvas.style, {
250
+ display: "block",
251
+ width: `${layout.widths[item.index]}px`,
252
+ height: `${layout.heights[item.index]}px`,
253
+ });
254
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0, size.width, size.height);
255
+ if (canvas.parentElement !== item.frame)
256
+ item.frame.append(canvas);
257
+ item.done = true;
258
+ }
259
+ #unmount(item) {
260
+ item.abort?.abort();
261
+ if (item.canvas !== undefined)
262
+ this.#pool.release(item.canvas);
263
+ item.root.remove();
264
+ this.#items.delete(item.index);
265
+ }
266
+ #unmountAll() {
267
+ for (const item of [...this.#items.values()])
268
+ this.#unmount(item);
269
+ }
270
+ #clicked(e) {
271
+ const target = e.target?.closest(".lamina-thumbnail");
272
+ const index = Number(target?.dataset.pageIndex);
273
+ if (target === null || target === undefined || !Number.isInteger(index))
274
+ return;
275
+ this.#go(index);
276
+ }
277
+ /** Arrow keys, Home and End move focus; Enter and Space go to the focused page. */
278
+ #key(e) {
279
+ const count = this.#layout?.pageCount ?? 0;
280
+ if (count === 0)
281
+ return;
282
+ const moves = {
283
+ ArrowDown: this.#focus + 1,
284
+ ArrowUp: this.#focus - 1,
285
+ Home: 0,
286
+ End: count - 1,
287
+ };
288
+ const next = moves[e.key];
289
+ if (next !== undefined) {
290
+ e.preventDefault();
291
+ this.#moveFocus(Math.min(count - 1, Math.max(0, next)));
292
+ }
293
+ else if (e.key === "Enter" || e.key === " ") {
294
+ e.preventDefault();
295
+ this.#go(this.#focus);
296
+ }
297
+ }
298
+ #moveFocus(index) {
299
+ this.#focus = index;
300
+ this.#reveal(index);
301
+ // The item may not be mounted yet: mount the range now, then focus it.
302
+ this.#update();
303
+ const item = this.#items.get(index);
304
+ for (const other of this.#items.values())
305
+ this.#mark(other);
306
+ item?.root.focus();
307
+ }
308
+ #go(index) {
309
+ this.#focus = index;
310
+ this.#viewer.goToPage(index);
311
+ }
312
+ }
313
+ //# sourceMappingURL=strip.js.map