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.
Files changed (41) hide show
  1. package/README.md +209 -41
  2. package/dist/Block.svelte +15 -8
  3. package/dist/Block.svelte.d.ts +2 -0
  4. package/dist/Elements/Alert.svelte +2 -1
  5. package/dist/Elements/Citation.svelte +7 -0
  6. package/dist/Elements/Code.svelte +60 -21
  7. package/dist/Elements/Code.svelte.d.ts +4 -0
  8. package/dist/Elements/Element.svelte +36 -8
  9. package/dist/Elements/Element.svelte.d.ts +2 -0
  10. package/dist/Elements/FootnoteRef.svelte +1 -0
  11. package/dist/Elements/Image.svelte +3 -2
  12. package/dist/Elements/Link.svelte +2 -2
  13. package/dist/Elements/Mermaid.svelte +65 -13
  14. package/dist/Elements/Mermaid.svelte.d.ts +3 -0
  15. package/dist/Elements/MermaidDownload.svelte +29 -8
  16. package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
  17. package/dist/Elements/TableDownload.svelte +57 -83
  18. package/dist/Elements/fallbacks/CodeFallback.svelte +25 -4
  19. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +3 -0
  20. package/dist/Elements/fallbacks/MermaidFallback.svelte +5 -2
  21. package/dist/Elements/fallbacks/MermaidFallback.svelte.d.ts +2 -0
  22. package/dist/Elements/icons.js +10 -1
  23. package/dist/Elements/srOnly.d.ts +1 -0
  24. package/dist/Elements/srOnly.js +3 -0
  25. package/dist/Streamdown.svelte +78 -16
  26. package/dist/context.svelte.d.ts +103 -22
  27. package/dist/context.svelte.js +38 -0
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.js +2 -1
  30. package/dist/marked/index.d.ts +6 -0
  31. package/dist/marked/index.js +46 -11
  32. package/dist/marked/marked-math.js +40 -1
  33. package/dist/utils/fence.d.ts +27 -0
  34. package/dist/utils/fence.js +56 -0
  35. package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
  36. package/dist/utils/parse-incomplete-markdown.js +283 -138
  37. package/dist/utils/table-export.d.ts +14 -0
  38. package/dist/utils/table-export.js +82 -0
  39. package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
  40. package/dist/utils/usePinnedScroll.svelte.js +36 -0
  41. 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 { token, children }: { token: StreamdownToken; children: Snippet } = $props();
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 props={{ children, token }} render={streamdown.snippets.code}>
75
- <MermaidComponent {id} {token} />
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.table}
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>;
@@ -74,6 +74,7 @@
74
74
  aria-expanded={popover.isOpen}
75
75
  aria-haspopup="dialog"
76
76
  aria-controls={'footnote-popover-' + id}
77
+ type="button"
77
78
  {@attach clickOutside.attachment}
78
79
  >
79
80
  {token.label.replace('^', '')}
@@ -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={`Blocked URL: ${token.href}`}
54
+ title={`${streamdown.translations.controls.blockedUrl}: ${token.href}`}
55
55
  >
56
- [Image blocked: {token.text || 'No description'}]
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={`Blocked URL: ${token.href}`}
54
+ title={`${streamdown.translations.controls.blockedUrl}: ${token.href}`}
55
55
  >
56
- {@render children()} [blocked]
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: number | undefined = undefined;
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 !== 'id') {
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
- style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
255
+ bind:this={container}
256
+ style={animate && streamdown.isMounted ? streamdown.animationBlockStyle : ''}
224
257
  class={streamdown.theme.mermaid.base}
225
- {@attach (node) => renderMermaid(chart, 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="Zoom to fit"
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="Zoom in"
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="Zoom out"
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="Toggle expand"
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
- <MermaidDownload {id} />
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('mermaid-diagram.svg', svgString, 'image/svg+xml');
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 = 'mermaid-diagram.png';
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 download = (type: 'SVG' | 'PNG') => {
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 ['PNG', 'SVG'] as type}
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 as 'SVG' | 'PNG')}
190
+ onclick={() => download(type)}
173
191
  class={streamdown.theme.components.button}
192
+ type="button"
174
193
  >
175
- {type}
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
- title="Download diagram"
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)()}
@@ -1,4 +1,6 @@
1
+ import type { CodeToken } from '../marked/index.js';
1
2
  type $$ComponentProps = {
3
+ token: CodeToken;
2
4
  id: string;
3
5
  };
4
6
  declare const MermaidDownload: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -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
- // textContent flattens a real <br> element, so a multiline cell collapses to
50
- // 'Paragraph one.Paragraph two.'. Walk the cell instead and keep the breaks;
51
- // the quoting below then quotes the newline for us.
52
- const extractCellText = (node: Node): string => {
53
- if (node.nodeType === 3) return node.textContent || '';
54
- if ((node as Element).tagName === 'BR') return '\n';
55
- return Array.from(node.childNodes).map(extractCellText).join('');
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 copyOrDownload = (type: 'Markdown' | 'HTML' | 'CSV') => {
79
+ const tableElement = () => document.querySelector(`[data-streamdown-table="${id}"]`);
80
+
81
+ const copyOrDownload = (type: Format) => {
59
82
  if (type === 'Markdown') {
60
- copyValue = token.raw;
61
- if (modeState === 'copy') {
62
- copy.copy();
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 = document.querySelector(`[data-streamdown-table="${id}"]`);
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
- copyValue = html;
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 if (type === 'CSV') {
95
- const table = document.querySelector(`[data-streamdown-table="${id}"]`);
107
+ } else {
108
+ const table = tableElement();
96
109
 
97
110
  if (table) {
98
- const rows = table.querySelectorAll('tr');
99
- const rowSpanFills: Array<{ rowIndex: number; colIndex: number; colSpan: number }> = [];
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
- save('table.csv', copyValue, 'text/csv');
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 ['Markdown', 'HTML', 'CSV'] as type}
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 as 'Markdown' | 'HTML' | 'CSV')}
138
+ onclick={() => copyOrDownload(type)}
175
139
  class={streamdown.theme.components.button}
140
+ type="button"
176
141
  >
177
- {type}
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 ['download', 'copy'] as mode (mode)}
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
- title={mode === 'download' ? 'Download table' : 'Copy table'}
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
- style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
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 class={streamdown.theme.code.pre}><code
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