@makolabs/ripple 3.8.1 → 3.9.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.
Files changed (39) 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 +220 -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-safety.d.ts +38 -0
  32. package/dist/helper/pdf-safety.js +85 -0
  33. package/dist/helper/resource-limits.d.ts +13 -0
  34. package/dist/helper/resource-limits.js +16 -0
  35. package/dist/helper/sanitize-html.d.ts +5 -0
  36. package/dist/helper/sanitize-html.js +12 -0
  37. package/dist/index.d.ts +9 -0
  38. package/dist/index.js +8 -0
  39. 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,16 @@
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';
6
16
  import Spinner from '../spinner/Spinner.svelte';
7
17
  import Button from '../../button/Button.svelte';
8
18
  import { Color, Size } from '../../variants.js';
@@ -13,10 +23,11 @@
13
23
 
14
24
  let {
15
25
  url,
16
- initialScale = 1.5,
26
+ initialScale = 'fit-width',
17
27
  minScale = 0.5,
18
28
  maxScale = 3,
19
29
  scaleStep = 0.25,
30
+ maxPages = MAX_PAGES,
20
31
  showToolbar = true,
21
32
  loadingLabel = 'Loading PDF...',
22
33
  errorTitle = 'Error loading PDF',
@@ -25,13 +36,32 @@
25
36
  testId
26
37
  }: PdfViewerProps = $props();
27
38
 
39
+ const RENDER_TIMEOUT_MS = 15000;
40
+ const LOAD_TIMEOUT_MS = 30000;
41
+ const RENDER_CONCURRENCY = 3;
42
+ // Container chrome to discount when fitting: canvas-area p-4 (16px each side)
43
+ // plus the canvas ring — matched empirically so a fit page doesn't overflow.
44
+ const FIT_PADDING = 48;
45
+
46
+ const isFitMode = (value: number | FitMode): value is FitMode =>
47
+ value === 'fit-width' || value === 'fit-page';
48
+
28
49
  const clampScale = (value: number) => Math.min(Math.max(value, minScale), maxScale);
29
50
 
30
51
  let containerEl: HTMLDivElement;
31
52
  let canvasElements: HTMLCanvasElement[] = $state([]);
32
53
  let pdfDoc: PDFDocumentProxy | null = $state(null);
33
54
  let pageCount = $state(0);
34
- let scale = $state(clampScale(initialScale));
55
+ // Derived so changing `maxPages` on an already-loaded document re-applies the
56
+ // cap (banner + canvas slots) without waiting for the URL to change.
57
+ const renderedPageCount = $derived(pagesToRender(pageCount, maxPages));
58
+ const pagesTruncated = $derived(pageCount > renderedPageCount);
59
+ // `fitMode` is the active auto-fit lock: while set, `scale` is derived from the
60
+ // container and re-applied on resize; a manual zoom clears it. `scale` starts
61
+ // at a provisional 100% in fit modes until the first page is measured.
62
+ let fitMode = $state<FitMode | null>(isFitMode(initialScale) ? initialScale : null);
63
+ let scale = $state(isFitMode(initialScale) ? clampScale(1) : clampScale(initialScale));
64
+ let resizeObserver: ResizeObserver | null = null;
35
65
  let loading = $state(true);
36
66
  let error = $state<string | null>(null);
37
67
  let pdfjsLib: PdfJsModule | null = $state(null);
