@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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { cn } from '../helper/cls.js';
|
|
3
3
|
import { formatTime } from '../helper/date.js';
|
|
4
4
|
import { marked } from 'marked';
|
|
5
|
+
import { sanitizeHtml } from '../helper/sanitize-html.js';
|
|
5
6
|
import { tv, type ClassValue } from 'tailwind-variants';
|
|
6
7
|
import type { VariantColors } from '../index.js';
|
|
7
8
|
import { parseContentSegments } from './content-detector.js';
|
|
@@ -69,7 +70,7 @@
|
|
|
69
70
|
|
|
70
71
|
function renderMarkdown(text: string): string {
|
|
71
72
|
const result = marked(text);
|
|
72
|
-
return typeof result === 'string' ? result : '';
|
|
73
|
+
return sanitizeHtml(typeof result === 'string' ? result : '');
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
// Parse content into segments for enhanced rendering
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../helper/cls.js';
|
|
3
3
|
import { buildTestId } from '../helper/testid.js';
|
|
4
|
+
import { isSafeLinkHref } from '../helper/is-safe-http-url.js';
|
|
4
5
|
import { buttonVariants } from './button.js';
|
|
5
6
|
import type { ButtonProps } from '../index.js';
|
|
7
|
+
import type { HTMLAttributeAnchorTarget } from 'svelte/elements';
|
|
6
8
|
import { Color, Size } from '../variants.js';
|
|
7
9
|
|
|
8
10
|
let {
|
|
@@ -33,20 +35,100 @@
|
|
|
33
35
|
className
|
|
34
36
|
)
|
|
35
37
|
);
|
|
38
|
+
|
|
39
|
+
// Drop dangerous spread attributes: string event handlers (function
|
|
40
|
+
// handlers like onclick={fn} are kept) and unsafe formaction.
|
|
41
|
+
function dropDangerous(props: Record<string, unknown>): Record<string, unknown> {
|
|
42
|
+
const out: Record<string, unknown> = {};
|
|
43
|
+
for (const [key, value] of Object.entries(props)) {
|
|
44
|
+
const lower = key.toLowerCase();
|
|
45
|
+
if (lower.startsWith('on') && typeof value === 'string') continue;
|
|
46
|
+
if (lower === 'formaction' && (typeof value !== 'string' || !isSafeLinkHref(value))) continue;
|
|
47
|
+
out[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ANCHOR_CONTROLLED = new Set(['href', 'target', 'rel', 'referrerpolicy']);
|
|
53
|
+
|
|
54
|
+
// restProps is a discriminated union; read anchor-only attrs via a record view.
|
|
55
|
+
const rest = $derived(restProps as Record<string, unknown>);
|
|
56
|
+
|
|
57
|
+
const rawHref = $derived(rest['href'] as string | undefined);
|
|
58
|
+
const isAnchor = $derived(typeof rawHref === 'string');
|
|
59
|
+
const safeHref = $derived(
|
|
60
|
+
typeof rawHref === 'string' && isSafeLinkHref(rawHref) ? rawHref : undefined
|
|
61
|
+
);
|
|
62
|
+
const anchorTarget = $derived(rest['target'] as HTMLAttributeAnchorTarget | undefined);
|
|
63
|
+
|
|
64
|
+
// Reverse-tabnabbing protection: opening a new browsing context requires
|
|
65
|
+
// `noopener`. A consumer `rel` (e.g. "nofollow") must not be able to silently
|
|
66
|
+
// drop it, so we merge the token in rather than treating rel as all-or-nothing.
|
|
67
|
+
// `noreferrer` already implies `noopener`, so either token satisfies the guard.
|
|
68
|
+
function hardenRel(
|
|
69
|
+
callerRel: string | undefined,
|
|
70
|
+
target: string | undefined
|
|
71
|
+
): string | undefined {
|
|
72
|
+
if (target !== '_blank') return callerRel;
|
|
73
|
+
if (callerRel === undefined) return 'noopener noreferrer';
|
|
74
|
+
const tokens = callerRel.split(/\s+/).filter(Boolean);
|
|
75
|
+
if (!tokens.some((t) => t === 'noopener' || t === 'noreferrer')) tokens.push('noopener');
|
|
76
|
+
return tokens.join(' ');
|
|
77
|
+
}
|
|
78
|
+
const anchorRel = $derived(hardenRel(rest['rel'] as string | undefined, anchorTarget));
|
|
79
|
+
const anchorReferrerPolicy = $derived(
|
|
80
|
+
(rest['referrerpolicy'] as ReferrerPolicy | undefined) ??
|
|
81
|
+
(anchorTarget === '_blank' && rest['rel'] === undefined
|
|
82
|
+
? ('no-referrer' as ReferrerPolicy)
|
|
83
|
+
: undefined)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const buttonAttrs = $derived(dropDangerous(rest));
|
|
87
|
+
const anchorAttrs = $derived(
|
|
88
|
+
Object.fromEntries(
|
|
89
|
+
Object.entries(dropDangerous(rest)).filter(([k]) => !ANCHOR_CONTROLLED.has(k.toLowerCase()))
|
|
90
|
+
)
|
|
91
|
+
);
|
|
36
92
|
</script>
|
|
37
93
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
94
|
+
<!--
|
|
95
|
+
Button renders arbitrary, scheme-guarded hrefs (including external URLs), so
|
|
96
|
+
the SvelteKit resolve() route helper does not apply here. safeHref is vetted by
|
|
97
|
+
isSafeLinkHref before it ever reaches the DOM.
|
|
98
|
+
-->
|
|
99
|
+
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
|
100
|
+
{#if isAnchor}
|
|
101
|
+
{#if safeHref && !disabled && !loading}
|
|
102
|
+
<a
|
|
103
|
+
class={buttonClasses}
|
|
104
|
+
data-testid={buildTestId('button', undefined, testId)}
|
|
105
|
+
{...anchorAttrs}
|
|
106
|
+
href={safeHref}
|
|
107
|
+
target={anchorTarget}
|
|
108
|
+
rel={anchorRel}
|
|
109
|
+
referrerpolicy={anchorReferrerPolicy}
|
|
110
|
+
>
|
|
111
|
+
{#if children}
|
|
112
|
+
{@render children()}
|
|
113
|
+
{/if}
|
|
114
|
+
</a>
|
|
115
|
+
{:else}
|
|
116
|
+
<span
|
|
117
|
+
class={cn(buttonClasses, 'pointer-events-none cursor-not-allowed opacity-50')}
|
|
118
|
+
data-testid={buildTestId('button', undefined, testId)}
|
|
119
|
+
aria-disabled="true"
|
|
120
|
+
>
|
|
121
|
+
{#if children}
|
|
122
|
+
{@render children()}
|
|
123
|
+
{/if}
|
|
124
|
+
</span>
|
|
125
|
+
{/if}
|
|
44
126
|
{:else}
|
|
45
127
|
<button
|
|
46
128
|
class={buttonClasses}
|
|
47
129
|
data-testid={buildTestId('button', undefined, testId)}
|
|
48
130
|
disabled={disabled || loading}
|
|
49
|
-
{...
|
|
131
|
+
{...buttonAttrs}
|
|
50
132
|
>
|
|
51
133
|
{#if children}
|
|
52
134
|
{@render children()}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../../helper/cls.js';
|
|
3
|
+
import Modal from '../../modal/Modal.svelte';
|
|
4
|
+
import Button from '../../button/Button.svelte';
|
|
5
|
+
import FileViewer from './FileViewer.svelte';
|
|
6
|
+
import { Color, Size } from '../../variants.js';
|
|
7
|
+
import type { FilePreviewModalProps } from './file-preview-modal-types.js';
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
open = $bindable(false),
|
|
11
|
+
url,
|
|
12
|
+
kind,
|
|
13
|
+
mimeType,
|
|
14
|
+
fileName,
|
|
15
|
+
height = '70vh',
|
|
16
|
+
size = Size.XL,
|
|
17
|
+
showDownload = true,
|
|
18
|
+
downloadUrl,
|
|
19
|
+
downloadLabel = 'Download',
|
|
20
|
+
closeLabel = 'Close',
|
|
21
|
+
onclose,
|
|
22
|
+
showToolbar,
|
|
23
|
+
initialScale,
|
|
24
|
+
minScale,
|
|
25
|
+
maxScale,
|
|
26
|
+
scaleStep,
|
|
27
|
+
maxPages,
|
|
28
|
+
maxImagePixels,
|
|
29
|
+
maxBytes,
|
|
30
|
+
loadingLabel,
|
|
31
|
+
errorTitle,
|
|
32
|
+
errorActionLabel,
|
|
33
|
+
class: className,
|
|
34
|
+
testId,
|
|
35
|
+
actions
|
|
36
|
+
}: FilePreviewModalProps = $props();
|
|
37
|
+
|
|
38
|
+
function handleClose() {
|
|
39
|
+
open = false;
|
|
40
|
+
onclose?.();
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<Modal
|
|
45
|
+
bind:open
|
|
46
|
+
title={fileName}
|
|
47
|
+
{size}
|
|
48
|
+
onclose={handleClose}
|
|
49
|
+
bodyClass="overflow-hidden p-0"
|
|
50
|
+
{testId}
|
|
51
|
+
>
|
|
52
|
+
<!--
|
|
53
|
+
Explicit height + non-scrolling body: gives the viewer a genuinely definite
|
|
54
|
+
height so its toolbar stays pinned and its own content area scrolls (a
|
|
55
|
+
flex-derived height would collapse through FileViewer's h-full).
|
|
56
|
+
-->
|
|
57
|
+
<div style="height: {height}">
|
|
58
|
+
<FileViewer
|
|
59
|
+
{url}
|
|
60
|
+
{kind}
|
|
61
|
+
{mimeType}
|
|
62
|
+
{fileName}
|
|
63
|
+
class={cn('h-full', className)}
|
|
64
|
+
{showToolbar}
|
|
65
|
+
{initialScale}
|
|
66
|
+
{minScale}
|
|
67
|
+
{maxScale}
|
|
68
|
+
{scaleStep}
|
|
69
|
+
{maxPages}
|
|
70
|
+
{maxImagePixels}
|
|
71
|
+
{maxBytes}
|
|
72
|
+
{loadingLabel}
|
|
73
|
+
{errorTitle}
|
|
74
|
+
{errorActionLabel}
|
|
75
|
+
{testId}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{#snippet footer()}
|
|
80
|
+
{@render actions?.()}
|
|
81
|
+
{#if showDownload}
|
|
82
|
+
<Button
|
|
83
|
+
variant="outline"
|
|
84
|
+
color={Color.DEFAULT}
|
|
85
|
+
href={downloadUrl ?? url}
|
|
86
|
+
target="_blank"
|
|
87
|
+
download={fileName ?? true}
|
|
88
|
+
data-testid="file-preview-download"
|
|
89
|
+
>
|
|
90
|
+
{downloadLabel}
|
|
91
|
+
</Button>
|
|
92
|
+
{/if}
|
|
93
|
+
<Button
|
|
94
|
+
variant="outline"
|
|
95
|
+
color={Color.DEFAULT}
|
|
96
|
+
onclick={handleClose}
|
|
97
|
+
data-testid="file-preview-close"
|
|
98
|
+
>
|
|
99
|
+
{closeLabel}
|
|
100
|
+
</Button>
|
|
101
|
+
{/snippet}
|
|
102
|
+
</Modal>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../../helper/cls.js';
|
|
3
|
+
import { buildTestId } from '../../helper/testid.js';
|
|
4
|
+
import PdfViewer from '../pdf-viewer/PdfViewer.svelte';
|
|
5
|
+
import ImageViewer from '../image-viewer/ImageViewer.svelte';
|
|
6
|
+
import TextViewer from '../text-viewer/TextViewer.svelte';
|
|
7
|
+
import UnsupportedPreview from '../viewer-shared/UnsupportedPreview.svelte';
|
|
8
|
+
import {
|
|
9
|
+
FileKind,
|
|
10
|
+
fileKindFromMime,
|
|
11
|
+
fileKindFromFileName,
|
|
12
|
+
type FileViewerProps
|
|
13
|
+
} from './file-viewer-types.js';
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
url,
|
|
17
|
+
kind,
|
|
18
|
+
mimeType,
|
|
19
|
+
fileName,
|
|
20
|
+
showToolbar = true,
|
|
21
|
+
initialScale,
|
|
22
|
+
minScale,
|
|
23
|
+
maxScale,
|
|
24
|
+
scaleStep,
|
|
25
|
+
maxPages,
|
|
26
|
+
maxImagePixels,
|
|
27
|
+
maxBytes,
|
|
28
|
+
loadingLabel,
|
|
29
|
+
errorTitle,
|
|
30
|
+
errorActionLabel,
|
|
31
|
+
class: className = '',
|
|
32
|
+
testId
|
|
33
|
+
}: FileViewerProps = $props();
|
|
34
|
+
|
|
35
|
+
// Resolution order: explicit kind → mimeType → fileName → UNKNOWN. Each source
|
|
36
|
+
// only wins if it yields a known kind, so a junk mimeType still lets fileName
|
|
37
|
+
// decide.
|
|
38
|
+
const resolvedKind = $derived.by(() => {
|
|
39
|
+
if (kind) return kind;
|
|
40
|
+
if (mimeType) {
|
|
41
|
+
const fromMime = fileKindFromMime(mimeType);
|
|
42
|
+
if (fromMime !== FileKind.UNKNOWN) return fromMime;
|
|
43
|
+
}
|
|
44
|
+
if (fileName) {
|
|
45
|
+
const fromName = fileKindFromFileName(fileName);
|
|
46
|
+
if (fromName !== FileKind.UNKNOWN) return fromName;
|
|
47
|
+
}
|
|
48
|
+
return FileKind.UNKNOWN;
|
|
49
|
+
});
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<div
|
|
53
|
+
class={cn('h-full w-full', className)}
|
|
54
|
+
data-testid={buildTestId('file-viewer', undefined, testId)}
|
|
55
|
+
>
|
|
56
|
+
{#if resolvedKind === FileKind.PDF}
|
|
57
|
+
<PdfViewer
|
|
58
|
+
{url}
|
|
59
|
+
{showToolbar}
|
|
60
|
+
{initialScale}
|
|
61
|
+
{minScale}
|
|
62
|
+
{maxScale}
|
|
63
|
+
{scaleStep}
|
|
64
|
+
{maxPages}
|
|
65
|
+
{loadingLabel}
|
|
66
|
+
{errorTitle}
|
|
67
|
+
{errorActionLabel}
|
|
68
|
+
{testId}
|
|
69
|
+
/>
|
|
70
|
+
{:else if resolvedKind === FileKind.IMAGE}
|
|
71
|
+
<ImageViewer
|
|
72
|
+
{url}
|
|
73
|
+
{showToolbar}
|
|
74
|
+
initialScale={typeof initialScale === 'number' ? initialScale : undefined}
|
|
75
|
+
{minScale}
|
|
76
|
+
{maxScale}
|
|
77
|
+
{scaleStep}
|
|
78
|
+
{maxImagePixels}
|
|
79
|
+
{loadingLabel}
|
|
80
|
+
{errorTitle}
|
|
81
|
+
{errorActionLabel}
|
|
82
|
+
{testId}
|
|
83
|
+
/>
|
|
84
|
+
{:else if resolvedKind === FileKind.TEXT}
|
|
85
|
+
<TextViewer {url} {maxBytes} {loadingLabel} {errorTitle} {errorActionLabel} {testId} />
|
|
86
|
+
{:else}
|
|
87
|
+
<UnsupportedPreview
|
|
88
|
+
{url}
|
|
89
|
+
{fileName}
|
|
90
|
+
title={errorTitle ?? 'Preview not available'}
|
|
91
|
+
message="This file type can't be previewed."
|
|
92
|
+
actionLabel={errorActionLabel}
|
|
93
|
+
{testId}
|
|
94
|
+
/>
|
|
95
|
+
{/if}
|
|
96
|
+
</div>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ClassValue } from 'tailwind-variants';
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { VariantSizes } from '../../index.js';
|
|
4
|
+
import type { FileKindValue } from './file-viewer-types.js';
|
|
5
|
+
import type { FitMode } from '../../helper/pdf-safety.js';
|
|
6
|
+
/**
|
|
7
|
+
* Props for `<FilePreviewModal>` — a `<Modal>` that previews a file with the
|
|
8
|
+
* unified `<FileViewer>`, correctly sized (toolbar pinned, content scrolls) with
|
|
9
|
+
* an optional download action and a close button.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```svelte
|
|
13
|
+
* <FilePreviewModal
|
|
14
|
+
* bind:open
|
|
15
|
+
* url={file.url}
|
|
16
|
+
* mimeType={file.mimeType}
|
|
17
|
+
* fileName={file.name}
|
|
18
|
+
* />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export type FilePreviewModalProps = {
|
|
22
|
+
/** Bindable open state. @default false */
|
|
23
|
+
open?: boolean;
|
|
24
|
+
/** URL of the file. */
|
|
25
|
+
url: string;
|
|
26
|
+
/** Explicit file kind. Optional — derived from `mimeType`/`fileName` otherwise. */
|
|
27
|
+
kind?: FileKindValue;
|
|
28
|
+
/** MIME type used to derive `kind`. */
|
|
29
|
+
mimeType?: string;
|
|
30
|
+
/** File name — the modal title, and used to derive `kind` and name the download. */
|
|
31
|
+
fileName?: string;
|
|
32
|
+
/** Height of the viewer inside the modal (any CSS length). @default '70vh' */
|
|
33
|
+
height?: string;
|
|
34
|
+
/** Modal size. @default 'xl' */
|
|
35
|
+
size?: VariantSizes;
|
|
36
|
+
/** Show a download action in the footer. @default true */
|
|
37
|
+
showDownload?: boolean;
|
|
38
|
+
/** URL for the download action. @default `url` */
|
|
39
|
+
downloadUrl?: string;
|
|
40
|
+
/** @default 'Download' */
|
|
41
|
+
downloadLabel?: string;
|
|
42
|
+
/** @default 'Close' */
|
|
43
|
+
closeLabel?: string;
|
|
44
|
+
/** Fires when the modal is dismissed (close button, × , backdrop, Escape). */
|
|
45
|
+
onclose?: () => void;
|
|
46
|
+
showToolbar?: boolean;
|
|
47
|
+
/** PDF initial zoom: a fixed scale, or `'fit-width'` / `'fit-page'`. @default 'fit-width' */
|
|
48
|
+
initialScale?: number | FitMode;
|
|
49
|
+
minScale?: number;
|
|
50
|
+
maxScale?: number;
|
|
51
|
+
scaleStep?: number;
|
|
52
|
+
maxPages?: number;
|
|
53
|
+
maxImagePixels?: number;
|
|
54
|
+
maxBytes?: number;
|
|
55
|
+
loadingLabel?: string;
|
|
56
|
+
errorTitle?: string;
|
|
57
|
+
errorActionLabel?: string;
|
|
58
|
+
class?: ClassValue;
|
|
59
|
+
testId?: string;
|
|
60
|
+
/** Extra footer content, rendered left of the Download/Close buttons. */
|
|
61
|
+
actions?: Snippet;
|
|
62
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ClassValue } from 'tailwind-variants';
|
|
2
|
+
import type { FitMode } from '../../helper/pdf-safety.js';
|
|
3
|
+
/** Coarse file category that selects which viewer renders the file. */
|
|
4
|
+
export declare const FileKind: {
|
|
5
|
+
readonly PDF: "pdf";
|
|
6
|
+
readonly IMAGE: "image";
|
|
7
|
+
readonly TEXT: "text";
|
|
8
|
+
readonly OFFICE: "office";
|
|
9
|
+
readonly UNKNOWN: "unknown";
|
|
10
|
+
};
|
|
11
|
+
export type FileKindValue = (typeof FileKind)[keyof typeof FileKind];
|
|
12
|
+
/** Map a MIME type to a FileKind. Unknown/junk maps to UNKNOWN (never a render path). */
|
|
13
|
+
export declare function fileKindFromMime(mime: string): FileKindValue;
|
|
14
|
+
/**
|
|
15
|
+
* Map a file name (or URL/path) to a FileKind by its extension. Query strings,
|
|
16
|
+
* fragments, and directories are ignored. Unknown/missing extensions map to
|
|
17
|
+
* UNKNOWN. Use when you don't have a MIME type; prefer `fileKindFromMime` when
|
|
18
|
+
* you do.
|
|
19
|
+
*/
|
|
20
|
+
export declare function fileKindFromFileName(name: string): FileKindValue;
|
|
21
|
+
/** Props for the `<FileViewer>` router component. */
|
|
22
|
+
export type FileViewerProps = {
|
|
23
|
+
/** URL of the file. */
|
|
24
|
+
url: string;
|
|
25
|
+
/**
|
|
26
|
+
* Explicit file kind. Optional — if omitted, it is derived from `mimeType`,
|
|
27
|
+
* then from `fileName`, falling back to `UNKNOWN` (the download fallback).
|
|
28
|
+
*/
|
|
29
|
+
kind?: FileKindValue;
|
|
30
|
+
/** MIME type used to derive `kind` when `kind` is not given. */
|
|
31
|
+
mimeType?: string;
|
|
32
|
+
/**
|
|
33
|
+
* File name, shown in the unsupported/download fallback and used to derive
|
|
34
|
+
* `kind` (by extension) when neither `kind` nor `mimeType` is given.
|
|
35
|
+
*/
|
|
36
|
+
fileName?: string;
|
|
37
|
+
showToolbar?: boolean;
|
|
38
|
+
/** PDF initial zoom: a fixed scale, or `'fit-width'` / `'fit-page'`. @default 'fit-width' */
|
|
39
|
+
initialScale?: number | FitMode;
|
|
40
|
+
minScale?: number;
|
|
41
|
+
maxScale?: number;
|
|
42
|
+
scaleStep?: number;
|
|
43
|
+
maxPages?: number;
|
|
44
|
+
maxImagePixels?: number;
|
|
45
|
+
maxBytes?: number;
|
|
46
|
+
loadingLabel?: string;
|
|
47
|
+
errorTitle?: string;
|
|
48
|
+
errorActionLabel?: string;
|
|
49
|
+
class?: ClassValue;
|
|
50
|
+
testId?: string;
|
|
51
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** Coarse file category that selects which viewer renders the file. */
|
|
2
|
+
export const FileKind = {
|
|
3
|
+
PDF: 'pdf',
|
|
4
|
+
IMAGE: 'image',
|
|
5
|
+
TEXT: 'text',
|
|
6
|
+
OFFICE: 'office',
|
|
7
|
+
UNKNOWN: 'unknown'
|
|
8
|
+
};
|
|
9
|
+
const TEXT_APPLICATION_TYPES = new Set([
|
|
10
|
+
'application/json',
|
|
11
|
+
'application/xml',
|
|
12
|
+
'application/javascript',
|
|
13
|
+
'application/typescript',
|
|
14
|
+
'application/x-ndjson',
|
|
15
|
+
'application/csv'
|
|
16
|
+
]);
|
|
17
|
+
const OFFICE_TYPES = new Set([
|
|
18
|
+
'application/msword',
|
|
19
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
20
|
+
'application/vnd.ms-excel',
|
|
21
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
22
|
+
'application/vnd.ms-powerpoint',
|
|
23
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
24
|
+
]);
|
|
25
|
+
/** Map a MIME type to a FileKind. Unknown/junk maps to UNKNOWN (never a render path). */
|
|
26
|
+
export function fileKindFromMime(mime) {
|
|
27
|
+
if (typeof mime !== 'string')
|
|
28
|
+
return FileKind.UNKNOWN;
|
|
29
|
+
const type = mime.toLowerCase().split(';')[0].trim();
|
|
30
|
+
if (!type)
|
|
31
|
+
return FileKind.UNKNOWN;
|
|
32
|
+
if (type === 'application/pdf')
|
|
33
|
+
return FileKind.PDF;
|
|
34
|
+
if (type.startsWith('image/'))
|
|
35
|
+
return FileKind.IMAGE;
|
|
36
|
+
if (type.startsWith('text/'))
|
|
37
|
+
return FileKind.TEXT;
|
|
38
|
+
if (TEXT_APPLICATION_TYPES.has(type))
|
|
39
|
+
return FileKind.TEXT;
|
|
40
|
+
if (OFFICE_TYPES.has(type))
|
|
41
|
+
return FileKind.OFFICE;
|
|
42
|
+
return FileKind.UNKNOWN;
|
|
43
|
+
}
|
|
44
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
45
|
+
'png',
|
|
46
|
+
'jpg',
|
|
47
|
+
'jpeg',
|
|
48
|
+
'gif',
|
|
49
|
+
'webp',
|
|
50
|
+
'svg',
|
|
51
|
+
'bmp',
|
|
52
|
+
'avif',
|
|
53
|
+
'ico',
|
|
54
|
+
'tiff',
|
|
55
|
+
'tif'
|
|
56
|
+
]);
|
|
57
|
+
// Rendered as plain text (textContent), so source-y formats are safe here.
|
|
58
|
+
const TEXT_EXTENSIONS = new Set([
|
|
59
|
+
'txt',
|
|
60
|
+
'text',
|
|
61
|
+
'md',
|
|
62
|
+
'markdown',
|
|
63
|
+
'csv',
|
|
64
|
+
'tsv',
|
|
65
|
+
'json',
|
|
66
|
+
'ndjson',
|
|
67
|
+
'xml',
|
|
68
|
+
'yml',
|
|
69
|
+
'yaml',
|
|
70
|
+
'log',
|
|
71
|
+
'js',
|
|
72
|
+
'mjs',
|
|
73
|
+
'cjs',
|
|
74
|
+
'ts',
|
|
75
|
+
'tsx',
|
|
76
|
+
'jsx',
|
|
77
|
+
'css',
|
|
78
|
+
'scss',
|
|
79
|
+
'less',
|
|
80
|
+
'html',
|
|
81
|
+
'htm',
|
|
82
|
+
'py',
|
|
83
|
+
'rb',
|
|
84
|
+
'go',
|
|
85
|
+
'rs',
|
|
86
|
+
'java',
|
|
87
|
+
'kt',
|
|
88
|
+
'c',
|
|
89
|
+
'h',
|
|
90
|
+
'cc',
|
|
91
|
+
'cpp',
|
|
92
|
+
'hpp',
|
|
93
|
+
'sh',
|
|
94
|
+
'bash',
|
|
95
|
+
'zsh',
|
|
96
|
+
'sql',
|
|
97
|
+
'ini',
|
|
98
|
+
'toml',
|
|
99
|
+
'conf',
|
|
100
|
+
'env'
|
|
101
|
+
]);
|
|
102
|
+
const OFFICE_EXTENSIONS = new Set(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']);
|
|
103
|
+
/**
|
|
104
|
+
* Map a file name (or URL/path) to a FileKind by its extension. Query strings,
|
|
105
|
+
* fragments, and directories are ignored. Unknown/missing extensions map to
|
|
106
|
+
* UNKNOWN. Use when you don't have a MIME type; prefer `fileKindFromMime` when
|
|
107
|
+
* you do.
|
|
108
|
+
*/
|
|
109
|
+
export function fileKindFromFileName(name) {
|
|
110
|
+
if (typeof name !== 'string')
|
|
111
|
+
return FileKind.UNKNOWN;
|
|
112
|
+
// Drop query/fragment, then isolate the final path segment.
|
|
113
|
+
const base = name.split(/[?#]/)[0].split('/').pop() ?? '';
|
|
114
|
+
const dot = base.lastIndexOf('.');
|
|
115
|
+
const ext = dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
|
|
116
|
+
if (!ext)
|
|
117
|
+
return FileKind.UNKNOWN;
|
|
118
|
+
if (ext === 'pdf')
|
|
119
|
+
return FileKind.PDF;
|
|
120
|
+
if (IMAGE_EXTENSIONS.has(ext))
|
|
121
|
+
return FileKind.IMAGE;
|
|
122
|
+
if (TEXT_EXTENSIONS.has(ext))
|
|
123
|
+
return FileKind.TEXT;
|
|
124
|
+
if (OFFICE_EXTENSIONS.has(ext))
|
|
125
|
+
return FileKind.OFFICE;
|
|
126
|
+
return FileKind.UNKNOWN;
|
|
127
|
+
}
|