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