@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.
- 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 +225 -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-assets.d.ts +42 -0
- package/dist/helper/pdf-assets.js +57 -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,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 =
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
221
|
+
pageCount = doc.numPages;
|
|
134
222
|
|
|
135
|
-
canvasElements = new Array(
|
|
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(
|
|
142
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
{#
|
|
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-
|
|
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...' */
|