@0k/pdf-viewer 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +282 -2
- package/dist/index.d.ts +109 -0
- package/dist/index.js +1023 -0
- package/dist/style.css +219 -0
- package/package.json +61 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@0k/pdf-viewer` — a plain-DOM PDF page viewer over the shared
|
|
3
|
+
* `@0k/pdfjs-legacy-runtime`.
|
|
4
|
+
*
|
|
5
|
+
* Loading, rendering and teardown follow these rules to preserve the
|
|
6
|
+
* visible canvas and bound worker and raster memory on WebView 91:
|
|
7
|
+
*
|
|
8
|
+
* - ⚠ The URL is handed to PDF.js, never a `blob:`. PDF.js then
|
|
9
|
+
* range-loads the cross-reference table and the current page rather
|
|
10
|
+
* than the whole file (`disableAutoFetch` + `disableStream`; range left
|
|
11
|
+
* enabled). A 67 MB scan paints page 1 without downloading page 40.
|
|
12
|
+
* - ⚠ The LOADING TASK is the sole owner of worker/transport teardown.
|
|
13
|
+
* The runtime's `PdfDocument` has no `destroy`, so `doc.destroy()` is a
|
|
14
|
+
* compile error rather than a second path to the same worker.
|
|
15
|
+
* - ⚠ Teardown → startup is SERIALIZED across hosts. A reload awaits its
|
|
16
|
+
* previous task's `destroy()` before importing the runtime; every load
|
|
17
|
+
* awaits all pending task teardowns (settled, rejection tolerated)
|
|
18
|
+
* before creating a worker. Failed teardown is logged and does not
|
|
19
|
+
* block startup, but may leave its worker running. Overlap can blank
|
|
20
|
+
* the canvas on WebView 91.
|
|
21
|
+
* - ⚠ The DISPLAYED page is never `cleanup()`-ed. Releasing it right
|
|
22
|
+
* after `render()` resolves can race the first compositor flush and
|
|
23
|
+
* lose the paint. Only stale, cancelled or errored renders — whose
|
|
24
|
+
* canvases are discarded anyway — are cleaned up.
|
|
25
|
+
* - ⚠ Rendering happens on a DETACHED canvas and is committed to the
|
|
26
|
+
* visible one in ONE synchronous step. Resizing a canvas's backing
|
|
27
|
+
* store clears it; an `await` between that clear and the copy is a
|
|
28
|
+
* blank frame.
|
|
29
|
+
* - ⚠ The device pixel ratio is CAPPED. Uncapped DPR × 200 % × A4 is a
|
|
30
|
+
* multi-hundred-megapixel canvas, which OOMs the WebView.
|
|
31
|
+
* - ⚠ Zoom is applied TWICE, deliberately. The live gesture drives a CSS
|
|
32
|
+
* transform (free); when it settles the page is re-rendered at the new
|
|
33
|
+
* scale and the transform is dropped. A canvas scaled by CSS is blurred
|
|
34
|
+
* exactly when the reader zoomed in to read it, but a re-render per
|
|
35
|
+
* pointer frame is a worker round-trip per finger movement.
|
|
36
|
+
*
|
|
37
|
+
* Hosts own everything else: the URL and its access policy, where the
|
|
38
|
+
* viewer mounts, any collapsed/opened affordance or overlay around it,
|
|
39
|
+
* and translated labels. The viewer is in-flow and fills its host.
|
|
40
|
+
*/
|
|
41
|
+
import { loadPdfRuntime, } from "@0k/pdfjs-legacy-runtime";
|
|
42
|
+
/** Class of the viewer root; every style is scoped under it. */
|
|
43
|
+
export const PDF_VIEWER_CLASS = "pdf-viewer";
|
|
44
|
+
/** Default zoom bounds and toolbar step (a fraction of the fitted size). */
|
|
45
|
+
export const PDF_VIEWER_MIN_ZOOM = 0.5;
|
|
46
|
+
export const PDF_VIEWER_MAX_ZOOM = 2.0;
|
|
47
|
+
export const PDF_VIEWER_ZOOM_STEP = 0.25;
|
|
48
|
+
export const PDF_VIEWER_DEFAULT_ZOOM = 1.0;
|
|
49
|
+
/**
|
|
50
|
+
* Cap on the effective device pixel ratio used to size the backing
|
|
51
|
+
* canvas. 2 is sharp enough while bounding memory on a 3× phone.
|
|
52
|
+
*/
|
|
53
|
+
export const PDF_VIEWER_MAX_DPR = 2;
|
|
54
|
+
/** Zoom factor for 100 CSS pixels of wheel-down movement. */
|
|
55
|
+
export const PDF_VIEWER_WHEEL_FACTOR = 0.9;
|
|
56
|
+
const WHEEL_NOTCH_PIXELS = 100;
|
|
57
|
+
const WHEEL_LINE_PIXELS = 16;
|
|
58
|
+
const RESIZE_SETTLE_MS = 100;
|
|
59
|
+
/** Milliseconds of quiet that end a wheel burst — a wheel has no "up". */
|
|
60
|
+
export const PDF_VIEWER_WHEEL_SETTLE_MS = 250;
|
|
61
|
+
export const DEFAULT_PDF_VIEWER_LABELS = {
|
|
62
|
+
controls: "PDF controls",
|
|
63
|
+
previous: "Previous page",
|
|
64
|
+
next: "Next page",
|
|
65
|
+
zoomOut: "Zoom out",
|
|
66
|
+
zoomIn: "Zoom in",
|
|
67
|
+
zoomReset: "Reset zoom",
|
|
68
|
+
reset: "Reset zoom and pan",
|
|
69
|
+
retry: "Retry",
|
|
70
|
+
loading: "Loading…",
|
|
71
|
+
failed: "Failed to load PDF: %{reason}",
|
|
72
|
+
};
|
|
73
|
+
/** Only unsettled task teardowns are retained, regardless of their host. */
|
|
74
|
+
const pendingTaskDestructions = new Set();
|
|
75
|
+
/**
|
|
76
|
+
* Internal renderer state, never returned to hosts. The public owner is a
|
|
77
|
+
* frozen facade whose closures call this instance with its original receiver,
|
|
78
|
+
* keeping host reactivity proxies away from PDF.js handles.
|
|
79
|
+
*/
|
|
80
|
+
class Viewer {
|
|
81
|
+
constructor(host, options, pendingDestroy = Promise.resolve()) {
|
|
82
|
+
Object.defineProperty(this, "host", {
|
|
83
|
+
enumerable: true,
|
|
84
|
+
configurable: true,
|
|
85
|
+
writable: true,
|
|
86
|
+
value: void 0
|
|
87
|
+
});
|
|
88
|
+
Object.defineProperty(this, "options", {
|
|
89
|
+
enumerable: true,
|
|
90
|
+
configurable: true,
|
|
91
|
+
writable: true,
|
|
92
|
+
value: void 0
|
|
93
|
+
});
|
|
94
|
+
Object.defineProperty(this, "labels", {
|
|
95
|
+
enumerable: true,
|
|
96
|
+
configurable: true,
|
|
97
|
+
writable: true,
|
|
98
|
+
value: void 0
|
|
99
|
+
});
|
|
100
|
+
Object.defineProperty(this, "bounds", {
|
|
101
|
+
enumerable: true,
|
|
102
|
+
configurable: true,
|
|
103
|
+
writable: true,
|
|
104
|
+
value: void 0
|
|
105
|
+
});
|
|
106
|
+
Object.defineProperty(this, "fit", {
|
|
107
|
+
enumerable: true,
|
|
108
|
+
configurable: true,
|
|
109
|
+
writable: true,
|
|
110
|
+
value: void 0
|
|
111
|
+
});
|
|
112
|
+
Object.defineProperty(this, "maxDpr", {
|
|
113
|
+
enumerable: true,
|
|
114
|
+
configurable: true,
|
|
115
|
+
writable: true,
|
|
116
|
+
value: void 0
|
|
117
|
+
});
|
|
118
|
+
Object.defineProperty(this, "root", {
|
|
119
|
+
enumerable: true,
|
|
120
|
+
configurable: true,
|
|
121
|
+
writable: true,
|
|
122
|
+
value: void 0
|
|
123
|
+
});
|
|
124
|
+
Object.defineProperty(this, "surface", {
|
|
125
|
+
enumerable: true,
|
|
126
|
+
configurable: true,
|
|
127
|
+
writable: true,
|
|
128
|
+
value: void 0
|
|
129
|
+
});
|
|
130
|
+
Object.defineProperty(this, "canvas", {
|
|
131
|
+
enumerable: true,
|
|
132
|
+
configurable: true,
|
|
133
|
+
writable: true,
|
|
134
|
+
value: void 0
|
|
135
|
+
});
|
|
136
|
+
Object.defineProperty(this, "statusLine", {
|
|
137
|
+
enumerable: true,
|
|
138
|
+
configurable: true,
|
|
139
|
+
writable: true,
|
|
140
|
+
value: void 0
|
|
141
|
+
});
|
|
142
|
+
Object.defineProperty(this, "controls", {
|
|
143
|
+
enumerable: true,
|
|
144
|
+
configurable: true,
|
|
145
|
+
writable: true,
|
|
146
|
+
value: void 0
|
|
147
|
+
});
|
|
148
|
+
Object.defineProperty(this, "status", {
|
|
149
|
+
enumerable: true,
|
|
150
|
+
configurable: true,
|
|
151
|
+
writable: true,
|
|
152
|
+
value: "loading"
|
|
153
|
+
});
|
|
154
|
+
Object.defineProperty(this, "error", {
|
|
155
|
+
enumerable: true,
|
|
156
|
+
configurable: true,
|
|
157
|
+
writable: true,
|
|
158
|
+
value: null
|
|
159
|
+
});
|
|
160
|
+
Object.defineProperty(this, "page", {
|
|
161
|
+
enumerable: true,
|
|
162
|
+
configurable: true,
|
|
163
|
+
writable: true,
|
|
164
|
+
value: 1
|
|
165
|
+
});
|
|
166
|
+
Object.defineProperty(this, "pageCount", {
|
|
167
|
+
enumerable: true,
|
|
168
|
+
configurable: true,
|
|
169
|
+
writable: true,
|
|
170
|
+
value: 0
|
|
171
|
+
});
|
|
172
|
+
/** Live zoom (gesture included), relative to the fitted size. */
|
|
173
|
+
Object.defineProperty(this, "zoom", {
|
|
174
|
+
enumerable: true,
|
|
175
|
+
configurable: true,
|
|
176
|
+
writable: true,
|
|
177
|
+
value: void 0
|
|
178
|
+
});
|
|
179
|
+
/** The zoom the visible raster was drawn at. */
|
|
180
|
+
Object.defineProperty(this, "rasterZoom", {
|
|
181
|
+
enumerable: true,
|
|
182
|
+
configurable: true,
|
|
183
|
+
writable: true,
|
|
184
|
+
value: void 0
|
|
185
|
+
});
|
|
186
|
+
Object.defineProperty(this, "panX", {
|
|
187
|
+
enumerable: true,
|
|
188
|
+
configurable: true,
|
|
189
|
+
writable: true,
|
|
190
|
+
value: 0
|
|
191
|
+
});
|
|
192
|
+
Object.defineProperty(this, "panY", {
|
|
193
|
+
enumerable: true,
|
|
194
|
+
configurable: true,
|
|
195
|
+
writable: true,
|
|
196
|
+
value: 0
|
|
197
|
+
});
|
|
198
|
+
Object.defineProperty(this, "fitScale", {
|
|
199
|
+
enumerable: true,
|
|
200
|
+
configurable: true,
|
|
201
|
+
writable: true,
|
|
202
|
+
value: 1
|
|
203
|
+
});
|
|
204
|
+
Object.defineProperty(this, "fitWidth", {
|
|
205
|
+
enumerable: true,
|
|
206
|
+
configurable: true,
|
|
207
|
+
writable: true,
|
|
208
|
+
value: 0
|
|
209
|
+
});
|
|
210
|
+
Object.defineProperty(this, "fitOuterWidth", {
|
|
211
|
+
enumerable: true,
|
|
212
|
+
configurable: true,
|
|
213
|
+
writable: true,
|
|
214
|
+
value: 0
|
|
215
|
+
});
|
|
216
|
+
Object.defineProperty(this, "fitHeight", {
|
|
217
|
+
enumerable: true,
|
|
218
|
+
configurable: true,
|
|
219
|
+
writable: true,
|
|
220
|
+
value: 0
|
|
221
|
+
});
|
|
222
|
+
Object.defineProperty(this, "scrollbarFit", {
|
|
223
|
+
enumerable: true,
|
|
224
|
+
configurable: true,
|
|
225
|
+
writable: true,
|
|
226
|
+
value: null
|
|
227
|
+
});
|
|
228
|
+
Object.defineProperty(this, "resizeObserver", {
|
|
229
|
+
enumerable: true,
|
|
230
|
+
configurable: true,
|
|
231
|
+
writable: true,
|
|
232
|
+
value: null
|
|
233
|
+
});
|
|
234
|
+
Object.defineProperty(this, "resizeTimer", {
|
|
235
|
+
enumerable: true,
|
|
236
|
+
configurable: true,
|
|
237
|
+
writable: true,
|
|
238
|
+
value: null
|
|
239
|
+
});
|
|
240
|
+
/** Has a raster of the CURRENT document been committed to the canvas? */
|
|
241
|
+
Object.defineProperty(this, "painted", {
|
|
242
|
+
enumerable: true,
|
|
243
|
+
configurable: true,
|
|
244
|
+
writable: true,
|
|
245
|
+
value: false
|
|
246
|
+
});
|
|
247
|
+
Object.defineProperty(this, "displayedPage", {
|
|
248
|
+
enumerable: true,
|
|
249
|
+
configurable: true,
|
|
250
|
+
writable: true,
|
|
251
|
+
value: null
|
|
252
|
+
});
|
|
253
|
+
Object.defineProperty(this, "alive", {
|
|
254
|
+
enumerable: true,
|
|
255
|
+
configurable: true,
|
|
256
|
+
writable: true,
|
|
257
|
+
value: true
|
|
258
|
+
});
|
|
259
|
+
Object.defineProperty(this, "loadingTask", {
|
|
260
|
+
enumerable: true,
|
|
261
|
+
configurable: true,
|
|
262
|
+
writable: true,
|
|
263
|
+
value: null
|
|
264
|
+
});
|
|
265
|
+
Object.defineProperty(this, "doc", {
|
|
266
|
+
enumerable: true,
|
|
267
|
+
configurable: true,
|
|
268
|
+
writable: true,
|
|
269
|
+
value: null
|
|
270
|
+
});
|
|
271
|
+
Object.defineProperty(this, "renderTask", {
|
|
272
|
+
enumerable: true,
|
|
273
|
+
configurable: true,
|
|
274
|
+
writable: true,
|
|
275
|
+
value: null
|
|
276
|
+
});
|
|
277
|
+
Object.defineProperty(this, "renderCanvas", {
|
|
278
|
+
enumerable: true,
|
|
279
|
+
configurable: true,
|
|
280
|
+
writable: true,
|
|
281
|
+
value: null
|
|
282
|
+
});
|
|
283
|
+
Object.defineProperty(this, "renderGeneration", {
|
|
284
|
+
enumerable: true,
|
|
285
|
+
configurable: true,
|
|
286
|
+
writable: true,
|
|
287
|
+
value: 0
|
|
288
|
+
});
|
|
289
|
+
Object.defineProperty(this, "loadGeneration", {
|
|
290
|
+
enumerable: true,
|
|
291
|
+
configurable: true,
|
|
292
|
+
writable: true,
|
|
293
|
+
value: 0
|
|
294
|
+
});
|
|
295
|
+
Object.defineProperty(this, "pendingDestroy", {
|
|
296
|
+
enumerable: true,
|
|
297
|
+
configurable: true,
|
|
298
|
+
writable: true,
|
|
299
|
+
value: void 0
|
|
300
|
+
});
|
|
301
|
+
Object.defineProperty(this, "taskDestructions", {
|
|
302
|
+
enumerable: true,
|
|
303
|
+
configurable: true,
|
|
304
|
+
writable: true,
|
|
305
|
+
value: new WeakMap()
|
|
306
|
+
});
|
|
307
|
+
// Gesture state.
|
|
308
|
+
Object.defineProperty(this, "activePointers", {
|
|
309
|
+
enumerable: true,
|
|
310
|
+
configurable: true,
|
|
311
|
+
writable: true,
|
|
312
|
+
value: new Map()
|
|
313
|
+
});
|
|
314
|
+
Object.defineProperty(this, "panOrigin", {
|
|
315
|
+
enumerable: true,
|
|
316
|
+
configurable: true,
|
|
317
|
+
writable: true,
|
|
318
|
+
value: null
|
|
319
|
+
});
|
|
320
|
+
Object.defineProperty(this, "pinchOrigin", {
|
|
321
|
+
enumerable: true,
|
|
322
|
+
configurable: true,
|
|
323
|
+
writable: true,
|
|
324
|
+
value: null
|
|
325
|
+
});
|
|
326
|
+
Object.defineProperty(this, "settleFrom", {
|
|
327
|
+
enumerable: true,
|
|
328
|
+
configurable: true,
|
|
329
|
+
writable: true,
|
|
330
|
+
value: void 0
|
|
331
|
+
});
|
|
332
|
+
Object.defineProperty(this, "wheelTimer", {
|
|
333
|
+
enumerable: true,
|
|
334
|
+
configurable: true,
|
|
335
|
+
writable: true,
|
|
336
|
+
value: null
|
|
337
|
+
});
|
|
338
|
+
Object.defineProperty(this, "interaction", {
|
|
339
|
+
enumerable: true,
|
|
340
|
+
configurable: true,
|
|
341
|
+
writable: true,
|
|
342
|
+
value: void 0
|
|
343
|
+
});
|
|
344
|
+
Object.defineProperty(this, "onResize", {
|
|
345
|
+
enumerable: true,
|
|
346
|
+
configurable: true,
|
|
347
|
+
writable: true,
|
|
348
|
+
value: () => {
|
|
349
|
+
if (!this.alive || this.status !== "ready" || !this.fitSizeChanged())
|
|
350
|
+
return;
|
|
351
|
+
if (this.resizeTimer !== null)
|
|
352
|
+
clearTimeout(this.resizeTimer);
|
|
353
|
+
this.resizeTimer = setTimeout(() => {
|
|
354
|
+
this.resizeTimer = null;
|
|
355
|
+
if (this.alive && this.status === "ready" && this.fitSizeChanged())
|
|
356
|
+
void this.paint();
|
|
357
|
+
}, RESIZE_SETTLE_MS);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
Object.defineProperty(this, "onPointerDown", {
|
|
361
|
+
enumerable: true,
|
|
362
|
+
configurable: true,
|
|
363
|
+
writable: true,
|
|
364
|
+
value: (event) => {
|
|
365
|
+
if (this.status !== "ready")
|
|
366
|
+
return;
|
|
367
|
+
if (event.pointerType === "mouse" && event.button !== 0)
|
|
368
|
+
return;
|
|
369
|
+
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
370
|
+
try {
|
|
371
|
+
this.surface.setPointerCapture(event.pointerId);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
/* not capturable — events still arrive on the surface */
|
|
375
|
+
}
|
|
376
|
+
if (this.activePointers.size === 1 && this.wheelTimer === null)
|
|
377
|
+
this.settleFrom = this.zoom;
|
|
378
|
+
// Scroll mode: a single finger belongs to the browser's scrolling.
|
|
379
|
+
if (this.interaction === "scroll" && this.activePointers.size < 2)
|
|
380
|
+
return;
|
|
381
|
+
if (this.activePointers.size >= 2) {
|
|
382
|
+
// A second finger converts pan into pinch; stop panning so the page
|
|
383
|
+
// does not lurch toward whichever finger arrived first.
|
|
384
|
+
this.panOrigin = null;
|
|
385
|
+
this.surface.classList.remove("pdf-viewer-surface--panning");
|
|
386
|
+
this.pinchOrigin = { distance: this.pinchDistance(), zoom: this.zoom };
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
this.panOrigin = { x: event.clientX, y: event.clientY, panX: this.panX, panY: this.panY };
|
|
390
|
+
this.surface.classList.add("pdf-viewer-surface--panning");
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
Object.defineProperty(this, "onPointerMove", {
|
|
394
|
+
enumerable: true,
|
|
395
|
+
configurable: true,
|
|
396
|
+
writable: true,
|
|
397
|
+
value: (event) => {
|
|
398
|
+
if (!this.activePointers.has(event.pointerId))
|
|
399
|
+
return;
|
|
400
|
+
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
401
|
+
if (this.pinchOrigin && this.activePointers.size >= 2) {
|
|
402
|
+
const distance = this.pinchDistance();
|
|
403
|
+
if (distance > 0 && this.pinchOrigin.distance > 0) {
|
|
404
|
+
this.zoom = this.clampZoom(this.pinchOrigin.zoom * (distance / this.pinchOrigin.distance));
|
|
405
|
+
this.applyTransform();
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (!this.panOrigin)
|
|
410
|
+
return;
|
|
411
|
+
this.panX = this.panOrigin.panX + (event.clientX - this.panOrigin.x);
|
|
412
|
+
this.panY = this.panOrigin.panY + (event.clientY - this.panOrigin.y);
|
|
413
|
+
this.applyTransform();
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
Object.defineProperty(this, "onPointerUp", {
|
|
417
|
+
enumerable: true,
|
|
418
|
+
configurable: true,
|
|
419
|
+
writable: true,
|
|
420
|
+
value: (event) => {
|
|
421
|
+
this.activePointers.delete(event.pointerId);
|
|
422
|
+
if (this.activePointers.size < 2)
|
|
423
|
+
this.pinchOrigin = null;
|
|
424
|
+
if (this.activePointers.size === 1) {
|
|
425
|
+
// One finger lifted after a pinch: resume panning from where the
|
|
426
|
+
// remaining finger is rather than jumping by the gap between them.
|
|
427
|
+
const [remaining] = [...this.activePointers.values()];
|
|
428
|
+
if (remaining && this.interaction === "gestures") {
|
|
429
|
+
this.panOrigin = { x: remaining.x, y: remaining.y, panX: this.panX, panY: this.panY };
|
|
430
|
+
this.surface.classList.add("pdf-viewer-surface--panning");
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (this.activePointers.size === 0) {
|
|
435
|
+
this.panOrigin = null;
|
|
436
|
+
this.surface.classList.remove("pdf-viewer-surface--panning");
|
|
437
|
+
this.sync();
|
|
438
|
+
this.settle();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
Object.defineProperty(this, "onWheel", {
|
|
443
|
+
enumerable: true,
|
|
444
|
+
configurable: true,
|
|
445
|
+
writable: true,
|
|
446
|
+
value: (event) => {
|
|
447
|
+
if (this.status !== "ready" || event.deltaY === 0)
|
|
448
|
+
return;
|
|
449
|
+
if (this.interaction === "scroll" && !event.ctrlKey && !event.metaKey)
|
|
450
|
+
return;
|
|
451
|
+
// The browser would otherwise zoom the whole page on Ctrl/⌘+wheel;
|
|
452
|
+
// a bare wheel zooms too, since the surface owns its scrolling.
|
|
453
|
+
event.preventDefault();
|
|
454
|
+
if (this.activePointers.size === 0 && this.wheelTimer === null)
|
|
455
|
+
this.settleFrom = this.zoom;
|
|
456
|
+
const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? WHEEL_LINE_PIXELS
|
|
457
|
+
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? this.surface.clientHeight : 1;
|
|
458
|
+
this.zoom = this.clampZoom(this.zoom * Math.pow(PDF_VIEWER_WHEEL_FACTOR, event.deltaY * unit / WHEEL_NOTCH_PIXELS));
|
|
459
|
+
this.applyTransform();
|
|
460
|
+
if (this.wheelTimer !== null)
|
|
461
|
+
clearTimeout(this.wheelTimer);
|
|
462
|
+
this.wheelTimer = setTimeout(() => {
|
|
463
|
+
this.wheelTimer = null;
|
|
464
|
+
this.sync();
|
|
465
|
+
this.settle();
|
|
466
|
+
}, PDF_VIEWER_WHEEL_SETTLE_MS);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
this.pendingDestroy = pendingDestroy;
|
|
470
|
+
this.host = host;
|
|
471
|
+
this.options = options;
|
|
472
|
+
this.labels = { ...DEFAULT_PDF_VIEWER_LABELS };
|
|
473
|
+
for (const [key, value] of Object.entries(options.labels ?? {})) {
|
|
474
|
+
if (value !== undefined)
|
|
475
|
+
Object.assign(this.labels, { [key]: value });
|
|
476
|
+
}
|
|
477
|
+
this.bounds = {
|
|
478
|
+
min: options.zoom?.min ?? PDF_VIEWER_MIN_ZOOM,
|
|
479
|
+
max: options.zoom?.max ?? PDF_VIEWER_MAX_ZOOM,
|
|
480
|
+
step: options.zoom?.step ?? PDF_VIEWER_ZOOM_STEP,
|
|
481
|
+
};
|
|
482
|
+
this.fit = options.fit ?? "width";
|
|
483
|
+
this.maxDpr = options.maxDpr ?? PDF_VIEWER_MAX_DPR;
|
|
484
|
+
this.interaction = options.interaction ?? "gestures";
|
|
485
|
+
this.zoom = this.clampZoom(options.zoom?.initial ?? PDF_VIEWER_DEFAULT_ZOOM);
|
|
486
|
+
this.rasterZoom = this.zoom;
|
|
487
|
+
this.settleFrom = this.zoom;
|
|
488
|
+
const built = buildDom(this.labels, this.interaction);
|
|
489
|
+
this.root = built.root;
|
|
490
|
+
this.surface = built.surface;
|
|
491
|
+
this.canvas = built.canvas;
|
|
492
|
+
this.statusLine = built.status;
|
|
493
|
+
this.controls = built.controls;
|
|
494
|
+
host.appendChild(this.root);
|
|
495
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
496
|
+
this.resizeObserver = new ResizeObserver(this.onResize);
|
|
497
|
+
this.resizeObserver.observe(this.surface);
|
|
498
|
+
}
|
|
499
|
+
this.controls.previous.addEventListener("click", () => this.previous());
|
|
500
|
+
this.controls.next.addEventListener("click", () => this.next());
|
|
501
|
+
this.controls.zoomOut.addEventListener("click", () => this.setZoom(this.zoom - this.bounds.step));
|
|
502
|
+
this.controls.zoomIn.addEventListener("click", () => this.setZoom(this.zoom + this.bounds.step));
|
|
503
|
+
this.controls.zoomReset.addEventListener("click", () => this.resetView());
|
|
504
|
+
this.controls.reset.addEventListener("click", () => this.resetView());
|
|
505
|
+
this.statusLine.addEventListener("click", (event) => {
|
|
506
|
+
const target = event.target;
|
|
507
|
+
if (target?.closest(".pdf-viewer-retry"))
|
|
508
|
+
void this.reload();
|
|
509
|
+
});
|
|
510
|
+
if (this.interaction !== "none") {
|
|
511
|
+
this.surface.addEventListener("pointerdown", this.onPointerDown);
|
|
512
|
+
this.surface.addEventListener("pointermove", this.onPointerMove);
|
|
513
|
+
this.surface.addEventListener("pointerup", this.onPointerUp);
|
|
514
|
+
this.surface.addEventListener("pointercancel", this.onPointerUp);
|
|
515
|
+
this.surface.addEventListener("wheel", this.onWheel, { passive: false });
|
|
516
|
+
if (this.interaction === "gestures")
|
|
517
|
+
this.surface.addEventListener("dblclick", () => this.resetView());
|
|
518
|
+
}
|
|
519
|
+
this.sync();
|
|
520
|
+
void this.load();
|
|
521
|
+
}
|
|
522
|
+
// ---- public handle ----------------------------------------------------
|
|
523
|
+
state() {
|
|
524
|
+
return {
|
|
525
|
+
status: this.status,
|
|
526
|
+
page: this.page,
|
|
527
|
+
pageCount: this.pageCount,
|
|
528
|
+
zoom: this.zoom,
|
|
529
|
+
error: this.error,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
goTo(page) {
|
|
533
|
+
if (this.status !== "ready" || this.pageCount === 0)
|
|
534
|
+
return;
|
|
535
|
+
const target = Math.min(this.pageCount, Math.max(1, Math.floor(page)));
|
|
536
|
+
if (target === this.page)
|
|
537
|
+
return;
|
|
538
|
+
this.page = target;
|
|
539
|
+
this.sync();
|
|
540
|
+
void this.paint();
|
|
541
|
+
}
|
|
542
|
+
next() {
|
|
543
|
+
this.goTo(this.page + 1);
|
|
544
|
+
}
|
|
545
|
+
previous() {
|
|
546
|
+
this.goTo(this.page - 1);
|
|
547
|
+
}
|
|
548
|
+
setZoom(zoom) {
|
|
549
|
+
if (this.status !== "ready")
|
|
550
|
+
return;
|
|
551
|
+
const clamped = this.clampZoom(zoom);
|
|
552
|
+
if (Math.abs(clamped - this.zoom) < 1e-9)
|
|
553
|
+
return;
|
|
554
|
+
this.zoom = clamped;
|
|
555
|
+
this.settleFrom = clamped;
|
|
556
|
+
this.sync();
|
|
557
|
+
void this.paint();
|
|
558
|
+
}
|
|
559
|
+
resetView() {
|
|
560
|
+
if (this.status !== "ready")
|
|
561
|
+
return;
|
|
562
|
+
const changedZoom = Math.abs(this.zoom - PDF_VIEWER_DEFAULT_ZOOM) >= 1e-9;
|
|
563
|
+
this.zoom = this.clampZoom(PDF_VIEWER_DEFAULT_ZOOM);
|
|
564
|
+
this.settleFrom = this.zoom;
|
|
565
|
+
this.panX = 0;
|
|
566
|
+
this.panY = 0;
|
|
567
|
+
this.sync();
|
|
568
|
+
if (changedZoom)
|
|
569
|
+
void this.paint();
|
|
570
|
+
else
|
|
571
|
+
this.applyTransform();
|
|
572
|
+
}
|
|
573
|
+
async reload() {
|
|
574
|
+
if (!this.alive)
|
|
575
|
+
return;
|
|
576
|
+
await this.load();
|
|
577
|
+
}
|
|
578
|
+
async destroy() {
|
|
579
|
+
if (!this.alive)
|
|
580
|
+
return this.pendingDestroy;
|
|
581
|
+
this.alive = false;
|
|
582
|
+
this.loadGeneration += 1;
|
|
583
|
+
if (this.wheelTimer !== null)
|
|
584
|
+
clearTimeout(this.wheelTimer);
|
|
585
|
+
this.wheelTimer = null;
|
|
586
|
+
this.resizeObserver?.disconnect();
|
|
587
|
+
this.resizeObserver = null;
|
|
588
|
+
if (this.resizeTimer !== null)
|
|
589
|
+
clearTimeout(this.resizeTimer);
|
|
590
|
+
this.resizeTimer = null;
|
|
591
|
+
this.root.remove();
|
|
592
|
+
const teardown = this.releaseDocument();
|
|
593
|
+
this.canvas.width = 0;
|
|
594
|
+
this.canvas.height = 0;
|
|
595
|
+
this.displayedPage = null;
|
|
596
|
+
this.painted = false;
|
|
597
|
+
await teardown;
|
|
598
|
+
}
|
|
599
|
+
// ---- lifecycle --------------------------------------------------------
|
|
600
|
+
clampZoom(value) {
|
|
601
|
+
return Math.min(this.bounds.max, Math.max(this.bounds.min, value));
|
|
602
|
+
}
|
|
603
|
+
emit() {
|
|
604
|
+
try {
|
|
605
|
+
this.options.onStateChange?.(this.state());
|
|
606
|
+
}
|
|
607
|
+
catch (reason) {
|
|
608
|
+
console.error("pdf-viewer: onStateChange failed", reason);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
cancelActiveRender() {
|
|
612
|
+
const task = this.renderTask;
|
|
613
|
+
this.renderTask = null;
|
|
614
|
+
this.renderGeneration += 1;
|
|
615
|
+
try {
|
|
616
|
+
task?.cancel();
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
// Cancelling an already-settled render throws in some builds.
|
|
620
|
+
}
|
|
621
|
+
finally {
|
|
622
|
+
if (this.renderCanvas)
|
|
623
|
+
this.discardRenderCanvas(this.renderCanvas);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Release the PDF.js handles: cancel the active render, destroy the
|
|
628
|
+
* loading task. Leaves the DOM alone (the retry path keeps its
|
|
629
|
+
* scaffold). The destroy promise is retained so `load` can await it.
|
|
630
|
+
*/
|
|
631
|
+
releaseDocument() {
|
|
632
|
+
this.cancelActiveRender();
|
|
633
|
+
const task = this.loadingTask;
|
|
634
|
+
this.loadingTask = null;
|
|
635
|
+
this.doc = null;
|
|
636
|
+
if (task)
|
|
637
|
+
this.destroyTask(task);
|
|
638
|
+
return this.pendingDestroy;
|
|
639
|
+
}
|
|
640
|
+
destroyTask(task) {
|
|
641
|
+
if (this.taskDestructions.has(task))
|
|
642
|
+
return;
|
|
643
|
+
const destruction = task.destroy()
|
|
644
|
+
.catch((reason) => {
|
|
645
|
+
try {
|
|
646
|
+
console.error("pdf-viewer: task.destroy failed", reason);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
// A throwing host logger must not reject the teardown/startup barrier.
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
pendingTaskDestructions.add(destruction);
|
|
653
|
+
const forget = () => { pendingTaskDestructions.delete(destruction); };
|
|
654
|
+
void destruction.then(forget, forget);
|
|
655
|
+
this.taskDestructions.set(task, destruction);
|
|
656
|
+
this.pendingDestroy = Promise.all([this.pendingDestroy, destruction]).then(() => undefined);
|
|
657
|
+
}
|
|
658
|
+
async load() {
|
|
659
|
+
const generation = ++this.loadGeneration;
|
|
660
|
+
let task = null;
|
|
661
|
+
const superseded = () => {
|
|
662
|
+
if (this.alive && generation === this.loadGeneration)
|
|
663
|
+
return false;
|
|
664
|
+
if (task)
|
|
665
|
+
this.destroyTask(task);
|
|
666
|
+
return true;
|
|
667
|
+
};
|
|
668
|
+
void this.releaseDocument();
|
|
669
|
+
this.status = "loading";
|
|
670
|
+
this.error = null;
|
|
671
|
+
this.painted = false;
|
|
672
|
+
this.pageCount = 0;
|
|
673
|
+
this.page = 1;
|
|
674
|
+
this.sync();
|
|
675
|
+
this.emit();
|
|
676
|
+
try {
|
|
677
|
+
await this.pendingDestroy;
|
|
678
|
+
if (superseded())
|
|
679
|
+
return;
|
|
680
|
+
const runtime = await loadPdfRuntime();
|
|
681
|
+
if (superseded())
|
|
682
|
+
return;
|
|
683
|
+
// Check after the import and after every wait: another host may
|
|
684
|
+
// begin teardown while either await is parked.
|
|
685
|
+
while (pendingTaskDestructions.size > 0) {
|
|
686
|
+
await Promise.all(pendingTaskDestructions);
|
|
687
|
+
if (superseded())
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
task = runtime.getDocument({
|
|
691
|
+
url: this.options.url,
|
|
692
|
+
disableAutoFetch: true,
|
|
693
|
+
disableStream: true,
|
|
694
|
+
});
|
|
695
|
+
this.loadingTask = task;
|
|
696
|
+
const loaded = await task.promise;
|
|
697
|
+
if (superseded())
|
|
698
|
+
return;
|
|
699
|
+
this.doc = loaded;
|
|
700
|
+
this.pageCount = loaded.numPages;
|
|
701
|
+
this.page = 1;
|
|
702
|
+
this.panX = 0;
|
|
703
|
+
this.panY = 0;
|
|
704
|
+
this.status = "ready";
|
|
705
|
+
this.sync();
|
|
706
|
+
await this.paint();
|
|
707
|
+
if (superseded())
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
catch (reason) {
|
|
711
|
+
if (superseded())
|
|
712
|
+
return;
|
|
713
|
+
this.fail(reason);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
fail(reason) {
|
|
717
|
+
void this.releaseDocument();
|
|
718
|
+
this.status = "error";
|
|
719
|
+
this.error = reason instanceof Error ? reason.message : String(reason);
|
|
720
|
+
this.sync();
|
|
721
|
+
this.emit();
|
|
722
|
+
}
|
|
723
|
+
measureFit(pageWidth, pageHeight) {
|
|
724
|
+
const surface = window.getComputedStyle(this.surface);
|
|
725
|
+
const canvas = window.getComputedStyle(this.canvas);
|
|
726
|
+
const px = (value) => Number.parseFloat(value) || 0;
|
|
727
|
+
this.fitOuterWidth = this.surface.offsetWidth;
|
|
728
|
+
this.fitWidth = this.availableFitWidth();
|
|
729
|
+
this.fitHeight = this.surface.clientHeight;
|
|
730
|
+
const width = this.fitWidth - px(surface.paddingLeft) - px(surface.paddingRight)
|
|
731
|
+
- px(canvas.borderLeftWidth) - px(canvas.borderRightWidth);
|
|
732
|
+
const height = this.fitHeight - px(surface.paddingTop) - px(surface.paddingBottom)
|
|
733
|
+
- px(canvas.borderTopWidth) - px(canvas.borderBottomWidth);
|
|
734
|
+
if (width < 2 || pageWidth <= 0 || pageHeight <= 0)
|
|
735
|
+
return 1;
|
|
736
|
+
if (this.fit === "page" && height >= 2) {
|
|
737
|
+
return Math.min(width / pageWidth, height / pageHeight);
|
|
738
|
+
}
|
|
739
|
+
return width / pageWidth;
|
|
740
|
+
}
|
|
741
|
+
fitSizeChanged() {
|
|
742
|
+
return this.availableFitWidth() !== this.fitWidth
|
|
743
|
+
|| (this.fit === "page" && this.surface.clientHeight !== this.fitHeight);
|
|
744
|
+
}
|
|
745
|
+
availableFitWidth() {
|
|
746
|
+
if (this.scrollbarFit && this.surface.offsetWidth !== this.scrollbarFit.outerWidth) {
|
|
747
|
+
this.scrollbarFit = null;
|
|
748
|
+
}
|
|
749
|
+
return this.scrollbarFit
|
|
750
|
+
? Math.min(this.surface.clientWidth, this.scrollbarFit.width)
|
|
751
|
+
: this.surface.clientWidth;
|
|
752
|
+
}
|
|
753
|
+
effectiveDpr() {
|
|
754
|
+
const dpr = typeof window !== "undefined" && window.devicePixelRatio ? window.devicePixelRatio : 1;
|
|
755
|
+
return Math.min(dpr, this.maxDpr);
|
|
756
|
+
}
|
|
757
|
+
/** Render the current page at the current zoom and commit it. */
|
|
758
|
+
async paint(correctFit = true) {
|
|
759
|
+
const current = this.doc;
|
|
760
|
+
if (!current || !this.alive)
|
|
761
|
+
return;
|
|
762
|
+
if (correctFit)
|
|
763
|
+
this.scrollbarFit = null;
|
|
764
|
+
this.cancelActiveRender();
|
|
765
|
+
const generation = this.renderGeneration;
|
|
766
|
+
let pdfPage;
|
|
767
|
+
try {
|
|
768
|
+
pdfPage = await current.getPage(this.page);
|
|
769
|
+
}
|
|
770
|
+
catch (reason) {
|
|
771
|
+
if (generation !== this.renderGeneration || !this.alive)
|
|
772
|
+
return;
|
|
773
|
+
this.fail(reason);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (generation !== this.renderGeneration || !this.alive) {
|
|
777
|
+
// A newer render started (page change, zoom settle, reload) while
|
|
778
|
+
// we awaited: this page is never shown, so release it.
|
|
779
|
+
this.cleanupPage(pdfPage);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const natural = pdfPage.getViewport({ scale: 1 });
|
|
783
|
+
this.fitScale = this.measureFit(natural.width, natural.height);
|
|
784
|
+
const targetZoom = this.zoom;
|
|
785
|
+
const viewport = pdfPage.getViewport({ scale: this.fitScale * targetZoom });
|
|
786
|
+
const dpr = this.effectiveDpr();
|
|
787
|
+
const rendered = document.createElement("canvas");
|
|
788
|
+
this.renderCanvas = rendered;
|
|
789
|
+
rendered.width = Math.max(1, Math.floor(viewport.width * dpr));
|
|
790
|
+
rendered.height = Math.max(1, Math.floor(viewport.height * dpr));
|
|
791
|
+
const context = rendered.getContext("2d");
|
|
792
|
+
if (!context) {
|
|
793
|
+
this.discardRenderCanvas(rendered);
|
|
794
|
+
this.cleanupPage(pdfPage);
|
|
795
|
+
this.fail(new Error("canvas 2D context unavailable"));
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const task = pdfPage.render({
|
|
799
|
+
canvasContext: context,
|
|
800
|
+
viewport,
|
|
801
|
+
transform: dpr === 1 ? null : [dpr, 0, 0, dpr, 0, 0],
|
|
802
|
+
});
|
|
803
|
+
this.renderTask = task;
|
|
804
|
+
try {
|
|
805
|
+
await task.promise;
|
|
806
|
+
}
|
|
807
|
+
catch (reason) {
|
|
808
|
+
this.discardRenderCanvas(rendered);
|
|
809
|
+
this.cleanupPage(pdfPage);
|
|
810
|
+
if (generation !== this.renderGeneration || !this.alive)
|
|
811
|
+
return;
|
|
812
|
+
this.fail(reason);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
finally {
|
|
816
|
+
if (this.renderTask === task)
|
|
817
|
+
this.renderTask = null;
|
|
818
|
+
}
|
|
819
|
+
if (generation !== this.renderGeneration || !this.alive) {
|
|
820
|
+
this.discardRenderCanvas(rendered);
|
|
821
|
+
this.cleanupPage(pdfPage);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const visible = this.canvas.getContext("2d");
|
|
825
|
+
if (!visible) {
|
|
826
|
+
this.discardRenderCanvas(rendered);
|
|
827
|
+
this.cleanupPage(pdfPage);
|
|
828
|
+
this.fail(new Error("visible canvas 2D context unavailable"));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
// ⚠ One synchronous commit: resizing the visible backing store clears
|
|
832
|
+
// it, so no await may separate that clear from the copy. The
|
|
833
|
+
// displayed page is deliberately NOT cleaned up (see header).
|
|
834
|
+
this.canvas.width = rendered.width;
|
|
835
|
+
this.canvas.height = rendered.height;
|
|
836
|
+
visible.drawImage(rendered, 0, 0);
|
|
837
|
+
this.displayedPage = pdfPage;
|
|
838
|
+
this.discardRenderCanvas(rendered);
|
|
839
|
+
this.canvas.style.width = `${Math.floor(viewport.width)}px`;
|
|
840
|
+
this.canvas.style.height = `${Math.floor(viewport.height)}px`;
|
|
841
|
+
this.rasterZoom = targetZoom;
|
|
842
|
+
this.painted = true;
|
|
843
|
+
this.applyTransform();
|
|
844
|
+
this.sync();
|
|
845
|
+
if (correctFit && this.fitSizeChanged()) {
|
|
846
|
+
const width = this.surface.clientWidth;
|
|
847
|
+
if (this.fit === "width" && this.interaction !== "gestures"
|
|
848
|
+
&& width < this.fitWidth && this.surface.offsetWidth === this.fitOuterWidth) {
|
|
849
|
+
// Keep the narrower fixed point if this correction removes the scrollbar.
|
|
850
|
+
// A genuine outer-width change or a new explicit paint releases the hold.
|
|
851
|
+
this.scrollbarFit = { outerWidth: this.fitOuterWidth, width };
|
|
852
|
+
}
|
|
853
|
+
await this.paint(false);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
this.emit();
|
|
857
|
+
}
|
|
858
|
+
// ---- DOM sync ---------------------------------------------------------
|
|
859
|
+
discardRenderCanvas(canvas) {
|
|
860
|
+
canvas.width = 0;
|
|
861
|
+
canvas.height = 0;
|
|
862
|
+
if (this.renderCanvas === canvas)
|
|
863
|
+
this.renderCanvas = null;
|
|
864
|
+
}
|
|
865
|
+
cleanupPage(page) {
|
|
866
|
+
if (page !== this.displayedPage)
|
|
867
|
+
page.cleanup();
|
|
868
|
+
}
|
|
869
|
+
applyTransform() {
|
|
870
|
+
if (this.interaction === "none")
|
|
871
|
+
return;
|
|
872
|
+
const scale = this.zoom / this.rasterZoom;
|
|
873
|
+
this.canvas.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${scale})`;
|
|
874
|
+
const adjusted = this.panX !== 0 || this.panY !== 0 || Math.abs(this.zoom - PDF_VIEWER_DEFAULT_ZOOM) >= 1e-9;
|
|
875
|
+
// In scroll mode the toolbar's reset covers zoom and there is no pan.
|
|
876
|
+
this.controls.reset.hidden = this.interaction !== "gestures" || !adjusted;
|
|
877
|
+
}
|
|
878
|
+
sync() {
|
|
879
|
+
const { controls } = this;
|
|
880
|
+
const busy = this.status !== "ready";
|
|
881
|
+
this.root.setAttribute("data-status", this.status);
|
|
882
|
+
controls.page.textContent = this.pageCount ? `${this.page} / ${this.pageCount}` : "…";
|
|
883
|
+
const zoomLabel = `${Math.round(this.settleFrom * 100)}%`;
|
|
884
|
+
if (controls.zoom.textContent !== zoomLabel)
|
|
885
|
+
controls.zoom.textContent = zoomLabel;
|
|
886
|
+
controls.previous.disabled = busy || this.page <= 1;
|
|
887
|
+
controls.next.disabled = busy || this.page >= this.pageCount;
|
|
888
|
+
controls.zoomOut.disabled = busy || this.zoom <= this.bounds.min + 1e-9;
|
|
889
|
+
controls.zoomIn.disabled = busy || this.zoom >= this.bounds.max - 1e-9;
|
|
890
|
+
controls.zoomReset.disabled = busy;
|
|
891
|
+
// Hidden until a raster exists: an unpainted canvas is a blank box
|
|
892
|
+
// that reads as "this document is empty".
|
|
893
|
+
this.canvas.hidden = busy || !this.painted;
|
|
894
|
+
if (this.painted)
|
|
895
|
+
this.root.setAttribute("data-painted", "1");
|
|
896
|
+
else
|
|
897
|
+
this.root.removeAttribute("data-painted");
|
|
898
|
+
if (busy)
|
|
899
|
+
controls.reset.hidden = true;
|
|
900
|
+
this.statusLine.textContent = "";
|
|
901
|
+
if (this.status === "error") {
|
|
902
|
+
const message = el("span", "pdf-viewer-error");
|
|
903
|
+
message.textContent = this.labels.failed.replace("%{reason}", () => this.error ?? "");
|
|
904
|
+
const retry = makeButton("pdf-viewer-retry", this.labels.retry, this.labels.retry);
|
|
905
|
+
this.statusLine.append(message, retry);
|
|
906
|
+
this.statusLine.setAttribute("data-state", "error");
|
|
907
|
+
}
|
|
908
|
+
else if (this.status === "loading") {
|
|
909
|
+
this.statusLine.textContent = this.labels.loading;
|
|
910
|
+
this.statusLine.setAttribute("data-state", "loading");
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
this.statusLine.removeAttribute("data-state");
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// ---- gestures ---------------------------------------------------------
|
|
917
|
+
pinchDistance() {
|
|
918
|
+
const [a, b] = [...this.activePointers.values()];
|
|
919
|
+
if (!a || !b)
|
|
920
|
+
return 0;
|
|
921
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
922
|
+
}
|
|
923
|
+
settle() {
|
|
924
|
+
if (this.zoom === this.settleFrom)
|
|
925
|
+
return;
|
|
926
|
+
this.settleFrom = this.zoom;
|
|
927
|
+
this.sync();
|
|
928
|
+
void this.paint();
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// ---- DOM construction -----------------------------------------------------
|
|
932
|
+
function el(tag, cls) {
|
|
933
|
+
const node = document.createElement(tag);
|
|
934
|
+
node.className = cls;
|
|
935
|
+
return node;
|
|
936
|
+
}
|
|
937
|
+
function makeButton(cls, label, text) {
|
|
938
|
+
const button = el("button", cls);
|
|
939
|
+
button.type = "button";
|
|
940
|
+
button.setAttribute("aria-label", label);
|
|
941
|
+
button.title = label;
|
|
942
|
+
button.textContent = text;
|
|
943
|
+
return button;
|
|
944
|
+
}
|
|
945
|
+
function buildDom(labels, interaction) {
|
|
946
|
+
const root = el("div", PDF_VIEWER_CLASS);
|
|
947
|
+
const toolbar = el("div", "pdf-viewer-toolbar");
|
|
948
|
+
toolbar.setAttribute("role", "toolbar");
|
|
949
|
+
toolbar.setAttribute("aria-label", labels.controls);
|
|
950
|
+
const previous = makeButton("pdf-viewer-previous", labels.previous, "\u2039");
|
|
951
|
+
const page = el("span", "pdf-viewer-page");
|
|
952
|
+
page.setAttribute("aria-live", "polite");
|
|
953
|
+
const next = makeButton("pdf-viewer-next", labels.next, "\u203A");
|
|
954
|
+
const zoomOut = makeButton("pdf-viewer-zoom-out", labels.zoomOut, "\u2212");
|
|
955
|
+
const zoom = el("span", "pdf-viewer-zoom");
|
|
956
|
+
zoom.setAttribute("aria-live", "polite");
|
|
957
|
+
const zoomReset = makeButton("pdf-viewer-zoom-reset", labels.zoomReset, "\u21BA");
|
|
958
|
+
const zoomIn = makeButton("pdf-viewer-zoom-in", labels.zoomIn, "+");
|
|
959
|
+
const pages = el("span", "pdf-viewer-group");
|
|
960
|
+
pages.append(previous, page, next);
|
|
961
|
+
const zooms = el("span", "pdf-viewer-group");
|
|
962
|
+
zooms.append(zoomOut, zoom, zoomReset, zoomIn);
|
|
963
|
+
toolbar.append(pages, zooms);
|
|
964
|
+
const surface = el("div", "pdf-viewer-surface");
|
|
965
|
+
if (interaction === "gestures")
|
|
966
|
+
surface.classList.add("pdf-viewer-surface--gestures");
|
|
967
|
+
if (interaction === "scroll")
|
|
968
|
+
surface.classList.add("pdf-viewer-surface--scroll");
|
|
969
|
+
const canvas = el("canvas", "pdf-viewer-canvas");
|
|
970
|
+
const reset = makeButton("pdf-viewer-reset", labels.reset, "");
|
|
971
|
+
reset.hidden = true;
|
|
972
|
+
reset.innerHTML =
|
|
973
|
+
'<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">' +
|
|
974
|
+
'<path d="M2 5.5V2.5h3M14 5.5V2.5h-3M2 10.5v3h3M14 10.5v3h-3" fill="none" ' +
|
|
975
|
+
'stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
976
|
+
reset.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
977
|
+
reset.addEventListener("dblclick", (event) => event.stopPropagation());
|
|
978
|
+
surface.append(canvas, reset);
|
|
979
|
+
const status = el("div", "pdf-viewer-status");
|
|
980
|
+
status.setAttribute("role", "status");
|
|
981
|
+
status.setAttribute("aria-live", "polite");
|
|
982
|
+
root.append(toolbar, surface, status);
|
|
983
|
+
return {
|
|
984
|
+
root,
|
|
985
|
+
surface,
|
|
986
|
+
canvas,
|
|
987
|
+
status,
|
|
988
|
+
controls: { previous, next, zoomOut, zoomIn, zoomReset, page, zoom, reset },
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
// ---- entry point ----------------------------------------------------------
|
|
992
|
+
const viewerOf = new WeakMap();
|
|
993
|
+
/**
|
|
994
|
+
* Mount a viewer into `host` and start loading `options.url`.
|
|
995
|
+
*
|
|
996
|
+
* Mounting twice on one host replaces the previous viewer. On any host,
|
|
997
|
+
* worker startup waits for every pending loading-task teardown in this
|
|
998
|
+
* module to settle, including rejected teardowns. Hosts need not await
|
|
999
|
+
* an old owner's destroy() before mounting on a new element. The returned
|
|
1000
|
+
* owner acts only on the viewer it mounted. A failed teardown is logged
|
|
1001
|
+
* and allows startup, but may leave its worker running. A slow or never
|
|
1002
|
+
* settling teardown delays or blocks startup across all hosts.
|
|
1003
|
+
*/
|
|
1004
|
+
export function mountPdfViewer(host, options) {
|
|
1005
|
+
const previous = viewerOf.get(host);
|
|
1006
|
+
const pendingDestroy = previous?.destroy();
|
|
1007
|
+
const viewer = new Viewer(host, options, pendingDestroy);
|
|
1008
|
+
viewerOf.set(host, viewer);
|
|
1009
|
+
const owner = {
|
|
1010
|
+
get host() { return host; },
|
|
1011
|
+
state: () => viewer.state(),
|
|
1012
|
+
goTo: (page) => viewer.goTo(page),
|
|
1013
|
+
next: () => viewer.next(),
|
|
1014
|
+
previous: () => viewer.previous(),
|
|
1015
|
+
setZoom: (zoom) => viewer.setZoom(zoom),
|
|
1016
|
+
resetView: () => viewer.resetView(),
|
|
1017
|
+
reload: () => viewer.reload(),
|
|
1018
|
+
destroy: () => viewer.destroy(),
|
|
1019
|
+
};
|
|
1020
|
+
// Preserve the host accessor without storing enumerable object state.
|
|
1021
|
+
Object.defineProperty(owner, "host", { enumerable: false });
|
|
1022
|
+
return Object.freeze(owner);
|
|
1023
|
+
}
|