@makolabs/ripple 3.8.1 → 3.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/ai/MessageBox.svelte +2 -1
  2. package/dist/button/Button.svelte +89 -7
  3. package/dist/elements/file-viewer/FilePreviewModal.svelte +102 -0
  4. package/dist/elements/file-viewer/FilePreviewModal.svelte.d.ts +4 -0
  5. package/dist/elements/file-viewer/FileViewer.svelte +96 -0
  6. package/dist/elements/file-viewer/FileViewer.svelte.d.ts +4 -0
  7. package/dist/elements/file-viewer/file-preview-modal-types.d.ts +62 -0
  8. package/dist/elements/file-viewer/file-preview-modal-types.js +1 -0
  9. package/dist/elements/file-viewer/file-viewer-types.d.ts +51 -0
  10. package/dist/elements/file-viewer/file-viewer-types.js +127 -0
  11. package/dist/elements/image-viewer/ImageViewer.svelte +133 -0
  12. package/dist/elements/image-viewer/ImageViewer.svelte.d.ts +4 -0
  13. package/dist/elements/image-viewer/image-viewer-types.d.ts +16 -0
  14. package/dist/elements/image-viewer/image-viewer-types.js +1 -0
  15. package/dist/elements/pdf-viewer/PdfViewer.svelte +225 -34
  16. package/dist/elements/pdf-viewer/pdf-viewer-types.d.ts +13 -4
  17. package/dist/elements/text-viewer/TextViewer.svelte +141 -0
  18. package/dist/elements/text-viewer/TextViewer.svelte.d.ts +4 -0
  19. package/dist/elements/text-viewer/text-viewer-types.d.ts +10 -0
  20. package/dist/elements/text-viewer/text-viewer-types.js +1 -0
  21. package/dist/elements/viewer-shared/UnsupportedPreview.svelte +84 -0
  22. package/dist/elements/viewer-shared/UnsupportedPreview.svelte.d.ts +4 -0
  23. package/dist/elements/viewer-shared/ViewerToolbar.svelte +128 -0
  24. package/dist/elements/viewer-shared/ViewerToolbar.svelte.d.ts +4 -0
  25. package/dist/elements/viewer-shared/unsupported-preview-types.d.ts +17 -0
  26. package/dist/elements/viewer-shared/unsupported-preview-types.js +1 -0
  27. package/dist/elements/viewer-shared/viewer-toolbar-types.d.ts +14 -0
  28. package/dist/elements/viewer-shared/viewer-toolbar-types.js +1 -0
  29. package/dist/helper/is-safe-http-url.d.ts +33 -0
  30. package/dist/helper/is-safe-http-url.js +214 -0
  31. package/dist/helper/pdf-assets.d.ts +42 -0
  32. package/dist/helper/pdf-assets.js +57 -0
  33. package/dist/helper/pdf-safety.d.ts +38 -0
  34. package/dist/helper/pdf-safety.js +85 -0
  35. package/dist/helper/resource-limits.d.ts +13 -0
  36. package/dist/helper/resource-limits.js +16 -0
  37. package/dist/helper/sanitize-html.d.ts +5 -0
  38. package/dist/helper/sanitize-html.js +12 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.js +8 -0
  41. package/package.json +4 -2