@@ -46,21 +76,47 @@
46
76
  onMount(async () => {
47
77
  if (!browser) return;
48
78
 
79
+ // Re-fit on container resize (e.g. the modal/window changes size) while an
80
+ // auto-fit lock is active; a manual zoom clears the lock and stops this.
81
+ if (containerEl && typeof ResizeObserver !== 'undefined') {
82
+ resizeObserver = new ResizeObserver(() => {
83
+ if (fitMode) void applyFit(fitMode);
84
+ });
85
+ resizeObserver.observe(containerEl);
86
+ }
87
+
49
88
  try {
50
89
  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;
90
+ // Resolve the worker from the consumer's own pdfjs-dist (peer dep) so its
91
+ // version always matches the API. `new URL(..., import.meta.url)` is the
92
+ // standard, bundler-agnostic pattern (Vite/webpack/Next all resolve it) —
93
+ // unlike Vite's `?url` query, which breaks other bundlers and esbuild's
94
+ // dep pre-bundling in consumer apps.
95
+ lib.GlobalWorkerOptions.workerSrc = new URL(
96
+ 'pdfjs-dist/build/pdf.worker.min.mjs',
97
+ import.meta.url
98
+ ).toString();
99
+
100
+ if (!isPdfJsVersionSafe(lib.version)) {
101
+ console.warn(
102
+ `[ripple] pdfjs-dist ${lib.version} is below the recommended 4.2.67 ` +
103
+ `(CVE-2024-4367 — arbitrary script execution). Please upgrade pdfjs-dist.`
104
+ );
105
+ }
106
+
53
107
  // Assign to reactive state only after workerSrc is configured,
54
108
  // so the $effect that calls loadPdf() sees a fully-ready library
55
109
  pdfjsLib = lib;
56
110
  } catch (err) {
57
- console.error('Error loading PDF.js:', err);
111
+ console.error('Error loading PDF.js:', err instanceof Error ? err.name : 'unknown');
58
112
  error = 'Failed to load PDF library';
59
113
  loading = false;
60
114
  }
61
115
  });
62
116
 
63
117
  onDestroy(() => {
118
+ resizeObserver?.disconnect();
119
+ resizeObserver = null;
64
120
  cleanupPdf();
65
121
  });
66
122
 
@@ -85,7 +141,8 @@
85
141
  });
86
142
 
87
143
  $effect(() => {
88
- if (pdfDoc && !loading && scale) {
144
+ // renderedPageCount is tracked so raising maxPages renders the new pages.
145
+ if (pdfDoc && !loading && scale && renderedPageCount) {
89
146
  // Wait for canvas elements to mount after loading completes
90
147
  tick().then(() => renderAllPages());
91
148
  }
@@ -110,38 +167,75 @@
110
167
  pdfDoc = null;
111
168
  }
112
169
 
170
+ // Release canvas backing stores eagerly instead of waiting for GC.
171
+ for (const canvas of canvasElements) {
172
+ if (canvas) {
173
+ canvas.width = 0;
174
+ canvas.height = 0;
175
+ }
176
+ }
113
177
  canvasElements = [];
114
178
  }
115
179
 
116
180
  async function loadPdf() {
117
181
  if (!pdfjsLib) return;
118
182
 
183
+ if (!isSafeHttpUrl(url, { allowBlob: true, allowPrivateHosts: true })) {
184
+ error = 'This document URL is not allowed.';
185
+ loading = false;
186
+ return;
187
+ }
188
+
119
189
  const token = ++loadToken;
120
190
 
121
191
  loading = true;
122
192
  error = null;
123
193
 
124
- const loadingTask = (currentLoadingTask = pdfjsLib.getDocument(url));
194
+ const loadingTask = (currentLoadingTask = pdfjsLib.getDocument({
195
+ url,
196
+ ...PDFJS_DOCUMENT_OPTIONS
197
+ }));
198
+
199
+ let loadTimer: ReturnType<typeof setTimeout> | undefined;
125
200
 
126
201
  try {
127
- const doc = await loadingTask.promise;
202
+ const doc = (await Promise.race([
203
+ loadingTask.promise,
204
+ new Promise<never>((_, reject) => {
205
+ loadTimer = setTimeout(() => {
206
+ loadingTask.destroy();
207
+ reject(new Error('PDF load timed out'));
208
+ }, LOAD_TIMEOUT_MS);
209
+ })
210
+ ])) as PDFDocumentProxy;
128
211
 
129
212
  if (token !== loadToken) return;
130
213
 
131
214
  currentLoadingTask = null;
132
215
  pdfDoc = doc;
133
- pageCount = pdfDoc.numPages;
216
+ pageCount = doc.numPages;
134
217
 
135
- canvasElements = new Array(pageCount).fill(null);
218
+ canvasElements = new Array(renderedPageCount).fill(null);
219
+
220
+ // Resolve any auto-fit lock now (loading is still true, so this sets the
221
+ // scale before the first render — no flash of a wrong zoom level).
222
+ if (fitMode) await applyFit(fitMode);
223
+ if (token !== loadToken) return;
136
224
 
137
225
  loading = false;
138
226
  } catch (err) {
139
227
  if (token !== loadToken) return;
140
228
 
141
- console.error('Error loading PDF:', err);
142
- error = err instanceof Error ? err.message : 'Failed to load PDF';
229
+ console.error(
230
+ 'Error loading PDF:',
231
+ redactUrl(url),
232
+ err instanceof Error ? err.name : 'unknown'
233
+ );
234
+ error = 'Failed to load PDF';
143
235
  loading = false;
144
236
  currentLoadingTask = null;
237
+ } finally {
238
+ clearTimeout(loadTimer);
145
239
  }
146
240
  }
147
241
 
@@ -156,9 +250,22 @@
156
250
  }
