@makolabs/ripple 3.4.0 → 3.5.0

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.
@@ -0,0 +1,427 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy, tick } from 'svelte';
3
+ import { browser } from '$app/environment';
4
+ import { cn } from '../../helper/cls.js';
5
+ import { buildTestId } from '../../helper/testid.js';
6
+ import Spinner from '../spinner/Spinner.svelte';
7
+ import Button from '../../button/Button.svelte';
8
+ import { Color, Size } from '../../variants.js';
9
+ import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
10
+ import type { PdfViewerProps } from './pdf-viewer-types.js';
11
+
12
+ type PdfJsModule = typeof import('pdfjs-dist');
13
+
14
+ let {
15
+ url,
16
+ initialScale = 1.5,
17
+ minScale = 0.5,
18
+ maxScale = 3,
19
+ scaleStep = 0.25,
20
+ showToolbar = true,
21
+ loadingLabel = 'Loading PDF...',
22
+ errorTitle = 'Error loading PDF',
23
+ errorActionLabel = 'Open in new tab',
24
+ class: className = '',
25
+ testId
26
+ }: PdfViewerProps = $props();
27
+
28
+ const clampScale = (value: number) => Math.min(Math.max(value, minScale), maxScale);
29
+
30
+ let containerEl: HTMLDivElement;
31
+ let canvasElements: HTMLCanvasElement[] = $state([]);
32
+ let pdfDoc: PDFDocumentProxy | null = $state(null);
33
+ let pageCount = $state(0);
34
+ let scale = $state(clampScale(initialScale));
35
+ let loading = $state(true);
36
+ let error = $state<string | null>(null);
37
+ let pdfjsLib: PdfJsModule | null = $state(null);
38
+ let currentLoadingTask: PDFDocumentLoadingTask | null = null;
39
+ let previousUrl: string | null = null;
40
+ let loadToken = 0;
41
+ let renderEpoch = 0;
42
+ let renderTasks: (RenderTask | null)[] = [];
43
+
44
+ const wrapperTestId = $derived(buildTestId('pdf-viewer', undefined, testId));
45
+
46
+ onMount(async () => {
47
+ if (!browser) return;
48
+
49
+ try {
50
+ 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;
53
+ // Assign to reactive state only after workerSrc is configured,
54
+ // so the $effect that calls loadPdf() sees a fully-ready library
55
+ pdfjsLib = lib;
56
+ } catch (err) {
57
+ console.error('Error loading PDF.js:', err);
58
+ error = 'Failed to load PDF library';
59
+ loading = false;
60
+ }
61
+ });
62
+
63
+ onDestroy(() => {
64
+ cleanupPdf();
65
+ });
66
+
67
+ $effect(() => {
68
+ if (!pdfjsLib || !browser) return;
69
+
70
+ const isFirstLoad = previousUrl === null;
71
+ const urlChanged = !isFirstLoad && previousUrl !== url;
72
+
73
+ if (!isFirstLoad && !urlChanged) {
74
+ return;
75
+ }
76
+
77
+ if (urlChanged) {
78
+ cleanupPdf();
79
+ pageCount = 0;
80
+ error = null;
81
+ }
82
+
83
+ previousUrl = url;
84
+ loadPdf();
85
+ });
86
+
87
+ $effect(() => {
88
+ if (pdfDoc && !loading && scale) {
89
+ // Wait for canvas elements to mount after loading completes
90
+ tick().then(() => renderAllPages());
91
+ }
92
+ });
93
+
94
+ function cleanupPdf() {
95
+ loadToken++;
96
+ renderEpoch++;
97
+
98
+ for (const task of renderTasks) {
99
+ if (task) task.cancel();
100
+ }
101
+ renderTasks = [];
102
+
103
+ if (currentLoadingTask) {
104
+ currentLoadingTask.destroy();
105
+ currentLoadingTask = null;
106
+ }
107
+
108
+ if (pdfDoc) {
109
+ pdfDoc.destroy();
110
+ pdfDoc = null;
111
+ }
112
+
113
+ canvasElements = [];
114
+ }
115
+
116
+ async function loadPdf() {
117
+ if (!pdfjsLib) return;
118
+
119
+ const token = ++loadToken;
120
+
121
+ loading = true;
122
+ error = null;
123
+
124
+ const loadingTask = (currentLoadingTask = pdfjsLib.getDocument(url));
125
+
126
+ try {
127
+ const doc = await loadingTask.promise;
128
+
129
+ if (token !== loadToken) return;
130
+
131
+ currentLoadingTask = null;
132
+ pdfDoc = doc;
133
+ pageCount = pdfDoc.numPages;
134
+
135
+ canvasElements = new Array(pageCount).fill(null);
136
+
137
+ loading = false;
138
+ } catch (err) {
139
+ if (token !== loadToken) return;
140
+
141
+ console.error('Error loading PDF:', err);
142
+ error = err instanceof Error ? err.message : 'Failed to load PDF';
143
+ loading = false;
144
+ currentLoadingTask = null;
145
+ }
146
+ }
147
+
148
+ async function renderAllPages() {
149
+ if (!pdfDoc) return;
150
+
151
+ const epoch = ++renderEpoch;
152
+ const renderScale = scale;
153
+
154
+ for (const task of renderTasks) {
155
+ if (task) task.cancel();
156
+ }
157
+ renderTasks = [];
158
+
159
+ for (let i = 1; i <= pageCount; i++) {
160
+ void renderPage(i, epoch, renderScale);
161
+ }
162
+ }
163
+
164
+ async function renderPage(num: number, epoch: number, renderScale: number) {
165
+ const doc = pdfDoc;
166
+ if (!doc) return;
167
+
168
+ const canvas = canvasElements[num - 1];
169
+ if (!canvas) return;
170
+
171
+ try {
172
+ const page = await doc.getPage(num);
173
+ // Bail if a newer pass started, the doc was swapped, or the canvas was remounted
174
+ if (epoch !== renderEpoch || doc !== pdfDoc || canvas !== canvasElements[num - 1]) return;
175
+
176
+ const viewport = page.getViewport({ scale: renderScale });
177
+
178
+ const context = canvas.getContext('2d');
179
+ if (!context) {
180
+ throw new Error('Could not get canvas context');
181
+ }
182
+
183
+ // Account for high-DPI/Retina displays to prevent fuzzy rendering
184
+ const pixelRatio = window.devicePixelRatio || 1;
185
+
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);
193
+
194
+ const renderTask = page.render({
195
+ canvas,
196
+ canvasContext: context,
197
+ viewport
198
+ });
199
+ if (epoch === renderEpoch) renderTasks[num - 1] = renderTask;
200
+
201
+ await renderTask.promise;
202
+ if (epoch === renderEpoch) renderTasks[num - 1] = null;
203
+ } catch (err) {
204
+ if (err instanceof Error && err.name === 'RenderingCancelledException') {
205
+ return;
206
+ }
207
+ console.error('Error rendering page:', err);
208
+ }
209
+ }
210
+
211
+ function handleZoomIn() {
212
+ scale = Math.min(scale + scaleStep, maxScale);
213
+ }
214
+
215
+ function handleZoomOut() {
216
+ scale = Math.max(scale - scaleStep, minScale);
217
+ }
218
+
219
+ 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;
225
+
226
+ const computed = (containerWidth - 48) / viewport.width;
227
+ scale = Math.min(Math.max(computed, minScale), maxScale);
228
+ }
229
+
230
+ function handleKeydown(event: KeyboardEvent) {
231
+ if (loading) return;
232
+
233
+ switch (event.key) {
234
+ case '+':
235
+ case '=':
236
+ event.preventDefault();
237
+ handleZoomIn();
238
+ break;
239
+ case '-':
240
+ event.preventDefault();
241
+ handleZoomOut();
242
+ break;
243
+ }
244
+ }
245
+ </script>
246
+
247
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
248
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
249
+ <div
250
+ class={cn(
251
+ 'focus-visible:ring-primary-400 flex h-full flex-col focus-visible:ring-2 focus-visible:outline-none',
252
+ className
253
+ )}
254
+ onkeydown={handleKeydown}
255
+ tabindex="0"
256
+ role="region"
257
+ aria-label="PDF viewer"
258
+ data-testid={wrapperTestId}
259
+ >
260
+ {#if showToolbar}
261
+ <div
262
+ class="border-default-200 bg-default-50 flex items-center justify-between border-b px-4 py-2"
263
+ data-testid={buildTestId('pdf-viewer', 'toolbar', testId)}
264
+ >
265
+ <div class="flex items-center gap-2">
266
+ <span class="text-default-700 text-sm" aria-live="polite">
267
+ {#if loading}
268
+ &nbsp;
269
+ {:else}
270
+ {pageCount}
271
+ {pageCount === 1 ? 'page' : 'pages'}
272
+ {/if}
273
+ </span>
274
+ </div>
275
+
276
+ <div class="flex items-center gap-1">
277
+ <button
278
+ type="button"
279
+ onclick={handleZoomOut}
280
+ disabled={loading || scale <= minScale}
281
+ 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"
282
+ aria-label="Zoom out"
283
+ title="Zoom out"
284
+ data-testid={buildTestId('pdf-viewer', 'zoom-out', testId)}
285
+ >
286
+ <svg
287
+ xmlns="http://www.w3.org/2000/svg"
288
+ class="h-5 w-5"
289
+ viewBox="0 0 24 24"
290
+ fill="none"
291
+ stroke="currentColor"
292
+ stroke-width="2"
293
+ stroke-linecap="round"
294
+ stroke-linejoin="round"
295
+ aria-hidden="true"
296
+ >
297
+ <circle cx="11" cy="11" r="8" />
298
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
299
+ <line x1="8" y1="11" x2="14" y2="11" />
300
+ </svg>
301
+ </button>
302
+
303
+ <span
304
+ class="text-default-700 min-w-[3.5rem] text-center text-sm tabular-nums"
305
+ aria-live="polite"
306
+ data-testid={buildTestId('pdf-viewer', 'scale', testId)}
307
+ >
308
+ {Math.round(scale * 100)}%
309
+ </span>
310
+
311
+ <button
312
+ type="button"
313
+ onclick={handleZoomIn}
314
+ disabled={loading || scale >= maxScale}
315
+ 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"
316
+ aria-label="Zoom in"
317
+ title="Zoom in"
318
+ data-testid={buildTestId('pdf-viewer', 'zoom-in', testId)}
319
+ >
320
+ <svg
321
+ xmlns="http://www.w3.org/2000/svg"
322
+ class="h-5 w-5"
323
+ viewBox="0 0 24 24"
324
+ fill="none"
325
+ stroke="currentColor"
326
+ stroke-width="2"
327
+ stroke-linecap="round"
328
+ stroke-linejoin="round"
329
+ aria-hidden="true"
330
+ >
331
+ <circle cx="11" cy="11" r="8" />
332
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
333
+ <line x1="11" y1="8" x2="11" y2="14" />
334
+ <line x1="8" y1="11" x2="14" y2="11" />
335
+ </svg>
336
+ </button>
337
+
338
+ <button
339
+ type="button"
340
+ onclick={handleFitToWidth}
341
+ disabled={loading}
342
+ 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"
343
+ aria-label="Fit to width"
344
+ title="Fit to width"
345
+ data-testid={buildTestId('pdf-viewer', 'fit-to-width', testId)}
346
+ >
347
+ <svg
348
+ xmlns="http://www.w3.org/2000/svg"
349
+ class="h-5 w-5"
350
+ viewBox="0 0 24 24"
351
+ fill="none"
352
+ stroke="currentColor"
353
+ stroke-width="2"
354
+ stroke-linecap="round"
355
+ stroke-linejoin="round"
356
+ aria-hidden="true"
357
+ >
358
+ <polyline points="15 3 21 3 21 9" />
359
+ <polyline points="9 21 3 21 3 15" />
360
+ <line x1="21" y1="3" x2="14" y2="10" />
361
+ <line x1="3" y1="21" x2="10" y2="14" />
362
+ </svg>
363
+ </button>
364
+ </div>
365
+ </div>
366
+ {/if}
367
+
368
+ <div
369
+ bind:this={containerEl}
370
+ class="bg-default-100 relative flex-1 overflow-auto p-4"
371
+ data-testid={buildTestId('pdf-viewer', 'canvas-area', testId)}
372
+ >
373
+ {#if loading}
374
+ <div class="absolute inset-0 flex items-center justify-center">
375
+ <Spinner size={Size.LG} color={Color.PRIMARY} label={loadingLabel} />
376
+ </div>
377
+ {:else if error}
378
+ <div class="absolute inset-0 flex items-center justify-center">
379
+ <div
380
+ class="ring-default-200 max-w-md rounded-xl bg-white p-6 text-center shadow-sm ring-1"
381
+ data-testid={buildTestId('pdf-viewer', 'error', testId)}
382
+ >
383
+ <div
384
+ class="bg-danger-50 text-danger-600 mx-auto mb-3 flex size-10 items-center justify-center rounded-full"
385
+ aria-hidden="true"
386
+ >
387
+ <svg
388
+ xmlns="http://www.w3.org/2000/svg"
389
+ class="h-5 w-5"
390
+ viewBox="0 0 24 24"
391
+ fill="none"
392
+ stroke="currentColor"
393
+ stroke-width="2"
394
+ stroke-linecap="round"
395
+ stroke-linejoin="round"
396
+ >
397
+ <circle cx="12" cy="12" r="10" />
398
+ <line x1="12" y1="8" x2="12" y2="12" />
399
+ <line x1="12" y1="16" x2="12.01" y2="16" />
400
+ </svg>
401
+ </div>
402
+ <p class="text-default-900 mb-1 text-base font-semibold">{errorTitle}</p>
403
+ <p class="text-default-600 mb-4 text-sm">{error}</p>
404
+ <Button
405
+ color={Color.PRIMARY}
406
+ size={Size.SM}
407
+ href={url}
408
+ target="_blank"
409
+ rel="noopener noreferrer"
410
+ >
411
+ {errorActionLabel}
412
+ </Button>
413
+ </div>
414
+ </div>
415
+ {:else}
416
+ <div class="flex flex-col items-center gap-4">
417
+ {#each { length: pageCount } as _, i (i)}
418
+ <canvas
419
+ bind:this={canvasElements[i]}
420
+ class="ring-default-200 block rounded-sm bg-white shadow-sm ring-1"
421
+ data-testid={buildTestId('pdf-viewer', 'page', testId, i + 1)}
422
+ ></canvas>
423
+ {/each}
424
+ </div>
425
+ {/if}
426
+ </div>
427
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { PdfViewerProps } from './pdf-viewer-types.js';
2
+ declare const PdfViewer: import("svelte").Component<PdfViewerProps, {}, "">;
3
+ type PdfViewer = ReturnType<typeof PdfViewer>;
4
+ export default PdfViewer;
@@ -0,0 +1,38 @@
1
+ import type { ClassValue } from 'tailwind-variants';
2
+ /**
3
+ * Props for `<PdfViewer>` — a scrollable, zoomable canvas-based PDF
4
+ * viewer powered by `pdfjs-dist` (loaded dynamically on the client).
5
+ *
6
+ * 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.
9
+ *
10
+ * **Peer dependency:** install `pdfjs-dist` in your application.
11
+ *
12
+ * @example
13
+ * ```svelte
14
+ * <PdfViewer url="/files/invoice.pdf" class="h-[80vh]" />
15
+ * ```
16
+ */
17
+ export type PdfViewerProps = {
18
+ /** URL of the PDF document. Changing the URL reloads the viewer. */
19
+ url: string;
20
+ /** Initial zoom scale. @default 1.5 */
21
+ initialScale?: number;
22
+ /** Minimum allowed zoom scale. @default 0.5 */
23
+ minScale?: number;
24
+ /** Maximum allowed zoom scale. @default 3 */
25
+ maxScale?: number;
26
+ /** Step used by the zoom-in / zoom-out buttons. @default 0.25 */
27
+ scaleStep?: number;
28
+ /** Show the zoom / page-count toolbar. @default true */
29
+ showToolbar?: boolean;
30
+ /** Visible label inside the loading spinner. @default 'Loading PDF...' */
31
+ loadingLabel?: string;
32
+ /** Heading shown when the PDF fails to load. @default 'Error loading PDF' */
33
+ errorTitle?: string;
34
+ /** Label of the fallback "open in new tab" link in the error state. @default 'Open in new tab' */
35
+ errorActionLabel?: string;
36
+ class?: ClassValue;
37
+ testId?: string;
38
+ };
@@ -0,0 +1 @@
1
+ export {};