@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.
- package/dist/ai/MessageBox.svelte +2 -1
- package/dist/button/Button.svelte +89 -7
- package/dist/elements/file-viewer/FilePreviewModal.svelte +102 -0
- package/dist/elements/file-viewer/FilePreviewModal.svelte.d.ts +4 -0
- package/dist/elements/file-viewer/FileViewer.svelte +96 -0
- package/dist/elements/file-viewer/FileViewer.svelte.d.ts +4 -0
- package/dist/elements/file-viewer/file-preview-modal-types.d.ts +62 -0
- package/dist/elements/file-viewer/file-preview-modal-types.js +1 -0
- package/dist/elements/file-viewer/file-viewer-types.d.ts +51 -0
- package/dist/elements/file-viewer/file-viewer-types.js +127 -0
- package/dist/elements/image-viewer/ImageViewer.svelte +133 -0
- package/dist/elements/image-viewer/ImageViewer.svelte.d.ts +4 -0
- package/dist/elements/image-viewer/image-viewer-types.d.ts +16 -0
- package/dist/elements/image-viewer/image-viewer-types.js +1 -0
- package/dist/elements/pdf-viewer/PdfViewer.svelte +220 -34
- package/dist/elements/pdf-viewer/pdf-viewer-types.d.ts +13 -4
- package/dist/elements/text-viewer/TextViewer.svelte +141 -0
- package/dist/elements/text-viewer/TextViewer.svelte.d.ts +4 -0
- package/dist/elements/text-viewer/text-viewer-types.d.ts +10 -0
- package/dist/elements/text-viewer/text-viewer-types.js +1 -0
- package/dist/elements/viewer-shared/UnsupportedPreview.svelte +84 -0
- package/dist/elements/viewer-shared/UnsupportedPreview.svelte.d.ts +4 -0
- package/dist/elements/viewer-shared/ViewerToolbar.svelte +128 -0
- package/dist/elements/viewer-shared/ViewerToolbar.svelte.d.ts +4 -0
- package/dist/elements/viewer-shared/unsupported-preview-types.d.ts +17 -0
- package/dist/elements/viewer-shared/unsupported-preview-types.js +1 -0
- package/dist/elements/viewer-shared/viewer-toolbar-types.d.ts +14 -0
- package/dist/elements/viewer-shared/viewer-toolbar-types.js +1 -0
- package/dist/helper/is-safe-http-url.d.ts +33 -0
- package/dist/helper/is-safe-http-url.js +214 -0
- package/dist/helper/pdf-safety.d.ts +38 -0
- package/dist/helper/pdf-safety.js +85 -0
- package/dist/helper/resource-limits.d.ts +13 -0
- package/dist/helper/resource-limits.js +16 -0
- package/dist/helper/sanitize-html.d.ts +5 -0
- package/dist/helper/sanitize-html.js +12 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- 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,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 =
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
216
|
+
pageCount = doc.numPages;
|
|
134
217
|
|
|
135
|
-
canvasElements = new Array(
|
|
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(
|
|
142
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
{#
|
|
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-
|
|
8
|
-
*
|
|
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
|
-
/**
|
|
21
|
-
|
|
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...' */
|