157
251
  renderTasks = [];
158
252
 
159
- for (let i = 1; i <= pageCount; i++) {
160
- void renderPage(i, epoch, renderScale);
253
+ const total = renderedPageCount;
254
+ let next = 1;
255
+
256
+ const worker = async () => {
257
+ while (next <= total) {
258
+ const num = next++;
259
+ if (epoch !== renderEpoch) return;
260
+ await renderPage(num, epoch, renderScale);
261
+ }
262
+ };
263
+
264
+ const workers: Promise<void>[] = [];
265
+ for (let i = 0; i < Math.min(RENDER_CONCURRENCY, total); i++) {
266
+ workers.push(worker());
161
267
  }
268
+ void Promise.all(workers);
162
269
  }
163
270
 
164
271
  async function renderPage(num: number, epoch: number, renderScale: number) {
@@ -168,11 +275,21 @@
168
275
  const canvas = canvasElements[num - 1];
169
276
  if (!canvas) return;
170
277
 
278
+ let renderTimer: ReturnType<typeof setTimeout> | undefined;
279
+
171
280
  try {
172
281
  const page = await doc.getPage(num);
173
282
  // Bail if a newer pass started, the doc was swapped, or the canvas was remounted
174
283
  if (epoch !== renderEpoch || doc !== pdfDoc || canvas !== canvasElements[num - 1]) return;
175
284
 
285
+ const baseViewport = page.getViewport({ scale: 1 });
286
+ const dims = computeCanvasDimensions(
287
+ baseViewport.width,
288
+ baseViewport.height,
289
+ renderScale,
290
+ typeof window !== 'undefined' ? window.devicePixelRatio : 1
291
+ );
292
+
176
293
  const viewport = page.getViewport({ scale: renderScale });
177
294
 
178
295
  const context = canvas.getContext('2d');
@@ -180,16 +297,12 @@
180
297
  throw new Error('Could not get canvas context');
181
298
  }
182
299
 
183
- // Account for high-DPI/Retina displays to prevent fuzzy rendering
184
- const pixelRatio = window.devicePixelRatio || 1;
300
+ canvas.width = dims.pixelWidth;
301
+ canvas.height = dims.pixelHeight;
302
+ canvas.style.width = `${dims.cssWidth}px`;
303
+ canvas.style.height = `${dims.cssHeight}px`;
185
304
 
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);
305
+ context.scale(dims.effectivePixelRatio, dims.effectivePixelRatio);
193
306
 
194
307
  const renderTask = page.render({
195
308
  canvas,
@@ -198,33 +311,64 @@
198
311
  });
199
312
  if (epoch === renderEpoch) renderTasks[num - 1] = renderTask;
200
313
 
201
- await renderTask.promise;
314
+ await Promise.race([
315
+ renderTask.promise,
316
+ new Promise<never>((_, reject) => {
317
+ renderTimer = setTimeout(() => {
318
+ renderTask.cancel();
319
+ reject(new Error('Page render timed out'));
320
+ }, RENDER_TIMEOUT_MS);
321
+ })
322
+ ]);
202
323
  if (epoch === renderEpoch) renderTasks[num - 1] = null;
203
324
  } catch (err) {
204
325
  if (err instanceof Error && err.name === 'RenderingCancelledException') {
205
326
  return;
206
327
  }
207
- console.error('Error rendering page:', err);
328
+ console.error('Error rendering page:', num, err instanceof Error ? err.name : 'unknown');
329
+ } finally {
330
+ clearTimeout(renderTimer);
208
331
  }
