svelte-streamdown 4.0.0 → 4.1.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.
- package/README.md +211 -38
- package/dist/Block.svelte +10 -4
- package/dist/Block.svelte.d.ts +2 -0
- package/dist/Elements/Alert.svelte +2 -1
- package/dist/Elements/Citation.svelte +9 -2
- package/dist/Elements/Code.svelte +65 -24
- package/dist/Elements/Code.svelte.d.ts +4 -2
- package/dist/Elements/Element.svelte +36 -11
- package/dist/Elements/Element.svelte.d.ts +1 -0
- package/dist/Elements/FootnoteRef.svelte +1 -0
- package/dist/Elements/Image.svelte +3 -2
- package/dist/Elements/Link.svelte +3 -2
- package/dist/Elements/Mermaid.svelte +69 -14
- package/dist/Elements/Mermaid.svelte.d.ts +4 -2
- package/dist/Elements/MermaidDownload.svelte +30 -9
- package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
- package/dist/Elements/TableDownload.svelte +60 -78
- package/dist/Elements/fallbacks/CodeFallback.svelte +28 -3
- package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -0
- package/dist/Elements/fallbacks/MermaidFallback.svelte +12 -3
- package/dist/Elements/fallbacks/MermaidFallback.svelte.d.ts +2 -0
- package/dist/Elements/icons.js +10 -1
- package/dist/Elements/srOnly.d.ts +1 -0
- package/dist/Elements/srOnly.js +3 -0
- package/dist/Streamdown.svelte +68 -13
- package/dist/context.svelte.d.ts +98 -22
- package/dist/context.svelte.js +38 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/marked/index.d.ts +8 -1
- package/dist/marked/index.js +65 -14
- package/dist/marked/marked-footnotes.js +6 -2
- package/dist/marked/marked-math.js +40 -1
- package/dist/marked/marked-subsup.js +16 -3
- package/dist/utils/fence.d.ts +16 -0
- package/dist/utils/fence.js +39 -0
- package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
- package/dist/utils/parse-incomplete-markdown.js +347 -122
- package/dist/utils/save.js +4 -1
- package/dist/utils/table-export.d.ts +14 -0
- package/dist/utils/table-export.js +82 -0
- package/dist/utils/url.js +6 -2
- package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
- package/dist/utils/usePinnedScroll.svelte.js +36 -0
- package/package.json +4 -2
|
@@ -9,9 +9,14 @@
|
|
|
9
9
|
import FootnoteRef from './FootnoteRef.svelte';
|
|
10
10
|
import Citation from './Citation.svelte';
|
|
11
11
|
import TableDownload from './TableDownload.svelte';
|
|
12
|
+
import { usePinnedScroll } from '../utils/usePinnedScroll.svelte.js';
|
|
12
13
|
// Import fallback components
|
|
13
14
|
import { CodeFallback, MermaidFallback, MathFallback } from './fallbacks/index.js';
|
|
14
|
-
let {
|
|
15
|
+
let {
|
|
16
|
+
token,
|
|
17
|
+
children,
|
|
18
|
+
incomplete = false
|
|
19
|
+
}: { token: StreamdownToken; children: Snippet; incomplete?: boolean } = $props();
|
|
15
20
|
const streamdown = useStreamdown();
|
|
16
21
|
|
|
17
22
|
// Use provided components or fallback to lightweight versions
|
|
@@ -22,6 +27,17 @@
|
|
|
22
27
|
// Only apply animation on block level elements. Leaves text elements to be animated by their text children.
|
|
23
28
|
const style = $derived(streamdown.isMounted ? streamdown.animationBlockStyle : '');
|
|
24
29
|
const id = $props.id();
|
|
30
|
+
|
|
31
|
+
// Only ever attached to the table wrapper, which is the element that already
|
|
32
|
+
// owns the horizontal scroll.
|
|
33
|
+
const tableScroll = usePinnedScroll({
|
|
34
|
+
get maxHeight() {
|
|
35
|
+
return streamdown.tableMaxHeight;
|
|
36
|
+
},
|
|
37
|
+
get content() {
|
|
38
|
+
return token.type === 'table' ? token.raw : undefined;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
25
41
|
</script>
|
|
26
42
|
|
|
27
43
|
{#if token.type === 'heading'}
|
|
@@ -71,12 +87,15 @@
|
|
|
71
87
|
</blockquote>
|
|
72
88
|
</Slot>
|
|
73
89
|
{:else if token.type === 'code' && token.lang === 'mermaid'}
|
|
74
|
-
<Slot
|
|
75
|
-
|
|
90
|
+
<Slot
|
|
91
|
+
props={{ children, token, incomplete }}
|
|
92
|
+
render={streamdown.snippets.mermaid ?? streamdown.snippets.code}
|
|
93
|
+
>
|
|
94
|
+
<MermaidComponent {id} {token} {incomplete} />
|
|
76
95
|
</Slot>
|
|
77
96
|
{:else if token.type === 'code'}
|
|
78
|
-
<Slot props={{ children, token }} render={streamdown.snippets.code}>
|
|
79
|
-
<CodeComponent {id} {token} />
|
|
97
|
+
<Slot props={{ children, token, incomplete }} render={streamdown.snippets.code}>
|
|
98
|
+
<CodeComponent {id} {token} {incomplete} />
|
|
80
99
|
</Slot>
|
|
81
100
|
{:else if token.type === 'codespan'}
|
|
82
101
|
<Slot props={{ children, token }} render={streamdown.snippets.codespan}>
|
|
@@ -124,7 +143,7 @@
|
|
|
124
143
|
</Slot>
|
|
125
144
|
{:else if token.type === 'table'}
|
|
126
145
|
<Slot props={{ token, children }} render={streamdown.snippets.table}>
|
|
127
|
-
{#if streamdown.controls.
|
|
146
|
+
{#if streamdown.controls.tableCopy || streamdown.controls.tableDownload}
|
|
128
147
|
<TableDownload {id} {token} />
|
|
129
148
|
{/if}
|
|
130
149
|
<div
|
|
@@ -132,6 +151,9 @@
|
|
|
132
151
|
{style}
|
|
133
152
|
class={`${streamdown.theme.table.base} group`}
|
|
134
153
|
style:overscroll-behavior-x="none"
|
|
154
|
+
style:max-height={streamdown.tableMaxHeight}
|
|
155
|
+
style:overflow-y={streamdown.tableMaxHeight ? 'auto' : undefined}
|
|
156
|
+
{@attach tableScroll}
|
|
135
157
|
>
|
|
136
158
|
<table class={streamdown.theme.table.table}>
|
|
137
159
|
{@render children()}
|
|
@@ -288,12 +310,11 @@
|
|
|
288
310
|
{@render children()}
|
|
289
311
|
</dd>
|
|
290
312
|
</Slot>
|
|
291
|
-
{:else if token.type === 'def'}
|
|
292
|
-
<!--
|
|
313
|
+
{:else if token.type === 'def' || token.type === 'space'}
|
|
314
|
+
<!-- Link reference definitions and blank lines produce no output (CommonMark) -->
|
|
293
315
|
{:else if token.type === 'escape'}
|
|
294
|
-
<!--
|
|
295
|
-
{
|
|
296
|
-
<!-- TODO This does not seems to be tokenized for now -->
|
|
316
|
+
<!-- `children` renders token.text, i.e. the escaped character itself -->
|
|
317
|
+
{@render children()}
|
|
297
318
|
{:else if token.type === 'text'}
|
|
298
319
|
{@render children()}
|
|
299
320
|
{:else if token.type === 'html'}
|
|
@@ -301,6 +322,10 @@
|
|
|
301
322
|
{@const content =
|
|
302
323
|
typeof streamdown.renderHtml === 'function' ? streamdown.renderHtml(token) : token.raw}
|
|
303
324
|
{@html content}
|
|
325
|
+
{:else}
|
|
326
|
+
<!-- Without renderHtml the source is shown literally instead of being dropped;
|
|
327
|
+
`children` interpolates it as text, so Svelte escapes it — no XSS surface -->
|
|
328
|
+
{@render children()}
|
|
304
329
|
{/if}
|
|
305
330
|
{:else if token.type === 'mdx'}
|
|
306
331
|
{@const Component = streamdown.mdxComponents?.[token.tagName]}
|
|
@@ -3,6 +3,7 @@ import type { StreamdownToken } from '../marked/index.js';
|
|
|
3
3
|
type $$ComponentProps = {
|
|
4
4
|
token: StreamdownToken;
|
|
5
5
|
children: Snippet;
|
|
6
|
+
incomplete?: boolean;
|
|
6
7
|
};
|
|
7
8
|
declare const Element: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
9
|
type Element = ReturnType<typeof Element>;
|
|
@@ -51,9 +51,10 @@
|
|
|
51
51
|
<span
|
|
52
52
|
data-streamdown-image-blocked={id}
|
|
53
53
|
class="inline-block rounded bg-gray-200 px-3 py-1 text-sm text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
|
54
|
-
title={
|
|
54
|
+
title={`${streamdown.translations.controls.blockedUrl}: ${token.href}`}
|
|
55
55
|
>
|
|
56
|
-
[
|
|
56
|
+
[{streamdown.translations.controls.imageBlocked}: {token.text ||
|
|
57
|
+
streamdown.translations.controls.imageNoDescription}]
|
|
57
58
|
</span>
|
|
58
59
|
{/if}
|
|
59
60
|
{/if}
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
<a
|
|
40
40
|
data-streamdown-link={id}
|
|
41
41
|
class={streamdown.theme.link.base}
|
|
42
|
+
title={token.title}
|
|
42
43
|
{...isRelativeUrl
|
|
43
44
|
? { href: token.href }
|
|
44
45
|
: { href: transformedUrl, target: '_blank', rel: 'noopener noreferrer' }}
|
|
@@ -50,8 +51,8 @@
|
|
|
50
51
|
<span
|
|
51
52
|
data-streamdown-link-blocked={id}
|
|
52
53
|
class={streamdown.theme.link.blocked}
|
|
53
|
-
title={
|
|
54
|
+
title={`${streamdown.translations.controls.blockedUrl}: ${token.href}`}
|
|
54
55
|
>
|
|
55
|
-
{@render children()} [
|
|
56
|
+
{@render children()} [{streamdown.translations.controls.linkBlocked}]
|
|
56
57
|
</span>
|
|
57
58
|
{/if}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
3
|
import { useStreamdown } from '../context.svelte.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { CodeToken } from '../marked/index.js';
|
|
5
5
|
import type { MermaidConfig } from 'mermaid';
|
|
6
6
|
import { on } from 'svelte/events';
|
|
7
7
|
import { usePanzoom } from '../utils/panzoom.svelte';
|
|
@@ -12,12 +12,29 @@
|
|
|
12
12
|
|
|
13
13
|
const {
|
|
14
14
|
token,
|
|
15
|
-
id
|
|
15
|
+
id,
|
|
16
|
+
incomplete = false
|
|
16
17
|
}: {
|
|
17
|
-
token:
|
|
18
|
+
token: CodeToken;
|
|
18
19
|
id: string;
|
|
20
|
+
/** The fence is still being streamed; nothing below it is final yet. */
|
|
21
|
+
incomplete?: boolean;
|
|
19
22
|
} = $props();
|
|
20
23
|
|
|
24
|
+
// Trailing blank lines are noise for mermaid but they still changed `token.text`
|
|
25
|
+
// on nearly every streamed chunk, which re-ran the whole render. Same trim as
|
|
26
|
+
// Code.svelte.
|
|
27
|
+
const chart = $derived(token.text.replace(/\n+$/, ''));
|
|
28
|
+
|
|
29
|
+
// Screen readers get the diagram's declaration line ('flowchart TD', 'sequenceDiagram'),
|
|
30
|
+
// which is the only human-readable summary the source offers.
|
|
31
|
+
const firstLine = $derived(chart.split('\n', 1)[0].trim());
|
|
32
|
+
const diagramLabel = $derived(
|
|
33
|
+
firstLine
|
|
34
|
+
? `${streamdown.translations.controls.diagram}: ${firstLine}`
|
|
35
|
+
: streamdown.translations.controls.diagram
|
|
36
|
+
);
|
|
37
|
+
|
|
21
38
|
let mermaid = $state<any>(null);
|
|
22
39
|
onMount(async () => {
|
|
23
40
|
mermaid = (await import('mermaid')).default;
|
|
@@ -25,7 +42,7 @@
|
|
|
25
42
|
|
|
26
43
|
const useIsInsideForMoreThanAQuarterSecond = () => {
|
|
27
44
|
let isInside = $state(false);
|
|
28
|
-
let timeout:
|
|
45
|
+
let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
29
46
|
|
|
30
47
|
return {
|
|
31
48
|
get isInside() {
|
|
@@ -62,6 +79,19 @@
|
|
|
62
79
|
}
|
|
63
80
|
});
|
|
64
81
|
|
|
82
|
+
const expandLabel = $derived(
|
|
83
|
+
panzoom.expanded
|
|
84
|
+
? streamdown.translations.controls.exitFullscreen
|
|
85
|
+
: streamdown.translations.controls.fullscreen
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
let container = $state<HTMLDivElement>();
|
|
89
|
+
$effect(() => {
|
|
90
|
+
// Expanding covers the page: move focus in so Escape (handled by panzoom)
|
|
91
|
+
// and the toolbar are reachable from the keyboard.
|
|
92
|
+
if (panzoom.expanded) container?.focus();
|
|
93
|
+
});
|
|
94
|
+
|
|
65
95
|
const sanitizeMermaidCode = (code: string): string => {
|
|
66
96
|
try {
|
|
67
97
|
let sanitized = code;
|
|
@@ -194,10 +224,15 @@
|
|
|
194
224
|
svgTarget.innerHTML = svgString;
|
|
195
225
|
svgTarget.id = uniqueId;
|
|
196
226
|
|
|
197
|
-
// Apply any additional attributes from the rendered SVG
|
|
227
|
+
// Apply any additional attributes from the rendered SVG. The accessibility
|
|
228
|
+
// attributes are ours: mermaid's root carries role="graphics-document" and
|
|
229
|
+
// an aria-roledescription, which would overwrite the label a screen reader
|
|
230
|
+
// needs (and did, non-deterministically, depending on when the lazy import
|
|
231
|
+
// resolved).
|
|
232
|
+
const keepOwn = new Set(['id', 'role', 'aria-label', 'aria-roledescription']);
|
|
198
233
|
const tempSvg = new DOMParser().parseFromString(svgString, 'image/svg+xml').documentElement;
|
|
199
234
|
Array.from(tempSvg.attributes).forEach((attribute) => {
|
|
200
|
-
if (attribute.name
|
|
235
|
+
if (!keepOwn.has(attribute.name)) {
|
|
201
236
|
svgTarget.setAttribute(attribute.name, attribute.value);
|
|
202
237
|
}
|
|
203
238
|
});
|
|
@@ -212,20 +247,31 @@
|
|
|
212
247
|
};
|
|
213
248
|
</script>
|
|
214
249
|
|
|
215
|
-
<div data-streamdown-mermaid={id}>
|
|
250
|
+
<div data-streamdown-mermaid={id} data-incomplete={incomplete || undefined}>
|
|
216
251
|
{#if mermaid}
|
|
217
252
|
<div
|
|
253
|
+
bind:this={container}
|
|
218
254
|
style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
|
|
219
255
|
class={streamdown.theme.mermaid.base}
|
|
220
|
-
{@attach (node) =>
|
|
256
|
+
{@attach (node) => {
|
|
257
|
+
// A half-written diagram makes mermaid throw and log on every chunk, so
|
|
258
|
+
// skip the render entirely and keep showing whatever rendered last.
|
|
259
|
+
if (!incomplete) renderMermaid(chart, node);
|
|
260
|
+
}}
|
|
221
261
|
{@attach insider.attach}
|
|
222
262
|
data-expanded={'false'}
|
|
263
|
+
role={panzoom.expanded ? 'dialog' : undefined}
|
|
264
|
+
aria-modal={panzoom.expanded ? 'true' : undefined}
|
|
265
|
+
aria-label={panzoom.expanded ? diagramLabel : undefined}
|
|
266
|
+
tabindex="-1"
|
|
223
267
|
>
|
|
224
268
|
{#if streamdown.controls.mermaid}
|
|
225
269
|
<div class={streamdown.theme.mermaid.buttons}>
|
|
226
270
|
<button
|
|
227
271
|
class={streamdown.theme.components.button}
|
|
228
|
-
aria-label=
|
|
272
|
+
aria-label={streamdown.translations.controls.resetView}
|
|
273
|
+
title={streamdown.translations.controls.resetView}
|
|
274
|
+
type="button"
|
|
229
275
|
onclick={() => panzoom.zoomToFit()}
|
|
230
276
|
data-panzoom-ignore
|
|
231
277
|
>
|
|
@@ -233,7 +279,9 @@
|
|
|
233
279
|
</button>
|
|
234
280
|
<button
|
|
235
281
|
class={streamdown.theme.components.button}
|
|
236
|
-
aria-label=
|
|
282
|
+
aria-label={streamdown.translations.controls.zoomIn}
|
|
283
|
+
title={streamdown.translations.controls.zoomIn}
|
|
284
|
+
type="button"
|
|
237
285
|
onclick={() => panzoom.zoomIn()}
|
|
238
286
|
data-panzoom-ignore
|
|
239
287
|
>
|
|
@@ -241,7 +289,9 @@
|
|
|
241
289
|
</button>
|
|
242
290
|
<button
|
|
243
291
|
class={streamdown.theme.components.button}
|
|
244
|
-
aria-label=
|
|
292
|
+
aria-label={streamdown.translations.controls.zoomOut}
|
|
293
|
+
title={streamdown.translations.controls.zoomOut}
|
|
294
|
+
type="button"
|
|
245
295
|
onclick={() => panzoom.zoomOut()}
|
|
246
296
|
data-panzoom-ignore
|
|
247
297
|
>
|
|
@@ -249,16 +299,21 @@
|
|
|
249
299
|
</button>
|
|
250
300
|
<button
|
|
251
301
|
class={streamdown.theme.components.button}
|
|
252
|
-
aria-label=
|
|
302
|
+
aria-label={expandLabel}
|
|
303
|
+
title={expandLabel}
|
|
304
|
+
aria-pressed={panzoom.expanded}
|
|
305
|
+
type="button"
|
|
253
306
|
onclick={() => panzoom.toggleExpand()}
|
|
254
307
|
data-panzoom-ignore
|
|
255
308
|
>
|
|
256
309
|
{@render (streamdown.icons?.fullscreen || fullscreenIcon)()}
|
|
257
310
|
</button>
|
|
258
|
-
|
|
311
|
+
{#if streamdown.controls.mermaidDownload}
|
|
312
|
+
<MermaidDownload {token} {id} />
|
|
313
|
+
{/if}
|
|
259
314
|
</div>
|
|
260
315
|
{/if}
|
|
261
|
-
<svg {@attach panzoom.attach} data-mermaid-svg></svg>
|
|
316
|
+
<svg {@attach panzoom.attach} data-mermaid-svg role="img" aria-label={diagramLabel}></svg>
|
|
262
317
|
</div>
|
|
263
318
|
{:else}
|
|
264
319
|
<div class={streamdown.theme.mermaid.base}></div>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CodeToken } from '../marked/index.js';
|
|
2
2
|
type $$ComponentProps = {
|
|
3
|
-
token:
|
|
3
|
+
token: CodeToken;
|
|
4
4
|
id: string;
|
|
5
|
+
/** The fence is still being streamed; nothing below it is final yet. */
|
|
6
|
+
incomplete?: boolean;
|
|
5
7
|
};
|
|
6
8
|
declare const Mermaid: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
9
|
type Mermaid = ReturnType<typeof Mermaid>;
|
|
@@ -6,16 +6,24 @@
|
|
|
6
6
|
import { useClickOutside } from '../utils/useClickOutside.svelte.js';
|
|
7
7
|
import { useKeyDown } from '../utils/useKeyDown.svelte.js';
|
|
8
8
|
import { save } from '../utils/save.js';
|
|
9
|
+
import type { CodeToken } from '../marked/index.js';
|
|
9
10
|
|
|
10
11
|
let {
|
|
12
|
+
token,
|
|
11
13
|
id
|
|
12
14
|
}: {
|
|
15
|
+
token: CodeToken;
|
|
13
16
|
id: string;
|
|
14
17
|
} = $props();
|
|
15
18
|
|
|
16
19
|
const streamdown = useStreamdown();
|
|
17
20
|
const popover = new Popover();
|
|
18
21
|
|
|
22
|
+
const filename = (extension: string) => {
|
|
23
|
+
const base = streamdown.controls.mermaidDownloadFilename;
|
|
24
|
+
return `${typeof base === 'function' ? base(token) : base}.${extension}`;
|
|
25
|
+
};
|
|
26
|
+
|
|
19
27
|
useKeyDown({
|
|
20
28
|
keys: ['Escape'],
|
|
21
29
|
get isActive() {
|
|
@@ -68,7 +76,7 @@
|
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
const svgString = new XMLSerializer().serializeToString(clonedSvg);
|
|
71
|
-
save('
|
|
79
|
+
save(filename('svg'), svgString, 'image/svg+xml');
|
|
72
80
|
popover.isOpen = false;
|
|
73
81
|
};
|
|
74
82
|
|
|
@@ -128,7 +136,7 @@
|
|
|
128
136
|
const url = URL.createObjectURL(blob);
|
|
129
137
|
const link = document.createElement('a');
|
|
130
138
|
link.href = url;
|
|
131
|
-
link.download = '
|
|
139
|
+
link.download = filename('png');
|
|
132
140
|
document.body.appendChild(link);
|
|
133
141
|
link.click();
|
|
134
142
|
document.body.removeChild(link);
|
|
@@ -145,18 +153,28 @@
|
|
|
145
153
|
popover.isOpen = false;
|
|
146
154
|
};
|
|
147
155
|
|
|
148
|
-
const
|
|
156
|
+
const formats = $derived([
|
|
157
|
+
{ type: 'PNG', label: streamdown.translations.controls.downloadDiagramPng },
|
|
158
|
+
{ type: 'SVG', label: streamdown.translations.controls.downloadDiagramSvg },
|
|
159
|
+
{ type: 'MMD', label: streamdown.translations.controls.downloadDiagramMmd }
|
|
160
|
+
] as const);
|
|
161
|
+
|
|
162
|
+
const download = (type: 'SVG' | 'PNG' | 'MMD') => {
|
|
149
163
|
if (type === 'SVG') {
|
|
150
164
|
downloadSvg();
|
|
151
|
-
} else {
|
|
165
|
+
} else if (type === 'PNG') {
|
|
152
166
|
downloadPng();
|
|
167
|
+
} else {
|
|
168
|
+
// The diagram source, same trailing-newline trim as the rendered chart.
|
|
169
|
+
save(filename('mmd'), token.text.replace(/\n+$/, ''), 'text/plain');
|
|
170
|
+
popover.isOpen = false;
|
|
153
171
|
}
|
|
154
172
|
};
|
|
155
173
|
</script>
|
|
156
174
|
|
|
157
175
|
{#if popover.isOpen}
|
|
158
176
|
<dialog
|
|
159
|
-
id={'mermaid-download-popover'}
|
|
177
|
+
id={'mermaid-download-popover-' + id}
|
|
160
178
|
aria-modal="false"
|
|
161
179
|
transition:scale|global={{ start: 0.95, duration: 100 }}
|
|
162
180
|
{@attach clickOutside.attachment}
|
|
@@ -166,13 +184,14 @@
|
|
|
166
184
|
style:min-width="fit-content !important"
|
|
167
185
|
class={streamdown.theme.components.popover}
|
|
168
186
|
>
|
|
169
|
-
{#each
|
|
187
|
+
{#each formats as { type, label } (type)}
|
|
170
188
|
<button
|
|
171
189
|
style="width: 100%; text-align: left; justify-content: flex-start; padding: 1rem 1rem; margin: 0.2rem 0;"
|
|
172
|
-
onclick={() => download(type
|
|
190
|
+
onclick={() => download(type)}
|
|
173
191
|
class={streamdown.theme.components.button}
|
|
192
|
+
type="button"
|
|
174
193
|
>
|
|
175
|
-
{
|
|
194
|
+
{label}
|
|
176
195
|
</button>
|
|
177
196
|
{/each}
|
|
178
197
|
</dialog>
|
|
@@ -189,7 +208,9 @@
|
|
|
189
208
|
popover.isOpen = true;
|
|
190
209
|
}}
|
|
191
210
|
{@attach clickOutside.attachment}
|
|
192
|
-
|
|
211
|
+
type="button"
|
|
212
|
+
title={streamdown.translations.controls.downloadDiagram}
|
|
213
|
+
aria-label={streamdown.translations.controls.downloadDiagram}
|
|
193
214
|
data-panzoom-ignore
|
|
194
215
|
>
|
|
195
216
|
{@render (streamdown.icons?.download || downloadIcon)()}
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
import type { TableToken } from '../marked/marked-table.js';
|
|
9
9
|
import { useCopy } from '../utils/copy.svelte.js';
|
|
10
10
|
import { save } from '../utils/save.js';
|
|
11
|
+
import { srOnly } from './srOnly.js';
|
|
12
|
+
import { extractTableData, tableDataToCSV, tableDataToTSV } from '../utils/table-export.js';
|
|
13
|
+
|
|
14
|
+
type Format = 'Markdown' | 'HTML' | 'CSV' | 'TSV';
|
|
11
15
|
|
|
12
16
|
let {
|
|
13
17
|
token,
|
|
@@ -19,6 +23,11 @@
|
|
|
19
23
|
const streamdown = useStreamdown();
|
|
20
24
|
const popover = new Popover();
|
|
21
25
|
let modeState = $state<'download' | 'copy'>('download');
|
|
26
|
+
const modes = $derived(
|
|
27
|
+
(['download', 'copy'] as const).filter((mode) =>
|
|
28
|
+
mode === 'download' ? streamdown.controls.tableDownload : streamdown.controls.tableCopy
|
|
29
|
+
)
|
|
30
|
+
);
|
|
22
31
|
|
|
23
32
|
useKeyDown({
|
|
24
33
|
keys: ['Escape'],
|
|
@@ -46,22 +55,41 @@
|
|
|
46
55
|
}
|
|
47
56
|
});
|
|
48
57
|
|
|
49
|
-
const
|
|
58
|
+
const filename = (extension: string) => {
|
|
59
|
+
const base = streamdown.controls.tableDownloadFilename;
|
|
60
|
+
return `${typeof base === 'function' ? base(token) : base}.${extension}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const formats = $derived([
|
|
64
|
+
{ type: 'Markdown', label: streamdown.translations.controls.tableFormatMarkdown },
|
|
65
|
+
{ type: 'HTML', label: streamdown.translations.controls.tableFormatHtml },
|
|
66
|
+
{ type: 'CSV', label: streamdown.translations.controls.tableFormatCsv },
|
|
67
|
+
{ type: 'TSV', label: streamdown.translations.controls.tableFormatTsv }
|
|
68
|
+
] as const satisfies readonly { type: Format; label: string }[]);
|
|
69
|
+
|
|
70
|
+
const emit = (content: string, extension: string, mimeType: string) => {
|
|
71
|
+
copyValue = content;
|
|
72
|
+
if (modeState === 'copy') {
|
|
73
|
+
copy.copy();
|
|
74
|
+
} else {
|
|
75
|
+
save(filename(extension), content, mimeType);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const tableElement = () => document.querySelector(`[data-streamdown-table="${id}"]`);
|
|
80
|
+
|
|
81
|
+
const copyOrDownload = (type: Format) => {
|
|
50
82
|
if (type === 'Markdown') {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
} else {
|
|
55
|
-
save('table.md', copyValue, 'text/markdown');
|
|
56
|
-
}
|
|
83
|
+
// `token.raw` is the author's own markdown; a DOM round-trip through
|
|
84
|
+
// tableDataToMarkdown() would lose the inline formatting.
|
|
85
|
+
emit(token.raw, 'md', 'text/markdown');
|
|
57
86
|
} else if (type === 'HTML') {
|
|
58
|
-
const table =
|
|
87
|
+
const table = tableElement();
|
|
59
88
|
|
|
60
89
|
if (table) {
|
|
61
90
|
let html = (table.cloneNode(true) as HTMLElement).outerHTML;
|
|
62
91
|
// remove comments
|
|
63
92
|
html = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
64
|
-
copyValue = html;
|
|
65
93
|
// Remove class and style attributes
|
|
66
94
|
html = html.replace(/class="[^"]*"/g, '');
|
|
67
95
|
html = html.replace(/style="[^"]*"/g, '');
|
|
@@ -74,73 +102,17 @@
|
|
|
74
102
|
// Collapse multiple spaces within tags to single space
|
|
75
103
|
html = html.replace(/<([^>]+)>/g, (match) => match.replace(/\s+/g, ' '));
|
|
76
104
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (modeState === 'copy') {
|
|
80
|
-
copy.copy();
|
|
81
|
-
} else {
|
|
82
|
-
save('table.html', copyValue, 'text/html');
|
|
83
|
-
}
|
|
105
|
+
emit(html, 'html', 'text/html');
|
|
84
106
|
}
|
|
85
|
-
} else
|
|
86
|
-
const table =
|
|
107
|
+
} else {
|
|
108
|
+
const table = tableElement();
|
|
87
109
|
|
|
88
110
|
if (table) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const matrix = Array.from(rows).reduce((acc, row, rowIndex) => {
|
|
93
|
-
const cells = row.querySelectorAll('td, th');
|
|
94
|
-
const rowData: string[] = [];
|
|
95
|
-
let actualCol = 0; // Track actual column position in the output matrix
|
|
96
|
-
|
|
97
|
-
Array.from(cells).forEach((cell) => {
|
|
98
|
-
const colSpan = parseInt(cell.getAttribute('colspan') || '1');
|
|
99
|
-
const rowSpan = parseInt(cell.getAttribute('rowspan') || '1');
|
|
100
|
-
|
|
101
|
-
// Add the cell content
|
|
102
|
-
// Add the cell content, quoting if it contains commas, quotes, or newlines
|
|
103
|
-
const content = cell.textContent || '';
|
|
104
|
-
const needsQuoting = /[,"\n]/.test(content);
|
|
105
|
-
const escapedContent = content.replace(/"/g, '""');
|
|
106
|
-
rowData.push(needsQuoting ? `"${escapedContent}"` : content);
|
|
107
|
-
|
|
108
|
-
// Add empty cells for colspan
|
|
109
|
-
for (let i = 0; i < colSpan - 1; i++) {
|
|
110
|
-
rowData.push('');
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Track rowspan fills needed in future rows
|
|
114
|
-
if (rowSpan > 1) {
|
|
115
|
-
for (let r = 1; r < rowSpan; r++) {
|
|
116
|
-
rowSpanFills.push({
|
|
117
|
-
rowIndex: rowIndex + r,
|
|
118
|
-
colIndex: actualCol,
|
|
119
|
-
colSpan: colSpan
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
actualCol += colSpan;
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
acc.push(rowData);
|
|
128
|
-
return acc;
|
|
129
|
-
}, [] as string[][]);
|
|
130
|
-
|
|
131
|
-
// Process rowspan fills - insert empty cells at correct positions
|
|
132
|
-
rowSpanFills.forEach(({ rowIndex, colIndex, colSpan }) => {
|
|
133
|
-
if (matrix[rowIndex]) {
|
|
134
|
-
matrix[rowIndex].splice(colIndex, 0, ...Array(colSpan).fill(''));
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
const csv = matrix.map((row) => row.join(',')).join('\n');
|
|
139
|
-
copyValue = csv;
|
|
140
|
-
if (modeState === 'copy') {
|
|
141
|
-
copy.copy();
|
|
111
|
+
const data = extractTableData(table);
|
|
112
|
+
if (type === 'CSV') {
|
|
113
|
+
emit(tableDataToCSV(data, streamdown.controls.tableCsvSeparator), 'csv', 'text/csv');
|
|
142
114
|
} else {
|
|
143
|
-
|
|
115
|
+
emit(tableDataToTSV(data), 'tsv', 'text/tab-separated-values');
|
|
144
116
|
}
|
|
145
117
|
}
|
|
146
118
|
}
|
|
@@ -150,7 +122,7 @@
|
|
|
150
122
|
|
|
151
123
|
{#if popover.isOpen}
|
|
152
124
|
<dialog
|
|
153
|
-
id={'table-download-popover'}
|
|
125
|
+
id={'table-download-popover-' + id}
|
|
154
126
|
aria-modal="false"
|
|
155
127
|
transition:scale|global={{ start: 0.95, duration: 100 }}
|
|
156
128
|
{@attach clickOutside.attachment}
|
|
@@ -160,13 +132,14 @@
|
|
|
160
132
|
style:min-width="fit-content !important"
|
|
161
133
|
class={streamdown.theme.components.popover}
|
|
162
134
|
>
|
|
163
|
-
{#each
|
|
135
|
+
{#each formats as { type, label } (type)}
|
|
164
136
|
<button
|
|
165
137
|
style="width: 100%; text-align: left; justify-content: flex-start; padding: 1rem 1rem; margin: 0.2rem 0;"
|
|
166
|
-
onclick={() => copyOrDownload(type
|
|
138
|
+
onclick={() => copyOrDownload(type)}
|
|
167
139
|
class={streamdown.theme.components.button}
|
|
140
|
+
type="button"
|
|
168
141
|
>
|
|
169
|
-
{
|
|
142
|
+
{label}
|
|
170
143
|
</button>
|
|
171
144
|
{/each}
|
|
172
145
|
</dialog>
|
|
@@ -176,7 +149,7 @@
|
|
|
176
149
|
data-streamdown-table-download
|
|
177
150
|
class=" right-0 ml-auto flex items-center justify-end gap-2 p-1"
|
|
178
151
|
>
|
|
179
|
-
{#each
|
|
152
|
+
{#each modes as mode (mode)}
|
|
180
153
|
<button
|
|
181
154
|
class={streamdown.theme.components.button}
|
|
182
155
|
onclick={async (e: MouseEvent) => {
|
|
@@ -196,7 +169,13 @@
|
|
|
196
169
|
modeState = mode as 'download' | 'copy';
|
|
197
170
|
}}
|
|
198
171
|
{@attach clickOutside.attachment}
|
|
199
|
-
|
|
172
|
+
type="button"
|
|
173
|
+
title={mode === 'download'
|
|
174
|
+
? streamdown.translations.controls.downloadTable
|
|
175
|
+
: streamdown.translations.controls.copyTable}
|
|
176
|
+
aria-label={mode === 'download'
|
|
177
|
+
? streamdown.translations.controls.downloadTable
|
|
178
|
+
: streamdown.translations.controls.copyTable}
|
|
200
179
|
>
|
|
201
180
|
{#if mode === 'download'}
|
|
202
181
|
{@render (streamdown.icons?.download || downloadIcon)()}
|
|
@@ -207,6 +186,9 @@
|
|
|
207
186
|
{/if}
|
|
208
187
|
</button>
|
|
209
188
|
{/each}
|
|
189
|
+
<span aria-live="polite" style={srOnly}
|
|
190
|
+
>{copy.isCopied ? streamdown.translations.controls.copiedTable : ''}</span
|
|
191
|
+
>
|
|
210
192
|
</div>
|
|
211
193
|
|
|
212
194
|
<style>
|