@@ -0,0 +1,133 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../helper/cls.js';
3
+ import { buildTestId } from '../../helper/testid.js';
4
+ import { isSafeHttpUrl } from '../../helper/is-safe-http-url.js';
5
+ import { MAX_IMAGE_PIXELS } from '../../helper/resource-limits.js';
6
+ import Spinner from '../spinner/Spinner.svelte';
7
+ import ViewerToolbar from '../viewer-shared/ViewerToolbar.svelte';
8
+ import UnsupportedPreview from '../viewer-shared/UnsupportedPreview.svelte';
9
+ import { Color, Size } from '../../variants.js';
10
+ import type { ImageViewerProps } from './image-viewer-types.js';
11
+
12
+ let {
13
+ url,
14
+ initialScale = 1,
15
+ minScale = 0.25,
16
+ maxScale = 5,
17
+ scaleStep = 0.25,
18
+ maxImagePixels = MAX_IMAGE_PIXELS,
19
+ showToolbar = true,
20
+ loadingLabel = 'Loading image...',
21
+ errorTitle = 'Unable to display image',
22
+ errorActionLabel = 'Open in new tab',
23
+ alt = '',
24
+ class: className = '',
25
+ testId
26
+ }: ImageViewerProps = $props();
27
+
28
+ const urlAllowed = $derived(isSafeHttpUrl(url, { allowBlob: true, allowPrivateHosts: true }));
29
+
30
+ const clampScale = (value: number) => Math.min(Math.max(value, minScale), maxScale);
31
+
32
+ let containerEl: HTMLDivElement;
33
+ let status = $state<'loading' | 'ready' | 'error'>('loading');
34
+ let naturalWidth = $state(0);
35
+ let scale = $state(clampScale(initialScale));
36
+
37
+ // Reset load state whenever the source *changes* (not on first mount, which
38
+ // would clobber the load/error handler). Without this, a viewer stuck on
39
+ // `error` never re-attempts (the <img> isn't rendered, so no onload fires),
40
+ // and a successful swap would briefly size the new image with the old
41
+ // naturalWidth. Scale (user zoom) is intentionally preserved across swaps.
42
+ let previousImgUrl: string | undefined;
43
+ $effect(() => {
44
+ const current = url;
45
+ if (previousImgUrl !== undefined && previousImgUrl !== current) {
46
+ status = 'loading';
47
+ naturalWidth = 0;
48
+ }
49
+ previousImgUrl = current;
50
+ });
51
+
52
+ function handleLoad(event: Event) {
53
+ const img = event.currentTarget as HTMLImageElement;
54
+ const pixels = img.naturalWidth * img.naturalHeight;
55
+ if (pixels > maxImagePixels) {
56
+ status = 'error';
57
+ return;
58
+ }
59
+ naturalWidth = img.naturalWidth;
60
+ status = 'ready';
61
+ }
62
+
63
+ function handleError() {
64
+ status = 'error';
65
+ }
66
+
67
+ function handleZoomIn() {
68
+ scale = Math.min(scale + scaleStep, maxScale);
69
+ }
70
+ function handleZoomOut() {
71
+ scale = Math.max(scale - scaleStep, minScale);
72
+ }
73
+ function handleFitToWidth() {
74
+ if (!containerEl || !naturalWidth) return;
75
+ const computed = (containerEl.clientWidth - 32) / naturalWidth;
76
+ scale = Math.min(Math.max(computed, minScale), maxScale);
77
+ }
78
+ </script>
79
+
80
+ <div
81
+ class={cn('flex h-full flex-col', className)}
82
+ data-testid={buildTestId('image-viewer', undefined, testId)}
83
+ >
84
+ {#if urlAllowed && status !== 'error' && showToolbar}
85
+ <ViewerToolbar
86
+ component="image-viewer"
87
+ {scale}
88
+ {minScale}
89
+ {maxScale}
90
+ disabled={status !== 'ready'}
91
+ onzoomin={handleZoomIn}
92
+ onzoomout={handleZoomOut}
93
+ onfittowidth={handleFitToWidth}
94
+ {testId}
95
+ />
96
+ {/if}
97
+
98
+ <div
99
+ bind:this={containerEl}
100
+ class="bg-default-100 relative flex-1 overflow-auto p-4"
101
+ data-testid={buildTestId('image-viewer', 'canvas-area', testId)}
102
+ >
103
+ {#if !urlAllowed || status === 'error'}
104
+ <UnsupportedPreview
105
+ url={urlAllowed ? url : undefined}
106
+ title={errorTitle}
107
+ actionLabel={errorActionLabel}
108
+ variant="error"
109
+ {testId}
110
+ />
111
+ {:else}
112
+ {#if status === 'loading'}
113
+ <div class="absolute inset-0 flex items-center justify-center">
114
+ <Spinner size={Size.LG} color={Color.PRIMARY} label={loadingLabel} />
115
+ </div>
116
+ {/if}
117
+ <div class="flex justify-center">
118
+ <img
119
+ src={url}
120
+ {alt}
121
+ loading="lazy"
122
+ decoding="async"
123
+ referrerpolicy="no-referrer"
124
+ onload={handleLoad}
125
+ onerror={handleError}
126
+ style:width={naturalWidth ? `${naturalWidth * scale}px` : undefined}
127
+ class="block max-w-none rounded-sm bg-white shadow-sm"
128
+ data-testid={buildTestId('image-viewer', 'img', testId)}
129
+ />
130
+ </div>
131
+ {/if}
132
+ </div>
133
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { ImageViewerProps } from './image-viewer-types.js';
2
+ declare const ImageViewer: import("svelte").Component<ImageViewerProps, {}, "">;
3
+ type ImageViewer = ReturnType<typeof ImageViewer>;
4
+ export default ImageViewer;
@@ -0,0 +1,16 @@
1
+ import type { ClassValue } from 'tailwind-variants';
2
+ export type ImageViewerProps = {
3
+ url: string;
4
+ initialScale?: number;
5
+ minScale?: number;
6
+ maxScale?: number;
7
+ scaleStep?: number;
8
+ maxImagePixels?: number;
9
+ showToolbar?: boolean;
10
+ loadingLabel?: string;
11
+ errorTitle?: string;
12
+ errorActionLabel?: string;
13
+ alt?: string;
14
+ class?: ClassValue;
15
+ testId?: string;
16
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -3,6 +3,17 @@
3
3
  import { browser } from '$app/environment';
4
4
  import { cn } from '../../helper/cls.js';
5
5
  import { buildTestId } from '../../helper/testid.js';
6
+ import { isSafeHttpUrl, redactUrl } from '../../helper/is-safe-http-url.js';
7
+ import {
8
+ PDFJS_DOCUMENT_OPTIONS,
9
+ isPdfJsVersionSafe,
10
+ computeCanvasDimensions,
11
+ pagesToRender,
12
+ computeFitScale,
13
+ type FitMode
14
+ } from '../../helper/pdf-safety.js';
15
+ import { MAX_PAGES } from '../../helper/resource-limits.js';
16
+ import { PdfBinaryDataFactory } from '../../helper/pdf-assets.js';
6
17
  import Spinner from '../spinner/Spinner.svelte';
7
18
  import Button from '../../button/Button.svelte';
8
19
  import { Color, Size } from '../../variants.js';
@@ -13,10 +24,11 @@
13
24
 
14
25
  let {
15
26
  url,
16
- initialScale = 1.5,
27
+ initialScale = 'fit-width',
17
28
  minScale = 0.5,
18
29
  maxScale = 3,
19
30
  scaleStep = 0.25,
31
+ maxPages = MAX_PAGES,
20
32
  showToolbar = true,
21
33
  loadingLabel = 'Loading PDF...',
22
34
  errorTitle = 'Error loading PDF',
@@ -25,13 +37,32 @@
25
37
  testId
26
38
  }: PdfViewerProps = $props();
27
39
 
40
+ const RENDER_TIMEOUT_MS = 15000;
41
+ const LOAD_TIMEOUT_MS = 30000;
42
+ const RENDER_CONCURRENCY = 3;
43
+ // Container chrome to discount when fitting: canvas-area p-4 (16px each side)
44
+ // plus the canvas ring — matched empirically so a fit page doesn't overflow.
45
+ const FIT_PADDING = 48;
46
+
47
+ const isFitMode = (value: number | FitMode): value is FitMode =>
48
+ value === 'fit-width' || value === 'fit-page';
49
+
28
50
  const clampScale = (value: number) => Math.min(Math.max(value, minScale), maxScale);
29
51
 
30
52
  let containerEl: HTMLDivElement;
31
53
  let canvasElements: HTMLCanvasElement[] = $state([]);
32
54
  let pdfDoc: PDFDocumentProxy | null = $state(null);
33
55
  let pageCount = $state(0);
34
- let scale = $state(clampScale(initialScale));
56
+ // Derived so changing `maxPages` on an already-loaded document re-applies the
57
+ // cap (banner + canvas slots) without waiting for the URL to change.
58
+ const renderedPageCount = $derived(pagesToRender(pageCount, maxPages));
59
+ const pagesTruncated = $derived(pageCount > renderedPageCount);
60
+ // `fitMode` is the active auto-fit lock: while set, `scale` is derived from the
61
+ // container and re-applied on resize; a manual zoom clears it. `scale` starts
62
+ // at a provisional 100% in fit modes until the first page is measured.
63
+ let fitMode = $state<FitMode | null>(isFitMode(initialScale) ? initialScale : null);
64
+ let scale = $state(isFitMode(initialScale) ? clampScale(1) : clampScale(initialScale));
65
+ let resizeObserver: ResizeObserver | null = null;
35
66
  let loading = $state(true);
36
67
  let error = $state<string | null>(null);
37
68
  let pdfjsLib: PdfJsModule | null = $state(null);
@@ -46,21 +77,47 @@
46
77
  onMount(async () => {
47
78
  if (!browser) return;
48
79
 
80
+ // Re-fit on container resize (e.g. the modal/window changes size) while an
81
+ // auto-fit lock is active; a manual zoom clears the lock and stops this.
82
+ if (containerEl && typeof ResizeObserver !== 'undefined') {
83
+ resizeObserver = new ResizeObserver(() => {
84
+ if (fitMode) void applyFit(fitMode);
85
+ });
86
+ resizeObserver.observe(containerEl);
87
+ }
88
+
49
89
  try {
50
90
  const lib = await import('pdfjs-dist');
51
- const workerModule = await import('pdfjs-dist/build/pdf.worker.min.mjs?url');
52
- lib.GlobalWorkerOptions.workerSrc = workerModule.default;
91
+ // Resolve the worker from the consumer's own pdfjs-dist (peer dep) so its
92
+ // version always matches the API. `new URL(..., import.meta.url)` is the
93
+ // standard, bundler-agnostic pattern (Vite/webpack/Next all resolve it) —
94
+ // unlike Vite's `?url` query, which breaks other bundlers and esbuild's
95
+ // dep pre-bundling in consumer apps.
96
+ lib.GlobalWorkerOptions.workerSrc = new URL(
97
+ 'pdfjs-dist/build/pdf.worker.min.mjs',
98
+ import.meta.url
99
+ ).toString();
100
+
101
+ if (!isPdfJsVersionSafe(lib.version)) {
102
+ console.warn(
103
+ `[ripple] pdfjs-dist ${lib.version} is below the recommended 4.2.67 ` +
104
+ `(CVE-2024-4367 — arbitrary script execution). Please upgrade pdfjs-dist.`
105
+ );
106
+ }
107
+
53
108
  // Assign to reactive state only after workerSrc is configured,
54
109
  // so the $effect that calls loadPdf() sees a fully-ready library
55
110
  pdfjsLib = lib;
56
111
  } catch (err) {
57
- console.error('Error loading PDF.js:', err);
112
+ console.error('Error loading PDF.js:', err instanceof Error ? err.name : 'unknown');
58
113
  error = 'Failed to load PDF library';
59
114
  loading = false;
60
115
  }
61
116
  });
62
117
 
63
118
  onDestroy(() => {
119
+ resizeObserver?.disconnect();
120
+ resizeObserver = null;
64
121
  cleanupPdf();
65
122
  });
66
123
 
@@ -85,7 +142,8 @@
85
142
  });
86
143
 
87
144
  $effect(() => {
88
- if (pdfDoc && !loading && scale) {
145
+ // renderedPageCount is tracked so raising maxPages renders the new pages.
146
+ if (pdfDoc && !loading && scale && renderedPageCount) {
89
147
  // Wait for canvas elements to mount after loading completes
90
148
  tick().then(() => renderAllPages());
91
149
  }
@@ -110,38 +168,79 @@
110
168
  pdfDoc = null;
111
169
  }
112
170
 
171
+ // Release canvas backing stores eagerly instead of waiting for GC.
172
+ for (const canvas of canvasElements) {
173
+ if (canvas) {
174
+ canvas.width = 0;
175
+ canvas.height = 0;
176
+ }
177
+ }
113
178
  canvasElements = [];
114
179
  }
115
180
 
116
181
  async function loadPdf() {
117
182
  if (!pdfjsLib) return;
118
183
 
184
+ if (!isSafeHttpUrl(url, { allowBlob: true, allowPrivateHosts: true })) {
185
+ error = 'This document URL is not allowed.';
186
+ loading = false;
187
+ return;
188
+ }
189
+
119
190
  const token = ++loadToken;
120
191
 
121
192
  loading = true;
122
193
  error = null;
123
194
 
124
- const loadingTask = (currentLoadingTask = pdfjsLib.getDocument(url));
195
+ const loadingTask = (currentLoadingTask = pdfjsLib.getDocument({
196
+ url,
197
+ // Serve pdfjs's image-codec wasm (JPEG2000/JPX, JBIG2) from
198
+ // bundler-emitted asset URLs. Without this, JPX-encoded pages render
199
+ // blank because pdfjs can't load `openjpeg.wasm`. See pdf-assets.ts.
200
+ BinaryDataFactory: PdfBinaryDataFactory,
201
+ ...PDFJS_DOCUMENT_OPTIONS
202
+ }));
203
+
204
+ let loadTimer: ReturnType<typeof setTimeout> | undefined;
125
205
 
126
206
  try {
127
- const doc = await loadingTask.promise;
207
+ const doc = (await Promise.race([
208
+ loadingTask.promise,
209
+ new Promise<never>((_, reject) => {
210
+ loadTimer = setTimeout(() => {
211
+ loadingTask.destroy();
212
+ reject(new Error('PDF load timed out'));
213
+ }, LOAD_TIMEOUT_MS);
214
+ })
215
+ ])) as PDFDocumentProxy;
128
216
 
129
217
  if (token !== loadToken) return;
130
218
 
131
219
  currentLoadingTask = null;
132
220
  pdfDoc = doc;
133
- pageCount = pdfDoc.numPages;
221
+ pageCount = doc.numPages;
134
222
 
135
- canvasElements = new Array(pageCount).fill(null);
223
+ canvasElements = new Array(renderedPageCount).fill(null);
224
+
225
+ // Resolve any auto-fit lock now (loading is still true, so this sets the
226
+ // scale before the first render — no flash of a wrong zoom level).
227
+ if (fitMode) await applyFit(fitMode);
228
+ if (token !== loadToken) return;
136
229
 
137
230
  loading = false;
138
231
  } catch (err) {
139
232
  if (token !== loadToken) return;
140
233
 
141
- console.error('Error loading PDF:', err);
142
- error = err instanceof Error ? err.message : 'Failed to load PDF';
234
+ console.error(
235
+ 'Error loading PDF:',
236
+ redactUrl(url),
237
+ err instanceof Error ? err.name : 'unknown'
238
+ );
239
+ error = 'Failed to load PDF';
143
240
  loading = false;
144
241
  currentLoadingTask = null;
242
+ } finally {
243
+ clearTimeout(loadTimer);
145
244
  }
146
245
  }
147
246
 
@@ -156,9 +255,22 @@
156
255
  }
157
256
  renderTasks = [];
158
257
 
159
- for (let i = 1; i <= pageCount; i++) {
160
- void renderPage(i, epoch, renderScale);
258
+ const total = renderedPageCount;
259
+ let next = 1;
260
+
261
+ const worker = async () => {
262
+ while (next <= total) {
263
+ const num = next++;
264
+ if (epoch !== renderEpoch) return;
265
+ await renderPage(num, epoch, renderScale);
266
+ }
267
+ };
268
+
269
+ const workers: Promise<void>[] = [];
270
+ for (let i = 0; i < Math.min(RENDER_CONCURRENCY, total); i++) {
271
+ workers.push(worker());
161
272
  }
273
+ void Promise.all(workers);
162
274
  }
163
275
 
164
276
  async function renderPage(num: number, epoch: number, renderScale: number) {
@@ -168,11 +280,21 @@
168
280
  const canvas = canvasElements[num - 1];
169
281
  if (!canvas) return;
170
282
 
283
+ let renderTimer: ReturnType<typeof setTimeout> | undefined;
284
+
171
285
  try {
172
286
  const page = await doc.getPage(num);
173
287
  // Bail if a newer pass started, the doc was swapped, or the canvas was remounted
174
288
  if (epoch !== renderEpoch || doc !== pdfDoc || canvas !== canvasElements[num - 1]) return;
175
289
 
290
+ const baseViewport = page.getViewport({ scale: 1 });
291
+ const dims = computeCanvasDimensions(
292
+ baseViewport.width,
293
+ baseViewport.height,
294
+ renderScale,
295
+ typeof window !== 'undefined' ? window.devicePixelRatio : 1
296
+ );
297
+
176
298
  const viewport = page.getViewport({ scale: renderScale });
177
299
 
178
300
  const context = canvas.getContext('2d');
@@ -180,16 +302,12 @@
180
302
  throw new Error('Could not get canvas context');
181
303
  }
182
304
 
183
- // Account for high-DPI/Retina displays to prevent fuzzy rendering
184
- const pixelRatio = window.devicePixelRatio || 1;
305
+ canvas.width = dims.pixelWidth;
306
+ canvas.height = dims.pixelHeight;
307
+ canvas.style.width = `${dims.cssWidth}px`;
308
+ canvas.style.height = `${dims.cssHeight}px`;
185
309
 
186
- canvas.height = viewport.height * pixelRatio;
187
- canvas.width = viewport.width * pixelRatio;
188
-
189
- canvas.style.width = `${viewport.width}px`;
190
- canvas.style.height = `${viewport.height}px`;
191
-
192
- context.scale(pixelRatio, pixelRatio);
310
+ context.scale(dims.effectivePixelRatio, dims.effectivePixelRatio);
193
311
 
194
312
  const renderTask = page.render({
195
313
  canvas,
@@ -198,33 +316,64 @@
198
316
  });
199
317
  if (epoch === renderEpoch) renderTasks[num - 1] = renderTask;
200
318
 
201
- await renderTask.promise;
319
+ await Promise.race([
320
+ renderTask.promise,
321
+ new Promise<never>((_, reject) => {
322
+ renderTimer = setTimeout(() => {
323
+ renderTask.cancel();
324
+ reject(new Error('Page render timed out'));
325
+ }, RENDER_TIMEOUT_MS);
326
+ })
327
+ ]);
202
328
  if (epoch === renderEpoch) renderTasks[num - 1] = null;
203
329
  } catch (err) {
204
330
  if (err instanceof Error && err.name === 'RenderingCancelledException') {
205
331
  return;
206
332
  }
207
- console.error('Error rendering page:', err);
333
+ console.error('Error rendering page:', num, err instanceof Error ? err.name : 'unknown');
334
+ } finally {
335
+ clearTimeout(renderTimer);
208
336
  }
209
337
  }
210
338
 
339
+ // Resolve the active fit mode against the current page + container geometry.
340
+ // Guards on document identity so a load/teardown mid-await can't set a stale scale.
341
+ async function applyFit(mode: FitMode) {
342
+ const doc = pdfDoc;
343
+ if (!doc || !containerEl) return;
344
+
345
+ const page = await doc.getPage(1);
346
+ if (doc !== pdfDoc) return;
347
+
348
+ const viewport = page.getViewport({ scale: 1 });
349
+ scale = computeFitScale(
350
+ viewport.width,
351
+ viewport.height,
352
+ containerEl.clientWidth,
353
+ containerEl.clientHeight,
354
+ mode,
355
+ { minScale, maxScale, padding: FIT_PADDING }
356
+ );
357
+ }
358
+
211
359
  function handleZoomIn() {
360
+ fitMode = null;
212
361
  scale = Math.min(scale + scaleStep, maxScale);
213
362
  }
214
363
 
215
364
  function handleZoomOut() {
365
+ fitMode = null;
216
366
  scale = Math.max(scale - scaleStep, minScale);
217
367
  }
218
368
 
219
369
  async function handleFitToWidth() {
220
- if (!pdfDoc || !containerEl) return;
221
-
222
- const page = await pdfDoc.getPage(1);
223
- const viewport = page.getViewport({ scale: 1 });
224
- const containerWidth = containerEl.clientWidth || viewport.width;
370
+ fitMode = 'fit-width';
371
+ await applyFit('fit-width');
372
+ }
225
373
 
226
- const computed = (containerWidth - 48) / viewport.width;
227
- scale = Math.min(Math.max(computed, minScale), maxScale);
374
+ async function handleFitToPage() {
375
+ fitMode = 'fit-page';
376
+ await applyFit('fit-page');
228
377
  }
229
378
 
230
379
  function handleKeydown(event: KeyboardEvent) {
@@ -329,7 +478,6 @@
329
478
  aria-hidden="true"
330
479
  >
331
480
  <circle cx="11" cy="11" r="8" />
332
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
333
481
  <line x1="11" y1="8" x2="11" y2="14" />
334
482
  <line x1="8" y1="11" x2="14" y2="11" />
335
483
  </svg>
@@ -361,6 +509,32 @@
361
509
  <line x1="3" y1="21" x2="10" y2="14" />
362
510
  </svg>
363
511
  </button>
512
+
513
+ <button
514
+ type="button"
515
+ onclick={handleFitToPage}
516
+ disabled={loading}
517
+ class="text-default-700 hover:bg-default-100 focus-visible:ring-primary-400 rounded-md p-1.5 transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40"
518
+ aria-label="Fit page"
519
+ title="Fit page"
520
+ data-testid={buildTestId('pdf-viewer', 'fit-to-page', testId)}
521
+ >
522
+ <svg
523
+ xmlns="http://www.w3.org/2000/svg"
524
+ class="h-5 w-5"
525
+ viewBox="0 0 24 24"
526
+ fill="none"
527
+ stroke="currentColor"
528
+ stroke-width="2"
529
+ stroke-linecap="round"
530
+ stroke-linejoin="round"
531
+ aria-hidden="true"
532
+ >
533
+ <rect x="4" y="3" width="16" height="18" rx="1" />
534
+ <polyline points="9 8 12 5 15 8" />
535
+ <polyline points="9 16 12 19 15 16" />
536
+ </svg>
537
+ </button>
364
538
  </div>
365
539
  </div>
366
540
  {/if}
@@ -414,7 +588,24 @@
414
588
  </div>
415
589
  {:else}
416
590
  <div class="flex flex-col items-center gap-4">
417
- {#each { length: pageCount } as _, i (i)}
591
+ {#if pagesTruncated}
592
+ <div
593
+ class="border-default-200 bg-default-50 text-default-700 flex w-full max-w-2xl items-center justify-between gap-4 rounded-lg border px-4 py-2 text-sm"
594
+ data-testid={buildTestId('pdf-viewer', 'truncated', testId)}
595
+ >
596
+ <span>Showing the first {renderedPageCount} of {pageCount} pages.</span>
597
+ <Button
598
+ color={Color.PRIMARY}
599
+ size={Size.SM}
600
+ href={url}
601
+ target="_blank"
602
+ rel="noopener noreferrer"
603
+ >
604
+ {errorActionLabel}
605
+ </Button>
606
+ </div>
607
+ {/if}
608
+ {#each { length: renderedPageCount } as _, i (i)}
418
609
  <canvas
419
610
  bind:this={canvasElements[i]}
420
611
  class="ring-default-200 block rounded-sm bg-white shadow-sm ring-1"
@@ -1,11 +1,13 @@
1
1
  import type { ClassValue } from 'tailwind-variants';
2
+ import type { FitMode } from '../../helper/pdf-safety.js';
2
3
  /**
3
4
  * Props for `<PdfViewer>` — a scrollable, zoomable canvas-based PDF
4
5
  * viewer powered by `pdfjs-dist` (loaded dynamically on the client).
5
6
  *
6
7
  * The component renders every page of the PDF as a stacked column of
7
- * canvases, with a toolbar for zoom in / zoom out / fit-to-width. PDF.js
8
- * is imported lazily inside `onMount`, so the viewer is SSR-safe.
8
+ * canvases, with a toolbar for zoom in / zoom out / fit-width / fit-page.
9
+ * It defaults to fit-width and re-fits on container resize until the user
10
+ * zooms. PDF.js is imported lazily inside `onMount`, so the viewer is SSR-safe.
9
11
  *
10
12
  * **Peer dependency:** install `pdfjs-dist` in your application.
11
13
  *
@@ -17,14 +19,21 @@ import type { ClassValue } from 'tailwind-variants';
17
19
  export type PdfViewerProps = {
18
20
  /** URL of the PDF document. Changing the URL reloads the viewer. */
19
21
  url: string;
20
- /** Initial zoom scale. @default 1.5 */
21
- initialScale?: number;
22
+ /**
23
+ * Initial zoom. A number is a fixed scale (1 = 100%); `'fit-width'` sizes the
24
+ * page to the container width (scroll vertically), `'fit-page'` fits the whole
25
+ * first page. Fit modes re-apply on container resize until the user zooms.
26
+ * @default 'fit-width'
27
+ */
28
+ initialScale?: number | FitMode;
22
29
  /** Minimum allowed zoom scale. @default 0.5 */
23
30
  minScale?: number;
24
31
  /** Maximum allowed zoom scale. @default 3 */
25
32
  maxScale?: number;
26
33
  /** Step used by the zoom-in / zoom-out buttons. @default 0.25 */
27
34
  scaleStep?: number;
35
+ /** Max pages to eagerly render; extra pages are not rendered. @default 200 */
36
+ maxPages?: number;
28
37
  /** Show the zoom / page-count toolbar. @default true */
29
38
  showToolbar?: boolean;
30
39
  /** Visible label inside the loading spinner. @default 'Loading PDF...' */