genus-pdf-viewer 0.1.7 → 0.1.9

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.
@@ -14,6 +14,9 @@ class GenusPdfViewerComponent {
14
14
  disableTextLayer = true;
15
15
  continuous = true;
16
16
  showToolbar = true;
17
+ zoomAnimation = true;
18
+ zoomAnimationMs = 180;
19
+ gestureAnimationMs = 120;
17
20
  canvas;
18
21
  singleStage;
19
22
  stage;
@@ -27,46 +30,64 @@ class GenusPdfViewerComponent {
27
30
  error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : []));
28
31
  PRINT_SCALE = 2;
29
32
  scrollRaf = 0;
30
- pinchRaf = 0;
31
- pinchActive = false;
32
- pinchStartDistance = 0;
33
- pinchStartZoom = 1;
34
- pinchAnchorClient = null;
35
- activePointers = new Map();
36
33
  renderLockCount = 0;
34
+ zoomAnimInFlight = false;
35
+ activeRenderTasks = new Set();
36
+ wheelRaf = 0;
37
+ pinchState = null;
38
+ activePointers = new Map();
39
+ renderVersion = 0;
40
+ previewActive = false;
41
+ reRenderQueued = false;
42
+ pendingAnchor;
43
+ lastZoomClient = null;
44
+ WHEEL_STEP = 0.02;
45
+ PINCH_SENSITIVITY = 0.5;
46
+ wheelCommitTimer = 0;
37
47
  constructor() {
38
48
  effect(() => {
39
- // Track dependencies explicitly
40
- this.pageSig();
49
+ // Only react to zoom changes here; page changes in continuous mode should NOT trigger a re-render
41
50
  this.zoomSig();
42
- if (this.renderLockCount > 0)
51
+ if (this.renderLockCount > 0 || this.previewActive)
43
52
  return;
44
53
  this.safeRender();
45
54
  });
46
55
  }