209
332
  }
210
333
 
334
+ // Resolve the active fit mode against the current page + container geometry.
335
+ // Guards on document identity so a load/teardown mid-await can't set a stale scale.
336
+ async function applyFit(mode: FitMode) {
337
+ const doc = pdfDoc;
338
+ if (!doc || !containerEl) return;
339
+
340
+ const page = await doc.getPage(1);
341
+ if (doc !== pdfDoc) return;
342
+
343
+ const viewport = page.getViewport({ scale: 1 });
344
+ scale = computeFitScale(
345
+ viewport.width,
346
+ viewport.height,
347
+ containerEl.clientWidth,
348
+ containerEl.clientHeight,
349
+ mode,
350
+ { minScale, maxScale, padding: FIT_PADDING }
351
+ );
352
+ }
353
+
211
354
  function handleZoomIn() {
355
+ fitMode = null;
212
356
  scale = Math.min(scale + scaleStep, maxScale);
213
357
  }
214
358
 
215
359
  function handleZoomOut() {
360
+ fitMode = null;
216
361
  scale = Math.max(scale - scaleStep, minScale);
217
362
  }
218
363
 
219
364
  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;
365
+ fitMode = 'fit-width';
366
+ await applyFit('fit-width');
367
+ }
225
368
 
226
- const computed = (containerWidth - 48) / viewport.width;
227
- scale = Math.min(Math.max(computed, minScale), maxScale);
369
+ async function handleFitToPage() {
370
+ fitMode = 'fit-page';
371
+ await applyFit('fit-page');
228
372
  }
229
373
 
230
374
  function handleKeydown(event: KeyboardEvent) {
@@ -329,7 +473,6 @@
329
473
  aria-hidden="true"
330
474
  >
331
475
  <circle cx="11" cy="11" r="8" />
332
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
333
476
  <line x1="11" y1="8" x2="11" y2="14" />
334
477
  <line x1="8" y1="11" x2="14" y2="11" />
335
478
  </svg>
@@ -361,6 +504,32 @@
361
504
  <line x1="3" y1="21" x2="10" y2="14" />
362
505
  </svg>
363
506
  </button>
507
+
508
+ <button
509
+ type="button"
510
+ onclick={handleFitToPage}
511
+ disabled={loading}
512
+ 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"
513
+ aria-label="Fit page"
514
+ title="Fit page"
515
+ data-testid={buildTestId('pdf-viewer', 'fit-to-page', testId)}
516
+ >
517
+ <svg
518
+ xmlns="http://www.w3.org/2000/svg"
519
+ class="h-5 w-5"
520
+ viewBox="0 0 24 24"
521
+ fill="none"
522
+ stroke="currentColor"
523
+ stroke-width="2"
524
+ stroke-linecap="round"
525
+ stroke-linejoin="round"
526
+ aria-hidden="true"
527
+ >
528
+ <rect x="4" y="3" width="16" height="18" rx="1" />
529
+ <polyline points="9 8 12 5 15 8" />
530
+ <polyline points="9 16 12 19 15 16" />
531
+ </svg>
532
+ </button>
364
533
  </div>
365
534
  </div>
366
535
  {/if}
@@ -414,7 +583,24 @@
414
583
  </div>
415
584
  {:else}
416
585
  <div class="flex flex-col items-center gap-4">
417
- {#each { length: pageCount } as _, i (i)}
586
+ {#if pagesTruncated}
587
+ <div
588
+ 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"
589
+ data-testid={buildTestId('pdf-viewer', 'truncated', testId)}
590
+ >
591
+ <span>Showing the first {renderedPageCount} of {pageCount} pages.</span>
592
+ <Button
593
+ color={Color.PRIMARY}
594
+ size={Size.SM}
595
+ href={url}
596
+ target="_blank"
597
+ rel="noopener noreferrer"
598
+ >
599
+ {errorActionLabel}
600
+ </Button>
601
+ </div>
602
+ {/if}
603
+ {#each { length: renderedPageCount } as _, i (i)}
418
604
  <canvas
419
605
  bind:this={canvasElements[i]}
420
606
  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...' */