svelte-streamdown 4.0.1 → 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.
Files changed (40) hide show
  1. package/README.md +206 -41
  2. package/dist/Block.svelte +5 -2
  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 +55 -19
  7. package/dist/Elements/Code.svelte.d.ts +2 -0
  8. package/dist/Elements/Element.svelte +28 -6
  9. package/dist/Elements/Element.svelte.d.ts +1 -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 +62 -12
  14. package/dist/Elements/Mermaid.svelte.d.ts +2 -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 +21 -2
  19. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -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 +68 -13
  26. package/dist/context.svelte.d.ts +98 -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.js +25 -10
  31. package/dist/marked/marked-math.js +40 -1
  32. package/dist/utils/fence.d.ts +16 -0
  33. package/dist/utils/fence.js +39 -0
  34. package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
  35. package/dist/utils/parse-incomplete-markdown.js +281 -138
  36. package/dist/utils/table-export.d.ts +14 -0
  37. package/dist/utils/table-export.js +82 -0
  38. package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
  39. package/dist/utils/usePinnedScroll.svelte.js +36 -0
  40. package/package.json +3 -2
@@ -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,13 @@
12
12
 
13
13
  const {
14
14
  token,
15
- id
15
+ id,
16
+ incomplete = false
16
17
  }: {
17
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
 
21
24
  // Trailing blank lines are noise for mermaid but they still changed `token.text`
@@ -23,6 +26,15 @@
23
26
  // Code.svelte.
24
27
  const chart = $derived(token.text.replace(/\n+$/, ''));
25
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
+
26
38
  let mermaid = $state<any>(null);
27
39
  onMount(async () => {
28
40
  mermaid = (await import('mermaid')).default;
@@ -30,7 +42,7 @@
30
42
 
31
43
  const useIsInsideForMoreThanAQuarterSecond = () => {
32
44
  let isInside = $state(false);
33
- let timeout: number | undefined = undefined;
45
+ let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
34
46
 
35
47
  return {
36
48
  get isInside() {
@@ -67,6 +79,19 @@
67
79
  }
68
80
  });
69
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
+
70
95
  const sanitizeMermaidCode = (code: string): string => {
71
96
  try {
72
97
  let sanitized = code;
@@ -199,10 +224,15 @@
199
224
  svgTarget.innerHTML = svgString;
200
225
  svgTarget.id = uniqueId;
201
226
 
202
- // 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']);
203
233
  const tempSvg = new DOMParser().parseFromString(svgString, 'image/svg+xml').documentElement;
204
234
  Array.from(tempSvg.attributes).forEach((attribute) => {
205
- if (attribute.name !== 'id') {
235
+ if (!keepOwn.has(attribute.name)) {
206
236
  svgTarget.setAttribute(attribute.name, attribute.value);
207
237
  }
208
238
  });
@@ -217,20 +247,31 @@
217
247
  };
218
248
  </script>
219
249
 
220
- <div data-streamdown-mermaid={id}>
250
+ <div data-streamdown-mermaid={id} data-incomplete={incomplete || undefined}>
221
251
  {#if mermaid}
222
252
  <div
253
+ bind:this={container}
223
254
  style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
224
255
  class={streamdown.theme.mermaid.base}
225
- {@attach (node) => renderMermaid(chart, 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
+ }}
226
261
  {@attach insider.attach}
227
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"
228
267
  >
229
268
  {#if streamdown.controls.mermaid}
230
269
  <div class={streamdown.theme.mermaid.buttons}>
231
270
  <button
232
271
  class={streamdown.theme.components.button}
233
- aria-label="Zoom to fit"
272
+ aria-label={streamdown.translations.controls.resetView}
273
+ title={streamdown.translations.controls.resetView}
274
+ type="button"
234
275
  onclick={() => panzoom.zoomToFit()}
235
276
  data-panzoom-ignore
236
277
  >
@@ -238,7 +279,9 @@
238
279
  </button>
239
280
  <button
240
281
  class={streamdown.theme.components.button}
241
- aria-label="Zoom in"
282
+ aria-label={streamdown.translations.controls.zoomIn}
283
+ title={streamdown.translations.controls.zoomIn}
284
+ type="button"
242
285
  onclick={() => panzoom.zoomIn()}
243
286
  data-panzoom-ignore
244
287
  >
@@ -246,7 +289,9 @@
246
289
  </button>
247
290
  <button
248
291
  class={streamdown.theme.components.button}
249
- aria-label="Zoom out"
292
+ aria-label={streamdown.translations.controls.zoomOut}
293
+ title={streamdown.translations.controls.zoomOut}
294
+ type="button"
250
295
  onclick={() => panzoom.zoomOut()}
251
296
  data-panzoom-ignore
252
297
  >
@@ -254,16 +299,21 @@
254
299
  </button>
255
300
  <button
256
301
  class={streamdown.theme.components.button}
257
- aria-label="Toggle expand"
302
+ aria-label={expandLabel}
303
+ title={expandLabel}
304
+ aria-pressed={panzoom.expanded}
305
+ type="button"
258
306
  onclick={() => panzoom.toggleExpand()}
259
307
  data-panzoom-ignore
260
308
  >
261
309
  {@render (streamdown.icons?.fullscreen || fullscreenIcon)()}
262
310
  </button>
263
- <MermaidDownload {id} />
311
+ {#if streamdown.controls.mermaidDownload}
312
+ <MermaidDownload {token} {id} />
313
+ {/if}
264
314
  </div>
265
315
  {/if}
266
- <svg {@attach panzoom.attach} data-mermaid-svg></svg>
316
+ <svg {@attach panzoom.attach} data-mermaid-svg role="img" aria-label={diagramLabel}></svg>
267
317
  </div>
268
318
  {:else}
269
319
  <div class={streamdown.theme.mermaid.base}></div>
@@ -2,6 +2,8 @@ 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;
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('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,17 @@
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
8
10
  }: {
9
11
  token: Tokens.Code;
10
12
  id: string;
13
+ /** The fence is still being streamed; nothing below it is final yet. */
14
+ incomplete?: boolean;
11
15
  } = $props();
12
16
 
13
17
  const streamdown = useStreamdown();
@@ -17,10 +21,21 @@
17
21
  // on nearly every streamed chunk. This is the default renderer, so it needs it
18
22
  // too.
19
23
  const code = $derived(token.text.replace(/\n+$/, ''));
24
+
25
+ // Same scroll container as Code.svelte: `pre` already scrolls horizontally.
26
+ const pinnedScroll = usePinnedScroll({
27
+ get maxHeight() {
28
+ return streamdown.codeBlockMaxHeight;
29
+ },
30
+ get content() {
31
+ return code;
32
+ }
33
+ });
20
34
  </script>
21
35
 
22
36
  <div
23
37
  data-streamdown-code={id}
38
+ data-incomplete={incomplete || undefined}
24
39
  style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
25
40
  class={streamdown.theme.code.base}
26
41
  >
@@ -28,7 +43,11 @@
28
43
  <span class={streamdown.theme.code.language}>{token.lang}</span>
29
44
  </div>
30
45
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
31
- <pre class={streamdown.theme.code.pre}><code
46
+ <pre
47
+ class={streamdown.theme.code.pre}
48
+ style:max-height={streamdown.codeBlockMaxHeight}
49
+ style:overflow-y={streamdown.codeBlockMaxHeight ? 'auto' : undefined}
50
+ {@attach pinnedScroll}><code
32
51
  >{#each code.split('\n') as line}<span class={streamdown.theme.code.line}
33
52
  ><span style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
34
53
  >{line.trim().length > 0 ? line : '\u200B'}</span
@@ -2,6 +2,8 @@ import type { Tokens } from 'marked';
2
2
  type $$ComponentProps = {
3
3
  token: Tokens.Code;
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 CodeFallback: import("svelte").Component<$$ComponentProps, {}, "">;
7
9
  type CodeFallback = ReturnType<typeof CodeFallback>;
@@ -4,10 +4,13 @@
4
4
 
5
5
  const {
6
6
  token,
7
- id
7
+ id,
8
+ incomplete = false
8
9
  }: {
9
10
  token: Tokens.Code;
10
11
  id: string;
12
+ /** The fence is still being streamed; nothing below it is final yet. */
13
+ incomplete?: boolean;
11
14
  } = $props();
12
15
 
13
16
  const streamdown = useStreamdown();
@@ -19,7 +22,7 @@
19
22
  const chart = $derived(token.text.replace(/\n+$/, ''));
20
23
  </script>
21
24
 
22
- <div data-streamdown-mermaid={id}>
25
+ <div data-streamdown-mermaid={id} data-incomplete={incomplete || undefined}>
23
26
  <div
24
27
  style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
25
28
  class={streamdown.theme.code.base}
@@ -2,6 +2,8 @@ import type { Tokens } from 'marked';
2
2
  type $$ComponentProps = {
3
3
  token: Tokens.Code;
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 MermaidFallback: import("svelte").Component<$$ComponentProps, {}, "">;
7
9
  type MermaidFallback = ReturnType<typeof MermaidFallback>;