47
- async ngOnChanges() {
48
- this.pageSig.set(this.page);
49
- this.zoomSig.set(this.zoom);
56
+ async ngOnChanges(changes) {
57
+ if (changes['page'])
58
+ this.pageSig.set(this.page);
59
+ if (changes['zoom'])
60
+ this.zoomSig.set(this.zoom);
61
+ const srcChanged = !!changes['src'];
62
+ const layoutChanged = !!(changes['fit'] || changes['minZoom'] || changes['maxZoom']);
50
63
  if (!this.src)
51
64
  return;
52
- this.loading.set(true);
53
- this.error.set(null);
54
- try {
55
- const task = pdfjsLib.getDocument(this.normalizePdfSource(this.src));
56
- this.doc = await task.promise;
65
+ if (srcChanged) {
66
+ await this.loadDocument();
67
+ if (this.doc) {
68
+ const pages = this.doc.numPages;
69
+ if (this.pageSig() < 1)
70
+ this.pageSig.set(1);
71
+ if (this.pageSig() > pages)
72
+ this.pageSig.set(pages);
73
+ await this.render();
74
+ }
75
+ return;
57
76
  }
58
- catch (e) {
59
- this.error.set('Failed to load PDF');
60
- this.loading.set(false);
77
+ if (layoutChanged) {
78
+ if (this.continuous) {
79
+ const container = this.stage?.nativeElement;
80
+ const anchor = container ? this.captureScrollAnchor(container) || undefined : undefined;
81
+ await this.renderAllPages(anchor);
82
+ }
83
+ else {
84
+ await this.render();
85
+ }
61
86
  return;
62
87
  }
63
- const pages = this.doc.numPages;
64
- if (this.pageSig() < 1)
65
- this.pageSig.set(1);
66
- if (this.pageSig() > pages)
67
- this.pageSig.set(pages);
68
- await this.render();
69
- this.loading.set(false);
88
+ if (!this.continuous && changes['page']) {
89
+ await this.render();
90
+ }
70
91
  }
71
92
  async safeRender() {
72
93
  try {
@@ -74,9 +95,48 @@ class GenusPdfViewerComponent {
74
95
  }
75
96
  catch (_) { }
76
97
  }
98
+ async loadDocument() {
99
+ this.loading.set(true);
100
+ this.error.set(null);
101
+ try {
102
+ const task = pdfjsLib.getDocument(this.normalizePdfSource(this.src));
103
+ this.doc = await task.promise;
104
+ }
105
+ catch (e) {
106
+ // Fallback for environments (notably some iOS builds) where range/streaming fetch fails.
107
+ // Try fetching the whole file via fetch() and pass ArrayBuffer to pdf.js.
108
+ try {
109
+ const href = typeof this.src === 'string' ? this.src : (this.src instanceof URL ? this.src.toString() : '');
110
+ if (href && typeof fetch !== 'undefined') {
111
+ const res = await fetch(href, { mode: 'cors' });
112
+ if (res.ok) {
113
+ const ab = await res.arrayBuffer();
114
+ const data = new Uint8Array(ab);
115
+ const task2 = pdfjsLib.getDocument({ data });
116
+ this.doc = await task2.promise;
117
+ }
118
+ else {
119
+ this.doc = undefined;
120
+ }
121
+ }
122
+ else {
123
+ this.doc = undefined;
124
+ }
125
+ }
126
+ catch {
127
+ this.doc = undefined;
128
+ }
129
+ if (!this.doc)
130
+ this.error.set('Failed to load PDF');
131
+ }
132
+ finally {
133
+ this.loading.set(false);
134
+ }
135
+ }
77
136
  async render() {
78
137
  if (!this.doc)
79
138
  return;
139
+ const version = ++this.renderVersion;
80
140
  if (this.continuous) {
81
141
  await this.renderAllPages();
82
142
  return;
@@ -90,6 +150,7 @@ class GenusPdfViewerComponent {
90
150
  const ctx = canvas.getContext('2d');
91
151
  const baseViewport = pdfPage.getViewport({ scale: 1 });
92
152
  const parent = canvas.parentElement;
153
+ const anchor = this.captureScrollAnchor(parent) || undefined;
93
154
  const pW = parent.clientWidth, pH = parent.clientHeight || baseViewport.height;
94
155
  let baseScale = 1;
95
156
  if (this.fit === 'width')
@@ -111,7 +172,12 @@ class GenusPdfViewerComponent {
111
172
  const renderCtx = { canvasContext: ctx, viewport };
112
173
  if (dpr !== 1)
113
174
  renderCtx.transform = [dpr, 0, 0, dpr, 0, 0];
114
- await pdfPage.render(renderCtx).promise;
175
+ const task = pdfPage.render(renderCtx);
176
+ await task.promise;
177
+ if (this.renderVersion !== version)
178
+ return;
179
+ if (anchor)
180
+ this.restoreScrollAnchor(parent, anchor);
115
181
  }
116
182
  async renderAllPages(anchorOverride) {
117
183
  if (!this.doc)
@@ -119,63 +185,104 @@ class GenusPdfViewerComponent {
119
185
  const stageEl = this.stage?.nativeElement;
120
186
  if (!stageEl)
121
187
  return;
188
+ const version = ++this.renderVersion;
122
189
  const anchor = anchorOverride ?? this.captureScrollAnchor(stageEl);
123
- const placeholderMinHeight = stageEl.scrollHeight;
124
- if (placeholderMinHeight > 0)
125
- stageEl.style.minHeight = placeholderMinHeight + 'px';
126
- const fragment = document.createDocumentFragment();
127
190
  const total = this.doc.numPages;
128
191
  const parentRect = stageEl.getBoundingClientRect();
129
192
  const parentWidth = parentRect.width || stageEl.clientWidth || 800;
130
193
  let computedCanPan = false;
131
- for (let i = 1; i <= total; i++) {
132
- const page = await this.doc.getPage(i);
133
- const baseViewport = page.getViewport({ scale: 1 });
134
- let baseScale = 1;
135
- if (this.fit === 'width')
136
- baseScale = (parentWidth - 16) / baseViewport.width;
137
- if (this.fit === 'page')
138
- baseScale = (parentWidth - 16) / baseViewport.width; // simple width-fit for page mode in continuous
139
- const minScale = baseScale * this.minZoom;
140
- const maxScale = baseScale * this.maxZoom;
141
- const finalScale = Math.max(minScale, Math.min(maxScale, baseScale * this.zoomSig()));
142
- if (i === 1)
143
- computedCanPan = finalScale > baseScale + 0.001;
144
- const viewport = page.getViewport({ scale: finalScale });
145
- const wrapper = document.createElement('div');
146
- wrapper.className = 'pdf-page';
147
- wrapper.setAttribute('data-page', String(i));
148
- wrapper.style.width = Math.floor(viewport.width) + 'px';
149
- wrapper.style.margin = '0 auto 12px auto';
150
- const canvas = document.createElement('canvas');
151
- const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) ? window.devicePixelRatio : 1;
152
- canvas.style.width = Math.floor(viewport.width) + 'px';
153
- canvas.style.height = Math.floor(viewport.height) + 'px';
154
- canvas.width = Math.floor(viewport.width * dpr);
155
- canvas.height = Math.floor(viewport.height * dpr);
156
- const ctx = canvas.getContext('2d');
157
- wrapper.appendChild(canvas);
158
- fragment.appendChild(wrapper);
159
- const renderCtx = { canvasContext: ctx, viewport };
160
- if (dpr !== 1)
161
- renderCtx.transform = [dpr, 0, 0, dpr, 0, 0];
162
- await page.render(renderCtx).promise;
194
+ const existing = Array.from(stageEl.querySelectorAll('.pdf-page'));
195
+ const isInitialBuild = existing.length !== total;
196
+ this.renderLockCount++;
197
+ if (isInitialBuild) {
198
+ stageEl.innerHTML = '';
199
+ const contentEl = document.createElement('div');
200
+ contentEl.className = 'pdf-content';
201
+ stageEl.appendChild(contentEl);
202
+ for (let i = 1; i <= total; i++) {
203
+ const page = await this.doc.getPage(i);
204
+ const baseViewport = page.getViewport({ scale: 1 });
205
+ let baseScale = 1;
206
+ if (this.fit === 'width')
207
+ baseScale = (parentWidth - 16) / baseViewport.width;
208
+ if (this.fit === 'page')
209
+ baseScale = (parentWidth - 16) / baseViewport.width;
210
+ const minScale = baseScale * this.minZoom;
211
+ const maxScale = baseScale * this.maxZoom;
212
+ const finalScale = Math.max(minScale, Math.min(maxScale, baseScale * this.zoomSig()));
213
+ if (i === 1)
214
+ computedCanPan = finalScale > baseScale + 0.001;
215
+ const viewport = page.getViewport({ scale: finalScale });
216
+ const wrapper = document.createElement('div');
217
+ wrapper.className = 'pdf-page';
218
+ wrapper.setAttribute('data-page', String(i));
219
+ wrapper.style.width = Math.floor(viewport.width) + 'px';
220
+ wrapper.style.height = Math.floor(viewport.height) + 'px';
221
+ wrapper.style.margin = '0 auto 12px auto';
222
+ const canvas = document.createElement('canvas');
223
+ const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) ? window.devicePixelRatio : 1;
224
+ canvas.style.width = Math.floor(viewport.width) + 'px';
225
+ canvas.style.height = Math.floor(viewport.height) + 'px';
226
+ canvas.width = Math.floor(viewport.width * dpr);
227
+ canvas.height = Math.floor(viewport.height * dpr);
228
+ const ctx = canvas.getContext('2d');
229
+ wrapper.appendChild(canvas);
230
+ contentEl.appendChild(wrapper);
231
+ const renderCtx = { canvasContext: ctx, viewport };
232
+ if (dpr !== 1)
233
+ renderCtx.transform = [dpr, 0, 0, dpr, 0, 0];
234
+ const task = page.render(renderCtx);
235
+ await task.promise;
236
+ if (this.renderVersion !== version)
237
+ return;
238
+ }
239
+ }
240
+ else {
241
+ let contentEl = stageEl.querySelector('.pdf-content');
242
+ if (!contentEl) {
243
+ contentEl = document.createElement('div');
244
+ contentEl.className = 'pdf-content';
245
+ const pages = Array.from(stageEl.querySelectorAll('.pdf-page'));
246
+ for (const el of pages)
247
+ contentEl.appendChild(el);
248
+ stageEl.appendChild(contentEl);
249
+ }
250
+ for (let i = 1; i <= total; i++) {
251
+ const page = await this.doc.getPage(i);
252
+ const baseViewport = page.getViewport({ scale: 1 });
253
+ let baseScale = 1;
254
+ if (this.fit === 'width')
255
+ baseScale = (parentWidth - 16) / baseViewport.width;
256
+ if (this.fit === 'page')
257
+ baseScale = (parentWidth - 16) / baseViewport.width;
258
+ const minScale = baseScale * this.minZoom;
259
+ const maxScale = baseScale * this.maxZoom;
260
+ const finalScale = Math.max(minScale, Math.min(maxScale, baseScale * this.zoomSig()));
261
+ if (i === 1)
262
+ computedCanPan = finalScale > baseScale + 0.001;
263
+ const viewport = page.getViewport({ scale: finalScale });
264
+ const wrapper = existing[i - 1];
265
+ const canvas = wrapper.querySelector('canvas');
266
+ wrapper.style.width = Math.floor(viewport.width) + 'px';
267
+ wrapper.style.height = Math.floor(viewport.height) + 'px';
268
+ const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) ? window.devicePixelRatio : 1;
269
+ canvas.style.width = Math.floor(viewport.width) + 'px';
270
+ canvas.style.height = Math.floor(viewport.height) + 'px';
271
+ canvas.width = Math.floor(viewport.width * dpr);
272
+ canvas.height = Math.floor(viewport.height * dpr);
273
+ const ctx = canvas.getContext('2d');
274
+ const renderCtx = { canvasContext: ctx, viewport };
275
+ if (dpr !== 1)
276
+ renderCtx.transform = [dpr, 0, 0, dpr, 0, 0];
277
+ const task = page.render(renderCtx);
278
+ await task.promise;
279
+ if (this.renderVersion !== version)
280
+ return;
281
+ }
163
282
  }
164
283
  this.canPan.set(computedCanPan);
165
- const prevBehavior = stageEl.style.scrollBehavior;
166
- const prevOverflow = stageEl.style.overflow;
167
- const prevVisibility = stageEl.style.visibility;
168
- this.renderLockCount++;
169
- stageEl.style.scrollBehavior = 'auto';
170
- stageEl.style.overflow = 'hidden';
171
- stageEl.style.visibility = 'hidden';
172
- stageEl.replaceChildren(fragment);
173
284
  if (anchor)
174
285
  this.restoreScrollAnchor(stageEl, anchor);
175
- stageEl.style.scrollBehavior = prevBehavior;
176
- stageEl.style.overflow = prevOverflow;
177
- stageEl.style.visibility = prevVisibility;
178
- stageEl.style.minHeight = '';
179
286
  this.renderLockCount = Math.max(0, this.renderLockCount - 1);
180
287
  }
181
288
  async goTo(page) {
@@ -192,27 +299,108 @@ class GenusPdfViewerComponent {
192
299
  await this.render();
193
300
  }
194
301
  }
195
- async zoomIn(step = 0.1) {
196
- this.zoomSig.set(Math.min(this.maxZoom, this.zoomSig() + step));
302
+ async zoomIn(step = 0.1) { await this.applyZoomDelta(step); }
303
+ async zoomOut(step = 0.1) { await this.applyZoomDelta(-step); }
304
+ clampZoom(z) {
305
+ return Math.max(this.minZoom, Math.min(this.maxZoom, z));
306
+ }
307
+ cancelPreviewIfAny(container) {
308
+ if (!this.previewActive || !container)
309
+ return;
310
+ const target = this.continuous ? (container.querySelector('.pdf-content') || container) : (container.querySelector('canvas') || container);
311
+ try {
312
+ target.style.transform = '';
313
+ target.style.willChange = '';
314
+ target.style.transition = '';
315
+ }
316
+ catch { }
317
+ this.previewActive = false;
318
+ this.lastZoomClient = null;
319
+ }
320
+ async applyZoomDelta(delta) {
321
+ const oldZoom = this.zoomSig();
322
+ const newZoom = this.clampZoom(oldZoom + delta);
323
+ if (Math.abs(newZoom - oldZoom) < 0.0001)
324
+ return;
197
325
  const container = this.continuous ? this.stage?.nativeElement : this.singleStage?.nativeElement;
198
- if (container) {
199
- const anchor = this.captureScrollAnchor(container) || undefined;
200
- await this.renderAllPages(anchor);
326
+ // If a preview transform is active (from pinch/wheel), clear it before capturing anchor or animating
327
+ this.cancelPreviewIfAny(container);
328
+ const anchor = container ? this.captureScrollAnchor(container) || undefined : undefined;
329
+ const ratio = newZoom / oldZoom;
330
+ if (this.zoomAnimation && container && !this.zoomAnimInFlight) {
331
+ this.zoomAnimInFlight = true;
332
+ this.renderLockCount++;
333
+ // Update UI value immediately
334
+ this.zoomSig.set(newZoom);
335
+ try {
336
+ await this.runZoomAnimation(container, ratio);
337
+ }
338
+ finally {
339
+ // Release the render lock before triggering the actual re-render
340
+ this.renderLockCount = Math.max(0, this.renderLockCount - 1);
341
+ await this.safeReRenderWithCancellation(anchor);
342
+ this.zoomAnimInFlight = false;
343
+ }
201
344
  }
202
345
  else {
203
- await this.render();
346
+ this.zoomSig.set(newZoom);
347
+ await this.safeReRenderWithCancellation(anchor);
204
348
  }
205
349
  }
206
- async zoomOut(step = 0.1) {
207
- this.zoomSig.set(Math.max(this.minZoom, this.zoomSig() - step));
208
- const container = this.continuous ? this.stage?.nativeElement : this.singleStage?.nativeElement;
209
- if (container) {
210
- const anchor = this.captureScrollAnchor(container) || undefined;
211
- await this.renderAllPages(anchor);
350
+ async runZoomAnimation(container, ratio) {
351
+ if (Math.abs(ratio - 1) < 0.0001)
352
+ return;
353
+ const { target, originX, originY } = this.getZoomAnimTarget(container);
354
+ return new Promise((resolve) => {
355
+ try {
356
+ const onDone = () => {
357
+ cleanup();
358
+ resolve();
359
+ };
360
+ const cleanup = () => {
361
+ target.removeEventListener('transitionend', onEnd);
362
+ target.style.transition = '';
363
+ target.style.transform = '';
364
+ target.style.transformOrigin = '';
365
+ target.style.willChange = '';
366
+ };
367
+ const onEnd = () => onDone();
368
+ target.style.willChange = 'transform';
369
+ target.style.transformOrigin = `${Math.max(0, originX)}px ${Math.max(0, originY)}px`;
370
+ target.style.transition = 'none';
371
+ target.style.transform = 'scale(1)';
372
+ requestAnimationFrame(() => {
373
+ target.addEventListener('transitionend', onEnd, { once: true });
374
+ target.style.transition = `transform ${this.zoomAnimationMs}ms cubic-bezier(0.2,0,0,1)`;
375
+ target.style.transform = `scale(${ratio})`;
376
+ setTimeout(onDone, this.zoomAnimationMs + 60);
377
+ });
378
+ }
379
+ catch {
380
+ resolve();
381
+ }
382
+ });
383
+ }
384
+ getZoomAnimTarget(container) {
385
+ const isContinuous = this.continuous;
386
+ let target = null;
387
+ if (isContinuous) {
388
+ target = container.querySelector('.pdf-content');
389
+ if (!target)
390
+ target = container; // fallback
212
391
  }
213
392
  else {
214
- await this.render();
393
+ target = container.querySelector('canvas');
394
+ if (!target)
395
+ target = container; // fallback
215
396
  }
397
+ const rectLeft = target.offsetLeft;
398
+ const rectTop = target.offsetTop;
399
+ // Prefer last gesture point (pinch center or wheel pointer) if available
400
+ const client = this.lastZoomClient;
401
+ const originX = client ? (container.scrollLeft + client.x - rectLeft) : (container.scrollLeft + container.clientWidth / 2 - rectLeft);
402
+ const originY = client ? (container.scrollTop + client.y - rectTop) : (container.scrollTop + container.clientHeight / 2 - rectTop);
403
+ return { target, originX, originY };
216
404
  }
217
405
  onScroll(evt) {
218
406
  const container = evt.target;
@@ -242,61 +430,80 @@ class GenusPdfViewerComponent {
242
430
  }
243
431
  async nextPage() { await this.goTo(this.pageSig() + 1); }
244
432
  async prevPage() { await this.goTo(this.pageSig() - 1); }
433
+ isZoomed() {
434
+ return Math.abs(this.zoomSig() - 1) > 0.0001;
435
+ }
436
+ async resetZoom() {
437
+ // Reset to exactly 1.0 (100%)
438
+ const delta = 1 - this.zoomSig();
439
+ await this.applyZoomDelta(delta);
440
+ }
441
+ async retryLoad() {
442
+ await this.loadDocument();
443
+ if (this.doc) {
444
+ const pages = this.doc.numPages;
445
+ if (this.pageSig() < 1)
446
+ this.pageSig.set(1);
447
+ if (this.pageSig() > pages)
448
+ this.pageSig.set(pages);
449
+ await this.render();
450
+ }
451
+ }
245
452
  onPointerDown(evt, containerEl) {
246
- // Track pointers for pinch and pan
247
- this.activePointers.set(evt.pointerId, { x: evt.clientX, y: evt.clientY });
248
- // If two pointers start pinch gesture
249
- if (this.activePointers.size === 2) {
250
- const pts = Array.from(this.activePointers.values());
251
- this.pinchStartDistance = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
252
- this.pinchStartZoom = this.zoomSig();
253
- this.pinchAnchorClient = {
254
- x: (pts[0].x + pts[1].x) / 2,
255
- y: (pts[0].y + pts[1].y) / 2,
256
- };
257
- this.pinchActive = true;
258
- containerEl.style.touchAction = 'none';
259
- containerEl.setPointerCapture?.(evt.pointerId);
260
- const onMove = (e) => {
261
- if (!this.pinchActive)
453
+ // Pinch-to-zoom (two pointers)
454
+ if (evt.pointerType === 'touch') {
455
+ this.activePointers.set(evt.pointerId, { x: evt.clientX, y: evt.clientY, type: evt.pointerType });
456
+ const onPointerMove = (e) => {
457
+ if (e.pointerType === 'touch')
458
+ this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY, type: e.pointerType });
459
+ const touches = Array.from(this.activePointers.entries()).map(([id, p]) => ({ pointerId: id, clientX: p.x, clientY: p.y }))
460
+ .slice(0, 2);
461
+ if (touches.length === 2) {
462
+ const [a, b] = touches;
463
+ const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
464
+ if (!this.pinchState) {
465
+ this.pinchState = { id1: a.pointerId, id2: b.pointerId, startDist: Math.max(1, dist), startZoom: this.zoomSig() };
466
+ const rect = containerEl.getBoundingClientRect();
467
+ this.lastZoomClient = { x: ((a.clientX + b.clientX) / 2) - rect.left, y: ((a.clientY + b.clientY) / 2) - rect.top };
468
+ }
469
+ const scale = dist / Math.max(1, this.pinchState.startDist);
470
+ const adjusted = 1 + (scale - 1) * this.PINCH_SENSITIVITY;
471
+ const desiredAdj = this.clampZoom(this.pinchState.startZoom * adjusted);
472
+ this.previewZoom(containerEl, desiredAdj);
473
+ e.preventDefault();
262
474
  return;
263
- const p = this.activePointers.get(e.pointerId);
264
- if (p) {
265
- p.x = e.clientX;
266
- p.y = e.clientY;
267
475
  }
268
- if (this.activePointers.size < 2)
269
- return;
270
- const all = Array.from(this.activePointers.values());
271
- const dist = Math.hypot(all[1].x - all[0].x, all[1].y - all[0].y);
272
- const scale = dist / Math.max(1, this.pinchStartDistance);
273
- const targetZoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.pinchStartZoom * scale));
274
- if (!this.pinchRaf) {
275
- this.pinchRaf = requestAnimationFrame(() => {
276
- this.pinchRaf = 0;
277
- this.zoomSig.set(targetZoom);
278
- this.renderWithAnchor(containerEl, this.pinchAnchorClient);
279
- });
476
+ // When pinch ends (not exactly two touches), commit render if we were previewing
477
+ if (this.pinchState) {
478
+ const final = this.zoomSig();
479
+ this.commitPreviewZoom(containerEl, final);
480
+ this.pinchState = null;
481
+ this.lastZoomClient = null;
280
482
  }
281
- e.preventDefault();
282
483
  };
283
- const onEnd = (e) => {
484
+ const onPointerEnd = (e) => {
284
485
  this.activePointers.delete(e.pointerId);
285
- if (this.activePointers.size <= 1) {
286
- this.pinchActive = false;
287
- containerEl.releasePointerCapture?.(evt.pointerId);
288
- containerEl.removeEventListener('pointermove', onMove);
289
- containerEl.removeEventListener('pointerup', onEnd);
290
- containerEl.removeEventListener('pointercancel', onEnd);
291
- containerEl.style.touchAction = '';
486
+ const touches = Array.from(this.activePointers.values());
487
+ if (touches.length !== 2 && this.pinchState) {
488
+ const final = this.zoomSig();
489
+ this.commitPreviewZoom(containerEl, final);
490
+ this.pinchState = null;
491
+ this.lastZoomClient = null;
292
492
  }
493
+ cleanup();
293
494
  };
294
- containerEl.addEventListener('pointermove', onMove);
295
- containerEl.addEventListener('pointerup', onEnd);
296
- containerEl.addEventListener('pointercancel', onEnd);
495
+ const cleanup = () => {
496
+ containerEl.removeEventListener('pointermove', onPointerMove);
497
+ containerEl.removeEventListener('pointerup', onPointerEnd);
498
+ containerEl.removeEventListener('pointercancel', onPointerEnd);
499
+ this.activePointers.clear();
500
+ };
501
+ containerEl.addEventListener('pointermove', onPointerMove);
502
+ containerEl.addEventListener('pointerup', onPointerEnd);
503
+ containerEl.addEventListener('pointercancel', onPointerEnd);
297
504
  return;
298
505
  }
299
- // Single-pointer: pan when canPan
506
+ // Mouse pan when canPan
300
507
  if (!this.canPan())
301
508
  return;
302
509
  this.isPanning.set(true);
@@ -307,9 +514,6 @@ class GenusPdfViewerComponent {
307
514
  containerEl.style.scrollBehavior = 'auto';
308
515
  containerEl.setPointerCapture?.(evt.pointerId);
309
516
  const move = (e) => {
310
- if (this.pinchActive)
311
- return; // ignore while pinching
312
- this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
313
517
  const dx = e.clientX - startX;
314
518
  const dy = e.clientY - startY;
315
519
  containerEl.scrollLeft = startLeft - dx;
@@ -317,7 +521,6 @@ class GenusPdfViewerComponent {
317
521
  e.preventDefault();
318
522
  };
319
523
  const end = (e) => {
320
- this.activePointers.delete(e.pointerId);
321
524
  this.isPanning.set(false);
322
525
  containerEl.releasePointerCapture?.(evt.pointerId);
323
526
  containerEl.removeEventListener('pointermove', move);
@@ -329,32 +532,109 @@ class GenusPdfViewerComponent {
329
532
  containerEl.addEventListener('pointerup', end);
330
533
  containerEl.addEventListener('pointercancel', end);
331
534
  }
332
- async renderWithAnchor(containerEl, anchorClient) {
333
- if (this.continuous) {
334
- // For pinch, anchor around the midpoint of the gesture if available
335
- const anchor = this.captureScrollAnchor(containerEl, anchorClient);
336
- await this.renderAllPages(anchor || undefined);
535
+ getActiveTouches(containerEl) {
536
+ // We capture pointer events only on this element; maintain list by querying PointerEvent.getCoalescedEvents not available broadly.
537
+ // Instead, rely on touches reported in native events; browsers will deliver move events for all active pointers targeting the element.
538
+ // Here we cannot query directly; so we return an empty array and use move handler state. For simplicity, we derive from event stream only.
539
+ // This helper remains for future extension; current logic passes actual events to compute pinch.
540
+ return [];
541
+ }
542
+ onWheel(evt, containerEl) {
543
+ const isCtrlZoom = evt.ctrlKey || evt.metaKey;
544
+ if (!isCtrlZoom)
545
+ return;
546
+ evt.preventDefault();
547
+ const rect = containerEl.getBoundingClientRect();
548
+ // Record gesture point relative to container
549
+ this.lastZoomClient = { x: evt.clientX - rect.left, y: evt.clientY - rect.top };
550
+ if (this.wheelRaf)
551
+ cancelAnimationFrame(this.wheelRaf);
552
+ if (this.wheelCommitTimer)
553
+ clearTimeout(this.wheelCommitTimer);
554
+ const delta = -evt.deltaY;
555
+ const step = delta > 0 ? this.WHEEL_STEP : -this.WHEEL_STEP;
556
+ const desired = this.clampZoom(this.zoomSig() + step);
557
+ this.previewZoom(containerEl, desired);
558
+ this.wheelRaf = requestAnimationFrame(() => {
559
+ this.wheelCommitTimer = setTimeout(() => {
560
+ this.commitPreviewZoom(containerEl, this.zoomSig());
561
+ this.lastZoomClient = null;
562
+ }, this.gestureAnimationMs + 20);
563
+ });
564
+ }
565
+ previewZoom(container, newZoom) {
566
+ const oldZoom = this.zoomSig();
567
+ if (Math.abs(newZoom - oldZoom) < 0.0001)
568
+ return;
569
+ const ratio = newZoom / oldZoom;
570
+ const target = this.continuous ? (container.querySelector('.pdf-content') || container) : (container.querySelector('canvas') || container);
571
+ const origin = this.getZoomAnimTarget(container);
572
+ target.style.willChange = 'transform';
573
+ target.style.transformOrigin = `${Math.max(0, origin.originX)}px ${Math.max(0, origin.originY)}px`;
574
+ target.style.transition = `transform ${this.gestureAnimationMs}ms cubic-bezier(0.2,0,0,1)`;
575
+ target.style.transform = `scale(${ratio})`;
576
+ this.zoomSig.set(newZoom);
577
+ this.previewActive = true;
578
+ }
579
+ async commitPreviewZoom(container, expectedZoom) {
580
+ // Clear transform and re-render once; cancel any in-flight render tasks
581
+ const target = this.continuous ? (container.querySelector('.pdf-content') || container) : (container.querySelector('canvas') || container);
582
+ target.style.transform = '';
583
+ target.style.willChange = '';
584
+ target.style.transition = '';
585
+ const anchor = this.captureScrollAnchor(container) || undefined;
586
+ if (this.previewActive) {
587
+ await this.safeReRenderWithCancellation(anchor);
588
+ this.previewActive = false;
589
+ }
590
+ }
591
+ async safeReRenderWithCancellation(anchor) {
592
+ // If a render is in progress, queue once with the most recent anchor
593
+ if (this.renderLockCount > 0) {
594
+ this.reRenderQueued = true;
337
595
  if (anchor)
338
- this.restoreScrollAnchor(containerEl, anchor);
596
+ this.pendingAnchor = anchor;
339
597
  return;
340
598
  }
341
- await this.render();
599
+ try {
600
+ this.renderLockCount++;
601
+ if (this.continuous) {
602
+ await this.renderAllPages(anchor);
603
+ }
604
+ else {
605
+ await this.render();
606
+ }
607
+ }
608
+ finally {
609
+ this.renderLockCount = Math.max(0, this.renderLockCount - 1);
610
+ if (this.renderLockCount === 0 && this.reRenderQueued) {
611
+ const nextAnchor = this.pendingAnchor;
612
+ this.reRenderQueued = false;
613
+ this.pendingAnchor = undefined;
614
+ await this.safeReRenderWithCancellation(nextAnchor);
615
+ }
616
+ }
342
617
  }
343
618
  captureScrollAnchor(container, client) {
344
619
  try {
345
620
  const pages = Array.from(container.querySelectorAll('.pdf-page'));
346
- if (pages.length === 0)
621
+ const centerY = container.scrollTop + container.clientHeight / 2;
622
+ if (pages.length > 0) {
623
+ let pageEl = pages.find(el => (el.offsetTop <= centerY && (el.offsetTop + el.offsetHeight) >= centerY))
624
+ || pages.find(el => (el.offsetTop + el.offsetHeight) > (container.scrollTop + 1))
625
+ || pages[0];
626
+ const page = parseInt(pageEl.getAttribute('data-page') || '1', 10);
627
+ const height = Math.max(1, pageEl.offsetHeight);
628
+ const ratio = Math.max(0, Math.min(1, (centerY - pageEl.offsetTop) / height));
629
+ return { page, ratio };
630
+ }
631
+ // Fallback: single-canvas mode
632
+ const canvas = container.querySelector('canvas');
633
+ if (!canvas)
347
634
  return null;
348
- const centerY = (typeof client?.y === 'number')
349
- ? (client.y - container.getBoundingClientRect().top + container.scrollTop)
350
- : (container.scrollTop + container.clientHeight / 2);
351
- let pageEl = pages.find(el => (el.offsetTop <= centerY && (el.offsetTop + el.offsetHeight) >= centerY))
352
- || pages.find(el => (el.offsetTop + el.offsetHeight) > (container.scrollTop + 1))
353
- || pages[0];
354
- const page = parseInt(pageEl.getAttribute('data-page') || '1', 10);
355
- const height = Math.max(1, pageEl.offsetHeight);
356
- const ratio = Math.max(0, Math.min(1, (centerY - pageEl.offsetTop) / height));
357
- return { page, ratio };
635
+ const height = Math.max(1, canvas.offsetHeight);
636
+ const ratio = Math.max(0, Math.min(1, (centerY - canvas.offsetTop) / height));
637
+ return { page: 1, ratio };
358
638
  }
359
639
  catch {
360
640
  return null;
@@ -362,13 +642,17 @@ class GenusPdfViewerComponent {
362
642
  }
363
643
  restoreScrollAnchor(container, anchor) {
364
644
  try {
365
- const el = container.querySelector(`[data-page="${anchor.page}"]`);
645
+ let el = container.querySelector(`[data-page="${anchor.page}"]`);
646
+ if (!el)
647
+ el = container.querySelector('canvas');
366
648
  if (!el)
367
649
  return;
368
650
  const prevBehavior = container.style.scrollBehavior;
369
651
  container.style.scrollBehavior = 'auto';
370
652
  const height = Math.max(1, el.offsetHeight);
371
- container.scrollTop = el.offsetTop + anchor.ratio * height;
653
+ const desired = el.offsetTop + anchor.ratio * height - container.clientHeight / 2;
654
+ const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
655
+ container.scrollTop = Math.max(0, Math.min(maxScroll, desired));
372
656
  container.style.scrollBehavior = prevBehavior;
373
657
  }
374
658
  catch { }
@@ -376,31 +660,83 @@ class GenusPdfViewerComponent {
376
660
  async download() {
377
661
  const filename = this.deriveFilename();
378
662
  const link = document.createElement('a');
663
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
664
+ let popup = null;
665
+ if (isIOS) {
666
+ // Open a tab synchronously to stay within user gesture
667
+ try {
668
+ popup = window.open('', '_blank', 'noopener,noreferrer');
669
+ }
670
+ catch {
671
+ popup = null;
672
+ }
673
+ }
379
674
  const blob = await this.createBlobFromSrc();
380
675
  if (blob) {
381
676
  const url = URL.createObjectURL(blob);
382
- link.href = url;
383
- link.download = filename;
677
+ if (isIOS) {
678
+ if (popup) {
679
+ try {
680
+ popup.location.href = url;
681
+ }
682
+ catch { /* ignore */ }
683
+ // Revoke later to allow the view to load fully
684
+ setTimeout(() => URL.revokeObjectURL(url), 30000);
685
+ }
686
+ else {
687
+ const a = document.createElement('a');
688
+ a.href = url;
689
+ a.target = '_blank';
690
+ a.rel = 'noopener';
691
+ document.body.appendChild(a);
692
+ a.click();
693
+ a.remove();
694
+ setTimeout(() => URL.revokeObjectURL(url), 30000);
695
+ }
696
+ }
697
+ else {
698
+ link.href = url;
699
+ link.download = filename;
700
+ link.rel = 'noopener';
701
+ link.style.display = 'none';
702
+ document.body.appendChild(link);
703
+ link.click();
704
+ link.remove();
705
+ URL.revokeObjectURL(url);
706
+ }
707
+ return;
708
+ }
709
+ // Fallback: cross-origin without CORS
710
+ const href = typeof this.src === 'string' ? this.src : (this.src instanceof URL ? this.src.toString() : '');
711
+ if (!href)
712
+ return;
713
+ if (isIOS) {
714
+ if (popup) {
715
+ try {
716
+ popup.location.href = href;
717
+ }
718
+ catch { /* ignore */ }
719
+ }
720
+ else {
721
+ const a = document.createElement('a');
722
+ a.href = href;
723
+ a.target = '_blank';
724
+ a.rel = 'noopener';
725
+ document.body.appendChild(a);
726
+ a.click();
727
+ a.remove();
728
+ }
729
+ }
730
+ else {
731
+ link.href = href;
732
+ link.target = '_blank';
384
733
  link.rel = 'noopener';
734
+ link.download = filename; // browsers may ignore for cross-origin
385
735
  link.style.display = 'none';
386
736
  document.body.appendChild(link);
387
737
  link.click();
388
738
  link.remove();
389
- URL.revokeObjectURL(url);
390
- return;
391
739
  }
392
- // Fallback: cross-origin without CORS; open in new tab rather than current window
393
- const href = typeof this.src === 'string' ? this.src : (this.src instanceof URL ? this.src.toString() : '');
394
- if (!href)
395
- return;
396
- link.href = href;
397
- link.target = '_blank';
398
- link.rel = 'noopener';
399
- link.download = filename; // browsers may ignore for cross-origin
400
- link.style.display = 'none';
401
- document.body.appendChild(link);
402
- link.click();
403
- link.remove();
404
740
  }
405
741
  async print() {
406
742
  // Robust, plugin-free printing by rendering pages to images
@@ -549,7 +885,7 @@ class GenusPdfViewerComponent {
549
885
  return src;
550
886
  }
551
887
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: GenusPdfViewerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
552
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.1", type: GenusPdfViewerComponent, isStandalone: true, selector: "genus-pdf-viewer", inputs: { src: "src", page: "page", zoom: "zoom", maxZoom: "maxZoom", minZoom: "minZoom", fit: "fit", disableTextLayer: "disableTextLayer", continuous: "continuous", showToolbar: "showToolbar" }, viewQueries: [{ propertyName: "canvas", first: true, predicate: ["canvas"], descendants: true }, { propertyName: "singleStage", first: true, predicate: ["singleStage"], descendants: true }, { propertyName: "stage", first: true, predicate: ["stage"], descendants: true }], usesOnChanges: true, ngImport: i0, template: `
888
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.1", type: GenusPdfViewerComponent, isStandalone: true, selector: "genus-pdf-viewer", inputs: { src: "src", page: "page", zoom: "zoom", maxZoom: "maxZoom", minZoom: "minZoom", fit: "fit", disableTextLayer: "disableTextLayer", continuous: "continuous", showToolbar: "showToolbar", zoomAnimation: "zoomAnimation", zoomAnimationMs: "zoomAnimationMs", gestureAnimationMs: "gestureAnimationMs" }, viewQueries: [{ propertyName: "canvas", first: true, predicate: ["canvas"], descendants: true }, { propertyName: "singleStage", first: true, predicate: ["singleStage"], descendants: true }, { propertyName: "stage", first: true, predicate: ["stage"], descendants: true }], usesOnChanges: true, ngImport: i0, template: `
553
889
  <div class="genus-pdf-shell">
554
890
  <ng-content select="[toolbar]"></ng-content>
555
891
 
@@ -557,12 +893,13 @@ class GenusPdfViewerComponent {
557
893
  <button type="button" title="Zoom out" aria-label="Zoom out" (click)="zoomOut()">−</button>
558
894
  <span class="pct">{{ (zoomSig() * 100) | number:'1.0-0' }}%</span>
559
895
  <button type="button" title="Zoom in" aria-label="Zoom in" (click)="zoomIn()">+</button>
896
+ <button type="button" *ngIf="isZoomed()" title="Reset zoom to 100%" aria-label="Reset zoom to 100%" (click)="resetZoom()">100%</button>
560
897
  <span class="spacer"></span>
561
898
  <button type="button" title="Previous page" aria-label="Previous page" (click)="prevPage()" [disabled]="pageSig() <= 1">⟨</button>
562
899
  <span>{{ pageSig() }} / {{ doc ? doc.numPages : 0 }}</span>
563
900
  <button type="button" title="Next page" aria-label="Next page" (click)="nextPage()" [disabled]="doc ? pageSig() >= doc.numPages : true">⟩</button>
564
901
  <span class="spacer"></span>
565
- <button type="button" title="Download" aria-label="Download" (click)="download()">
902
+ <button type="button" class="download-btn" title="Download" aria-label="Download" (click)="download()">
566
903
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
567
904
  <path d="M12 3v10m0 0 4-4m-4 4-4-4M5 15v3a3 3 0 0 0 3 3h8a3 3 0 0 0 3-3v-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
568
905
  </svg>
@@ -570,15 +907,28 @@ class GenusPdfViewerComponent {
570
907
  </button>
571
908
  </div>
572
909
 
573
- <div class="genus-pdf-stage" #singleStage [style.display]="continuous ? 'none' : 'flex'" [class.can-pan]="canPan()" [class.is-panning]="isPanning()" (pointerdown)="onPointerDown($event, singleStage)" (scroll)="onScroll($event)">
910
+ <div class="genus-pdf-stage" #singleStage [style.display]="continuous ? 'none' : 'flex'" [class.can-pan]="canPan()" [class.is-panning]="isPanning()" (pointerdown)="onPointerDown($event, singleStage)" (wheel)="onWheel($event, singleStage)" (scroll)="onScroll($event)">
574
911
  <div class="genus-pdf-overlay" *ngIf="loading()"><div class="spinner" aria-label="Loading"></div></div>
575
- <div class="genus-pdf-overlay error" *ngIf="error()"><span>{{ error() }}</span></div>
912
+ <div class="genus-pdf-overlay error" *ngIf="error()">
913
+ <div style="display:flex;flex-direction:column;gap:8px;align-items:center;">
914
+ <span>{{ error() }}</span>
915
+ <button type="button" (click)="retryLoad()">Retry</button>
916
+ </div>
917
+ </div>
576
918
  <canvas #canvas></canvas>
577
919
  </div>
578
920
 
579
- <div class="genus-pdf-stage genus-pdf-stage--continuous" #stage [style.display]="continuous ? 'block' : 'none'" [class.can-pan]="canPan()" [class.is-panning]="isPanning()" (pointerdown)="onPointerDown($event, stage)" (scroll)="onScroll($event)"></div>
921
+ <div class="genus-pdf-stage genus-pdf-stage--continuous" #stage [style.display]="continuous ? 'block' : 'none'" [class.can-pan]="canPan()" [class.is-panning]="isPanning()" (pointerdown)="onPointerDown($event, stage)" (wheel)="onWheel($event, stage)" (scroll)="onScroll($event)">
922
+ <div class="genus-pdf-overlay" *ngIf="loading()"><div class="spinner" aria-label="Loading"></div></div>
923
+ <div class="genus-pdf-overlay error" *ngIf="error()">
924
+ <div style="display:flex;flex-direction:column;gap:8px;align-items:center;">
925
+ <span>{{ error() }}</span>
926
+ <button type="button" (click)="retryLoad()">Retry</button>
927
+ </div>
928
+ </div>
929
+ </div>
580
930
  </div>
581
- `, isInline: true, styles: [".genus-pdf-shell{display:flex;flex-direction:column;gap:8px;width:100%;height:100%;background:#f3f4f6}.genus-pdf-toolbar{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid #e5e7eb;border-radius:8px;background:#ffffffd9;-webkit-backdrop-filter:saturate(1.2) blur(6px);backdrop-filter:saturate(1.2) blur(6px);box-shadow:0 1px 2px #0000000f}.genus-pdf-toolbar .pct{min-width:48px;text-align:center;font-variant-numeric:tabular-nums;color:#111827}.genus-pdf-toolbar button{appearance:none;border:1px solid #e5e7eb;background:#fff;padding:6px 10px;border-radius:6px;cursor:pointer;color:#111827;font-size:14px;line-height:1;font-weight:500;min-width:32px;-webkit-user-select:none;user-select:none;display:inline-flex;align-items:center;gap:6px}.genus-pdf-toolbar button:disabled{opacity:.5;cursor:not-allowed}.genus-pdf-toolbar .spacer{flex:1}.genus-pdf-stage{position:relative;flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;background:#fafafa;touch-action:pan-y;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;overscroll-behavior-y:contain}.genus-pdf-stage.can-pan{cursor:grab;touch-action:none}.genus-pdf-stage.is-panning{cursor:grabbing}.genus-pdf-stage--continuous{display:block;align-items:unset;justify-content:unset;padding:8px;scroll-behavior:smooth}.genus-pdf-stage--continuous .pdf-page{margin:0 auto 12px;background:#fff;box-shadow:0 1px 2px #00000014}canvas{display:block;max-width:100%;height:auto}.genus-pdf-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#ffffffb3;z-index:1}.genus-pdf-overlay.error{background:transparent}.spinner{width:28px;height:28px;border-radius:50%;border:3px solid #e5e7eb;border-top-color:#111827;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "pipe", type: i1.DecimalPipe, name: "number" }] });
931
+ `, isInline: true, styles: [".genus-pdf-shell{display:flex;flex-direction:column;gap:8px;width:100%;height:100%;background:#f3f4f6}.genus-pdf-toolbar{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid #e5e7eb;border-radius:8px;background:#ffffffd9;-webkit-backdrop-filter:saturate(1.2) blur(6px);backdrop-filter:saturate(1.2) blur(6px);box-shadow:0 1px 2px #0000000f}.genus-pdf-toolbar .pct{min-width:48px;text-align:center;font-variant-numeric:tabular-nums;color:#111827}.genus-pdf-toolbar button{appearance:none;border:1px solid #e5e7eb;background:#fff;padding:6px 10px;border-radius:6px;cursor:pointer;color:#111827;font-size:14px;line-height:1;font-weight:500;min-width:32px;-webkit-user-select:none;user-select:none;display:inline-flex;align-items:center;gap:6px}.genus-pdf-toolbar button.download-btn{background:#2563eb;border-color:#2563eb;color:#fff}.genus-pdf-toolbar button.download-btn:hover{background:#1d4ed8;border-color:#1d4ed8}.genus-pdf-toolbar button:disabled{opacity:.5;cursor:not-allowed}.genus-pdf-toolbar .spacer{flex:1}.genus-pdf-stage{position:relative;flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;background:#fafafa;touch-action:pan-y;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;overscroll-behavior-y:contain}.genus-pdf-stage.can-pan{cursor:grab;touch-action:none}.genus-pdf-stage.is-panning{cursor:grabbing}.genus-pdf-stage--continuous{display:block;align-items:unset;justify-content:unset;padding:8px;scroll-behavior:smooth}.genus-pdf-stage--continuous .pdf-page{margin:0 auto 12px;background:#fff;box-shadow:0 1px 2px #00000014}canvas{display:block;max-width:100%;height:auto}.genus-pdf-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#ffffffb3;z-index:1}.genus-pdf-overlay.error{background:transparent}.spinner{width:28px;height:28px;border-radius:50%;border:3px solid #e5e7eb;border-top-color:#111827;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "pipe", type: i1.DecimalPipe, name: "number" }] });
582
932
  }
583
933
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: GenusPdfViewerComponent, decorators: [{
584
934
  type: Component,
@@ -590,12 +940,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImpor
590
940
  <button type="button" title="Zoom out" aria-label="Zoom out" (click)="zoomOut()">−</button>
591
941
  <span class="pct">{{ (zoomSig() * 100) | number:'1.0-0' }}%</span>
592
942
  <button type="button" title="Zoom in" aria-label="Zoom in" (click)="zoomIn()">+</button>
943
+ <button type="button" *ngIf="isZoomed()" title="Reset zoom to 100%" aria-label="Reset zoom to 100%" (click)="resetZoom()">100%</button>
593
944
  <span class="spacer"></span>
594
945
  <button type="button" title="Previous page" aria-label="Previous page" (click)="prevPage()" [disabled]="pageSig() <= 1">⟨</button>
595
946
  <span>{{ pageSig() }} / {{ doc ? doc.numPages : 0 }}</span>
596
947
  <button type="button" title="Next page" aria-label="Next page" (click)="nextPage()" [disabled]="doc ? pageSig() >= doc.numPages : true">⟩</button>
597
948
  <span class="spacer"></span>
598
- <button type="button" title="Download" aria-label="Download" (click)="download()">
949
+ <button type="button" class="download-btn" title="Download" aria-label="Download" (click)="download()">
599
950
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
600
951
  <path d="M12 3v10m0 0 4-4m-4 4-4-4M5 15v3a3 3 0 0 0 3 3h8a3 3 0 0 0 3-3v-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
601
952
  </svg>
@@ -603,15 +954,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImpor
603
954
  </button>
604
955
  </div>
605
956
 
606
- <div class="genus-pdf-stage" #singleStage [style.display]="continuous ? 'none' : 'flex'" [class.can-pan]="canPan()" [class.is-panning]="isPanning()" (pointerdown)="onPointerDown($event, singleStage)" (scroll)="onScroll($event)">
957
+ <div class="genus-pdf-stage" #singleStage [style.display]="continuous ? 'none' : 'flex'" [class.can-pan]="canPan()" [class.is-panning]="isPanning()" (pointerdown)="onPointerDown($event, singleStage)" (wheel)="onWheel($event, singleStage)" (scroll)="onScroll($event)">
607
958
  <div class="genus-pdf-overlay" *ngIf="loading()"><div class="spinner" aria-label="Loading"></div></div>
608
- <div class="genus-pdf-overlay error" *ngIf="error()"><span>{{ error() }}</span></div>
959
+ <div class="genus-pdf-overlay error" *ngIf="error()">
960
+ <div style="display:flex;flex-direction:column;gap:8px;align-items:center;">
961
+ <span>{{ error() }}</span>
962
+ <button type="button" (click)="retryLoad()">Retry</button>
963
+ </div>
964
+ </div>
609
965
  <canvas #canvas></canvas>
610
966
  </div>
611
967
 
612
- <div class="genus-pdf-stage genus-pdf-stage--continuous" #stage [style.display]="continuous ? 'block' : 'none'" [class.can-pan]="canPan()" [class.is-panning]="isPanning()" (pointerdown)="onPointerDown($event, stage)" (scroll)="onScroll($event)"></div>
968
+ <div class="genus-pdf-stage genus-pdf-stage--continuous" #stage [style.display]="continuous ? 'block' : 'none'" [class.can-pan]="canPan()" [class.is-panning]="isPanning()" (pointerdown)="onPointerDown($event, stage)" (wheel)="onWheel($event, stage)" (scroll)="onScroll($event)">
969
+ <div class="genus-pdf-overlay" *ngIf="loading()"><div class="spinner" aria-label="Loading"></div></div>
970
+ <div class="genus-pdf-overlay error" *ngIf="error()">
971
+ <div style="display:flex;flex-direction:column;gap:8px;align-items:center;">
972
+ <span>{{ error() }}</span>
973
+ <button type="button" (click)="retryLoad()">Retry</button>
974
+ </div>
975
+ </div>
976
+ </div>
613
977
  </div>
614
- `, styles: [".genus-pdf-shell{display:flex;flex-direction:column;gap:8px;width:100%;height:100%;background:#f3f4f6}.genus-pdf-toolbar{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid #e5e7eb;border-radius:8px;background:#ffffffd9;-webkit-backdrop-filter:saturate(1.2) blur(6px);backdrop-filter:saturate(1.2) blur(6px);box-shadow:0 1px 2px #0000000f}.genus-pdf-toolbar .pct{min-width:48px;text-align:center;font-variant-numeric:tabular-nums;color:#111827}.genus-pdf-toolbar button{appearance:none;border:1px solid #e5e7eb;background:#fff;padding:6px 10px;border-radius:6px;cursor:pointer;color:#111827;font-size:14px;line-height:1;font-weight:500;min-width:32px;-webkit-user-select:none;user-select:none;display:inline-flex;align-items:center;gap:6px}.genus-pdf-toolbar button:disabled{opacity:.5;cursor:not-allowed}.genus-pdf-toolbar .spacer{flex:1}.genus-pdf-stage{position:relative;flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;background:#fafafa;touch-action:pan-y;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;overscroll-behavior-y:contain}.genus-pdf-stage.can-pan{cursor:grab;touch-action:none}.genus-pdf-stage.is-panning{cursor:grabbing}.genus-pdf-stage--continuous{display:block;align-items:unset;justify-content:unset;padding:8px;scroll-behavior:smooth}.genus-pdf-stage--continuous .pdf-page{margin:0 auto 12px;background:#fff;box-shadow:0 1px 2px #00000014}canvas{display:block;max-width:100%;height:auto}.genus-pdf-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#ffffffb3;z-index:1}.genus-pdf-overlay.error{background:transparent}.spinner{width:28px;height:28px;border-radius:50%;border:3px solid #e5e7eb;border-top-color:#111827;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}\n"] }]
978
+ `, styles: [".genus-pdf-shell{display:flex;flex-direction:column;gap:8px;width:100%;height:100%;background:#f3f4f6}.genus-pdf-toolbar{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid #e5e7eb;border-radius:8px;background:#ffffffd9;-webkit-backdrop-filter:saturate(1.2) blur(6px);backdrop-filter:saturate(1.2) blur(6px);box-shadow:0 1px 2px #0000000f}.genus-pdf-toolbar .pct{min-width:48px;text-align:center;font-variant-numeric:tabular-nums;color:#111827}.genus-pdf-toolbar button{appearance:none;border:1px solid #e5e7eb;background:#fff;padding:6px 10px;border-radius:6px;cursor:pointer;color:#111827;font-size:14px;line-height:1;font-weight:500;min-width:32px;-webkit-user-select:none;user-select:none;display:inline-flex;align-items:center;gap:6px}.genus-pdf-toolbar button.download-btn{background:#2563eb;border-color:#2563eb;color:#fff}.genus-pdf-toolbar button.download-btn:hover{background:#1d4ed8;border-color:#1d4ed8}.genus-pdf-toolbar button:disabled{opacity:.5;cursor:not-allowed}.genus-pdf-toolbar .spacer{flex:1}.genus-pdf-stage{position:relative;flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;background:#fafafa;touch-action:pan-y;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;overscroll-behavior-y:contain}.genus-pdf-stage.can-pan{cursor:grab;touch-action:none}.genus-pdf-stage.is-panning{cursor:grabbing}.genus-pdf-stage--continuous{display:block;align-items:unset;justify-content:unset;padding:8px;scroll-behavior:smooth}.genus-pdf-stage--continuous .pdf-page{margin:0 auto 12px;background:#fff;box-shadow:0 1px 2px #00000014}canvas{display:block;max-width:100%;height:auto}.genus-pdf-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#ffffffb3;z-index:1}.genus-pdf-overlay.error{background:transparent}.spinner{width:28px;height:28px;border-radius:50%;border:3px solid #e5e7eb;border-top-color:#111827;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}\n"] }]
615
979
  }], ctorParameters: () => [], propDecorators: { src: [{
616
980
  type: Input
617
981
  }], page: [{
@@ -630,6 +994,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImpor
630
994
  type: Input
631
995
  }], showToolbar: [{
632
996
  type: Input
997
+ }], zoomAnimation: [{
998
+ type: Input
999
+ }], zoomAnimationMs: [{
1000
+ type: Input
1001
+ }], gestureAnimationMs: [{
1002
+ type: Input
633
1003
  }], canvas: [{
634
1004
  type: ViewChild,
635
1005
  args: ['canvas', { static: false }]
@@ -646,23 +1016,24 @@ function initPdfWorkerFactory(cfg, platformId) {
646
1016
  return () => {
647
1017
  if (!isPlatformBrowser(platformId))
648
1018
  return;
649
- const tryModuleWorker = cfg.tryModuleWorker ?? true;
650
- // 1) First, always try a real ESM module worker (best for Amplify)
1019
+ const defaults = {
1020
+ tryModuleWorker: true,
1021
+ workerSrc: '/assets/pdf.worker.min.mjs',
1022
+ };
1023
+ const merged = { ...defaults, ...cfg };
1024
+ // 1) Try an ESM module worker (most robust across modern browsers)
651
1025
  try {
652
- if (tryModuleWorker && 'Worker' in window) {
1026
+ if (merged.tryModuleWorker && 'Worker' in window) {
653
1027
  const worker = new Worker(new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url), { type: 'module' });
654
1028
  pdfjsLib.GlobalWorkerOptions.workerPort = worker;
655
1029
  return;
656
1030
  }
657
1031
  }
658
1032
  catch {
659
- /* fall through to workerSrc fallback */
660
- }
661
- // 2) Fallback: classic worker path (only if you explicitly pass one)
662
- const workerSrc = cfg.workerSrc;
663
- if (workerSrc) {
664
- pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;
1033
+ // fall back below
665
1034
  }
1035
+ // 2) Fallback: classic worker path served by the app
1036
+ pdfjsLib.GlobalWorkerOptions.workerSrc = merged.workerSrc;
666
1037
  };
667
1038
  }
668
1039
  function provideGenusPdfViewer(cfg = {}) {