duckfn-docs-kit 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,12 @@
1
1
  import type {ListTable, ListTableConstructorOptions} from '@visactor/vtable';
2
+ import type {SearchComponent} from '@visactor/vtable-search';
2
3
  import type {QueryResult} from './runtime';
3
4
  import type {RunnableSqlConfig} from './remark';
4
- import {PreviewTabs, type PreviewTabItem, type PreviewTableHandle} from './PreviewTabs';
5
+ import {PreviewTabs, type PreviewTabItem} from './PreviewTabs';
6
+ import {SvgViewer, parseSvgMarkup, type FigureLabels} from './SvgViewer';
5
7
  import {el} from '../dom';
8
+ import {sectionFileName, type DownloadPayload} from '../download';
9
+ import {IconButton} from '../IconButton';
6
10
 
7
11
  /**
8
12
  * Result renderers, keyed by the config's `show` field.
@@ -30,6 +34,13 @@ export interface RenderContext {
30
34
  * renderer only borrows it so every result has the same chrome.
31
35
  */
32
36
  fullscreenButton: HTMLElement;
37
+ /**
38
+ * Subscribes to the result area's fullscreen state, calling back with the
39
+ * current value straight away and returning the unsubscribe. A renderer whose
40
+ * figure zooms only in fullscreen (`svg`, `mermaid`) forwards the value to its
41
+ * viewer; the subscription must be released by the renderer's disposer.
42
+ */
43
+ onFullscreenChange(listener: (value: boolean) => void): () => void;
33
44
  }
34
45
 
35
46
  /**
@@ -46,6 +57,13 @@ export type Renderer = (
46
57
  result: QueryResult,
47
58
  ) => Promise<void | (() => void)>;
48
59
 
60
+ /** What a mounted VTable hands back: its release hook, and its export. */
61
+ interface PreviewTableHandle {
62
+ dispose(): void;
63
+ /** The visible grid as CSV, in the current display order. */
64
+ csv(): string;
65
+ }
66
+
49
67
  /**
50
68
  * The default `sandbox` for the `iframe` renderer: scripts run (HTML reports
51
69
  * draw their charts with them), but `allow-same-origin` is deliberately absent,
@@ -62,27 +80,26 @@ const ROW_HEIGHT = 30;
62
80
  const HEADER_HEIGHT = 32;
63
81
 
64
82
  /**
65
- * Stable `menuKey`s for the context-menu items. VTable fires the click through
66
- * the `dropdown_menu_click` event (see {@link ResultTable.#onMenu}) with
83
+ * Stable `menuKey`s for the context menu. VTable fires a context-menu click
84
+ * through the `dropdown_menu_click` event (see {@link ResultTable.#onMenu}) with
67
85
  * `menuKey = menuItem.menuKey || menuItem.text`, so giving every item an
68
86
  * explicit key keeps the dispatch independent of the (localised) label text.
87
+ *
88
+ * Only the per-cell and per-column items live here now: the actions that act on
89
+ * the grid as a whole (search, copy the table, the width modes, reset, unfreeze)
90
+ * moved to the result area's tab strip, where they neither cover the cells nor
91
+ * sit in a menu a reader has to find.
69
92
  */
70
93
  const MENU = {
71
94
  copyCell: 'dfk-copy-cell',
72
- copyAll: 'dfk-copy-all',
73
95
  wrap: 'dfk-wrap',
74
96
  unwrap: 'dfk-unwrap',
75
97
  freeze: 'dfk-freeze',
76
- unfreeze: 'dfk-unfreeze',
77
- reset: 'dfk-reset',
78
- widthAdaptive: 'dfk-width-adaptive',
79
- widthStandard: 'dfk-width-standard',
80
- widthFill: 'dfk-width-fill',
81
98
  } as const;
82
99
 
83
100
  /**
84
- * The column-width view modes the context menu switches between. Each is a
85
- * plain pair of official VTable options:
101
+ * The column-width view modes the tab strip's width button cycles through. Each
102
+ * is a plain pair of official VTable options:
86
103
  *
87
104
  * - `adaptive` (the default) hands the container width to the columns: every
88
105
  * column keeps its measured content as its share, so the table always fills
@@ -92,26 +109,27 @@ const MENU = {
92
109
  * - `standard` + `autoFillWidth` keeps content widths but stretches them to
93
110
  * fill when the content happens to be narrower than the box.
94
111
  *
95
- * `label`/`fallback` are the labels key and its English default, so a consumer
96
- * that only provides a few strings still gets text for every item.
112
+ * `id` is the button's identity (there is no context-menu entry to key it to any
113
+ * more); `label`/`fallback` are the labels key and its English default, so a
114
+ * consumer that only provides a few strings still gets text for every item.
97
115
  */
98
116
  const WIDTH_MODES = [
99
117
  {
100
- menuKey: MENU.widthAdaptive,
118
+ id: 'dfk-width-adaptive',
101
119
  label: 'widthAdaptive',
102
120
  widthMode: 'adaptive',
103
121
  autoFillWidth: false,
104
122
  fallback: 'Fill the width',
105
123
  },
106
124
  {
107
- menuKey: MENU.widthStandard,
125
+ id: 'dfk-width-standard',
108
126
  label: 'widthStandard',
109
127
  widthMode: 'standard',
110
128
  autoFillWidth: false,
111
129
  fallback: 'Content widths, scroll sideways',
112
130
  },
113
131
  {
114
- menuKey: MENU.widthFill,
132
+ id: 'dfk-width-fill',
115
133
  label: 'widthFill',
116
134
  widthMode: 'standard',
117
135
  autoFillWidth: true,
@@ -119,9 +137,9 @@ const WIDTH_MODES = [
119
137
  },
120
138
  ] as const;
121
139
 
122
- /** The width-mode table row for a `MENU.*` key (the default when unknown). */
123
- function widthModeOption(menuKey: string): (typeof WIDTH_MODES)[number] {
124
- return WIDTH_MODES.find((mode) => mode.menuKey === menuKey) ?? WIDTH_MODES[0];
140
+ /** The width-mode table row for an `id` (the default when unknown). */
141
+ function widthModeOption(id: string): (typeof WIDTH_MODES)[number] {
142
+ return WIDTH_MODES.find((mode) => mode.id === id) ?? WIDTH_MODES[0];
125
143
  }
126
144
 
127
145
  /** Theme shape VTable accepts in the constructor / `updateTheme`. */
@@ -131,6 +149,14 @@ type VTableModule = typeof import('@visactor/vtable');
131
149
  /** The two official themes the table switches between. */
132
150
  type VTableThemeName = 'DEFAULT' | 'DARK';
133
151
  type TableColumns = NonNullable<ListTableConstructorOptions['columns']>;
152
+ /**
153
+ * The search component's highlight style. Its option type demands a complete
154
+ * `CellStyle` where only the background is read, so this names that one
155
+ * property and the tint can be handed over without inventing 26 more.
156
+ */
157
+ type HighlightStyle = NonNullable<
158
+ ConstructorParameters<typeof SearchComponent>[0]['highlightCellStyle']
159
+ >;
134
160
  /**
135
161
  * The context-menu item shape. Mirrors VTable's `MenuListItem`, which is not
136
162
  * re-exported from the package root, so it is spelled out locally.
@@ -210,8 +236,6 @@ function compareValues(a: unknown, b: unknown, order: string): -1 | 0 | 1 {
210
236
  return (String(order).toLowerCase() === 'desc' ? -sign : sign) as -1 | 0 | 1;
211
237
  }
212
238
 
213
- const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
214
-
215
239
  function messageOf(error: unknown): string {
216
240
  return error instanceof Error ? error.message : String(error);
217
241
  }
@@ -228,12 +252,28 @@ function errorText(labels: Record<string, string>, detail: string): string {
228
252
  return `${labels.error ?? 'Error'}: ${detail}`;
229
253
  }
230
254
 
255
+ /**
256
+ * One CSV field, quoted per RFC 4180: a value containing a comma, a double quote
257
+ * or a line break is wrapped in quotes, and its own quotes are doubled. Nothing
258
+ * else is touched, so plain values stay readable in a raw diff.
259
+ */
260
+ function csvCell(value: string): string {
261
+ return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
262
+ }
263
+
231
264
  /**
232
265
  * A VTable result grid with the interaction layer a docs example wants:
233
266
  * sortable columns, clipboard copy, resizable rows/columns, draggable headers,
234
- * cross-highlight on hover, per-column text wrapping and column freezing — all
235
- * driven through VTable's own options and events so the canvas stays the single
236
- * source of truth (nothing is re-laid-out in DOM).
267
+ * cross-highlight on hover, per-column text wrapping, column freezing and a
268
+ * search — all driven through VTable's own options and events so the canvas
269
+ * stays the single source of truth (nothing is re-laid-out in DOM).
270
+ *
271
+ * The actions that act on the grid as a whole (search, copy the table, the
272
+ * column-width view modes, reset the view, unfreeze) live in the result area's
273
+ * tab strip, not in a floating cluster over the canvas: the table is as wide as
274
+ * the page, and anything floating would cover cells. They are built here, into
275
+ * the container the renderer made for this tab's {@link PreviewTabItem.actions}.
276
+ * Only the per-cell and per-column items stay in the context menu.
237
277
  *
238
278
  * All styling comes from VTable's own themes (`themes.DEFAULT` / `themes.DARK`,
239
279
  * picked below by the document's colour scheme). The table deliberately does
@@ -251,14 +291,23 @@ class ResultTable {
251
291
  readonly #labels: Record<string, string>;
252
292
  /** VTable's theme namespace, taken from the dynamic `import()`. */
253
293
  readonly #themes: VTableModule['themes'];
294
+ /** The strip's container for this table's controls. */
295
+ readonly #actions: HTMLElement;
296
+ readonly #searchBtn: IconButton;
297
+ readonly #searchInput: HTMLInputElement;
298
+ readonly #searchCount: HTMLElement;
299
+ readonly #widthBtn: IconButton;
300
+ readonly #unfreezeBtn: IconButton;
254
301
  /** Fields whose column currently wraps (row height switches to `auto`). */
255
302
  readonly #wrapped = new Set<string>();
256
303
  #frozen = 0;
257
304
  #frame = 0;
258
305
  /** The official theme currently applied, so a repaint happens only on change. */
259
306
  #appliedTheme?: VTableThemeName;
260
- /** The active width mode, one of `WIDTH_MODES`' keys (default: adaptive). */
261
- #widthMode: string = MENU.widthAdaptive;
307
+ /** The active width mode's `id` (see `WIDTH_MODES`; default: adaptive). */
308
+ #widthMode: string = WIDTH_MODES[0].id;
309
+ #search: SearchComponent | null = null;
310
+ #searchLoading = false;
262
311
  #observer?: ResizeObserver;
263
312
  #themeObserver?: MutationObserver;
264
313
 
@@ -267,13 +316,26 @@ class ResultTable {
267
316
  record: HTMLElement,
268
317
  result: QueryResult,
269
318
  labels: Record<string, string>,
319
+ actions: HTMLElement,
270
320
  ) {
271
321
  this.#record = record;
272
322
  this.#result = result;
273
323
  this.#labels = labels;
274
324
  this.#themes = vtable.themes;
325
+ this.#actions = actions;
326
+ this.#searchBtn = new IconButton('lucide:search', () => this.#toggleSearch());
327
+ this.#searchInput = el('input', {
328
+ class: 'dfk-sql-search-input',
329
+ type: 'search',
330
+ hidden: true,
331
+ attrs: {placeholder: labels.search ?? 'Search', 'aria-label': labels.search ?? 'Search'},
332
+ });
333
+ this.#searchCount = el('span', {class: 'dfk-sql-search-count', hidden: true});
334
+ this.#widthBtn = new IconButton('lucide:stretch-horizontal', () => this.#cycleWidthMode());
335
+ this.#unfreezeBtn = new IconButton('lucide:pin-off', () => this.#freeze(-1));
275
336
  this.#table = new vtable.ListTable(this.#options());
276
337
  this.#appliedTheme = this.#themeName();
338
+ this.#buildActions();
277
339
 
278
340
  // `resize()` re-measures and repaints inside `record`, so running it straight
279
341
  // from the observer callback feeds the resulting box change back into the very
@@ -303,6 +365,152 @@ class ResultTable {
303
365
  this.#table.on('dropdown_menu_click', (args) => this.#onMenu(args));
304
366
  }
305
367
 
368
+ /**
369
+ * Builds the strip's controls, once. Each is a plain button or input held in a
370
+ * field — the search box is revealed in place rather than opened as a popover,
371
+ * because the strip has the room and a popover over a full-width table is the
372
+ * thing this arrangement exists to avoid.
373
+ */
374
+ #buildActions(): void {
375
+ const copyAll = new IconButton('lucide:copy', () => void this.#copy(this.#allText()));
376
+ copyAll.setLabel(this.#labels.copyAll ?? 'Copy table');
377
+ const reset = new IconButton('lucide:rotate-ccw', () => this.#reset());
378
+ reset.setLabel(this.#labels.resetView ?? 'Reset view');
379
+ this.#searchBtn.setLabel(this.#labels.search ?? 'Search');
380
+ this.#unfreezeBtn.setLabel(this.#labels.unfreezeColumns ?? 'Unfreeze columns');
381
+ this.#searchInput.addEventListener('input', () => this.#runSearch());
382
+ this.#searchInput.addEventListener('keydown', (event) => this.#onSearchKey(event));
383
+ this.#actions.append(
384
+ this.#searchBtn.root,
385
+ this.#searchCount,
386
+ this.#searchInput,
387
+ copyAll.root,
388
+ this.#widthBtn.root,
389
+ reset.root,
390
+ this.#unfreezeBtn.root,
391
+ );
392
+ this.#updateWidthLabel();
393
+ this.#updateUnfreeze();
394
+ }
395
+
396
+ // --- Search ----------------------------------------------------------------
397
+
398
+ /** `@visactor/vtable-search`, loaded on first use like every other heavy dep. */
399
+ async #ensureSearch(): Promise<SearchComponent | null> {
400
+ if (this.#search !== null || this.#searchLoading) {
401
+ return this.#search;
402
+ }
403
+ this.#searchLoading = true;
404
+ try {
405
+ const {SearchComponent} = await import('@visactor/vtable-search');
406
+ // The highlight has to be chosen per colour mode: a translucent amber that
407
+ // reads as "found" over a light cell is glaring over a dark one. The
408
+ // vendor's option type asks for a *complete* `CellStyle` even though it
409
+ // only reads the background, so the tint is cast — see `HighlightStyle`.
410
+ const dark = this.#themeName() === 'DARK';
411
+ this.#search = new SearchComponent({
412
+ table: this.#table,
413
+ // The header row is not content; matching a column title would point the
414
+ // reader at a cell they cannot compare with anything.
415
+ skipHeader: true,
416
+ highlightCellStyle: {
417
+ bgColor: dark ? 'rgba(255, 214, 0, 0.25)' : 'rgba(255, 214, 0, 0.45)',
418
+ } as HighlightStyle,
419
+ focusHighlightCellStyle: {
420
+ bgColor: dark ? 'rgba(255, 152, 0, 0.55)' : 'rgba(255, 152, 0, 0.7)',
421
+ } as HighlightStyle,
422
+ });
423
+ } catch {
424
+ // Unavailable (offline, CDN blocked): the box stays, the search does not.
425
+ } finally {
426
+ this.#searchLoading = false;
427
+ }
428
+ return this.#search;
429
+ }
430
+
431
+ #toggleSearch(): void {
432
+ if (!this.#searchInput.hidden) {
433
+ this.#closeSearch();
434
+ return;
435
+ }
436
+ this.#searchInput.hidden = false;
437
+ this.#searchBtn.setOn(true);
438
+ this.#searchInput.focus();
439
+ }
440
+
441
+ #closeSearch(): void {
442
+ this.#searchInput.value = '';
443
+ this.#searchInput.hidden = true;
444
+ this.#searchCount.hidden = true;
445
+ this.#searchCount.textContent = '';
446
+ this.#searchBtn.setOn(false);
447
+ this.#search?.clear();
448
+ }
449
+
450
+ #onSearchKey(event: KeyboardEvent): void {
451
+ if (event.key === 'Escape') {
452
+ event.preventDefault();
453
+ this.#closeSearch();
454
+ return;
455
+ }
456
+ if (event.key !== 'Enter') {
457
+ return;
458
+ }
459
+ // Enter walks forward through the matches, Shift+Enter back.
460
+ event.preventDefault();
461
+ const query = this.#searchInput.value.trim();
462
+ if (!query || this.#search === null) {
463
+ return;
464
+ }
465
+ this.#reportSearch(event.shiftKey ? this.#search.prev() : this.#search.next());
466
+ }
467
+
468
+ async #runSearch(): Promise<void> {
469
+ const query = this.#searchInput.value.trim();
470
+ if (!query) {
471
+ this.#search?.clear();
472
+ this.#reportSearch(null);
473
+ return;
474
+ }
475
+ const search = await this.#ensureSearch();
476
+ if (search === null) {
477
+ return;
478
+ }
479
+ this.#reportSearch(search.search(query));
480
+ }
481
+
482
+ /** Shows `current / total` while a query is active, nothing otherwise. */
483
+ #reportSearch(result: {index: number; results: unknown[]} | null): void {
484
+ const total = result?.results.length ?? 0;
485
+ const current = total === 0 ? 0 : (result?.index ?? 0) + 1;
486
+ if (this.#searchInput.value.trim() === '') {
487
+ this.#searchCount.textContent = '';
488
+ this.#searchCount.hidden = true;
489
+ return;
490
+ }
491
+ this.#searchCount.textContent = `${current}/${total}`;
492
+ this.#searchCount.hidden = false;
493
+ }
494
+
495
+ // --- Strip actions ---------------------------------------------------------
496
+
497
+ /** Cycles adaptive → content → content-fill → adaptive (see `WIDTH_MODES`). */
498
+ #cycleWidthMode(): void {
499
+ const index = WIDTH_MODES.findIndex((mode) => mode.id === this.#widthMode);
500
+ this.#setWidthMode(WIDTH_MODES[(index + 1) % WIDTH_MODES.length]);
501
+ this.#updateWidthLabel();
502
+ }
503
+
504
+ /** The tooltip names the mode now active, since the icon alone cannot. */
505
+ #updateWidthLabel(): void {
506
+ const mode = widthModeOption(this.#widthMode);
507
+ this.#widthBtn.setLabel(this.#labels[mode.label] ?? mode.fallback);
508
+ }
509
+
510
+ #updateUnfreeze(): void {
511
+ this.#unfreezeBtn.root.hidden = this.#frozen === 0;
512
+ }
513
+
306
514
  #columns(order: readonly string[]): TableColumns {
307
515
  return order.map((field) => ({
308
516
  field,
@@ -433,46 +641,29 @@ class ResultTable {
433
641
  };
434
642
  }
435
643
 
644
+ /**
645
+ * The context menu, which now holds only what acts on *one cell or one
646
+ * column*: the field name, copy, wrap and freeze. The grid-wide actions sit in
647
+ * the tab strip instead (see {@link #buildActions}), so the menu cannot grow
648
+ * into a second, hidden toolbar.
649
+ */
436
650
  #menuItems(field: string): TableMenuItem[] {
437
651
  const labels = this.#labels;
438
652
  const wrapped = this.#wrapped.has(field);
439
- const freezeOrUnfreeze: TableMenuItem =
440
- this.#frozen > 0
441
- ? {text: labels.unfreezeColumns ?? 'Unfreeze columns', menuKey: MENU.unfreeze}
442
- : {text: labels.freezeColumn ?? 'Freeze up to here', menuKey: MENU.freeze};
443
653
  return [
444
654
  {text: field, type: 'title'},
445
655
  {type: 'split'},
446
656
  {text: labels.copy ?? 'Copy cell', menuKey: MENU.copyCell},
447
- {text: labels.copyAll ?? 'Copy table', menuKey: MENU.copyAll},
448
657
  {
449
658
  text: wrapped
450
659
  ? (labels.unwrapColumn ?? 'Stop wrapping column')
451
660
  : (labels.wrapColumn ?? 'Wrap column'),
452
661
  menuKey: wrapped ? MENU.unwrap : MENU.wrap,
453
662
  },
454
- freezeOrUnfreeze,
455
- {type: 'split'},
456
- // The width modes sit right above "reset": both are view switches.
457
- {text: labels.widthMode ?? 'Column width', children: this.#widthModeItems()},
458
- {text: labels.resetView ?? 'Reset view', menuKey: MENU.reset},
663
+ {text: labels.freezeColumn ?? 'Freeze up to here', menuKey: MENU.freeze},
459
664
  ];
460
665
  }
461
666
 
462
- /**
463
- * The width-mode submenu. The vendor html menu has no check state of its own
464
- * (its `--select` highlight is driven by `menu.dropDownMenuHighlight`, which
465
- * only resolves against the cell being clicked), so the active mode carries a
466
- * leading tick — item text goes through `innerHTML`, but a plain character is
467
- * safe.
468
- */
469
- #widthModeItems(): TableMenuItem[] {
470
- return WIDTH_MODES.map((mode) => ({
471
- text: `${mode.menuKey === this.#widthMode ? '✓ ' : ''}${this.#labels[mode.label] ?? mode.fallback}`,
472
- menuKey: mode.menuKey,
473
- }));
474
- }
475
-
476
667
  /**
477
668
  * Dispatch a context-menu click.
478
669
  *
@@ -496,9 +687,6 @@ class ResultTable {
496
687
  case MENU.copyCell:
497
688
  void this.#copy(this.#cellText(col, row));
498
689
  break;
499
- case MENU.copyAll:
500
- void this.#copy(this.#allText());
501
- break;
502
690
  case MENU.wrap:
503
691
  if (field) {
504
692
  this.#toggleWrap(field, true);
@@ -512,17 +700,6 @@ class ResultTable {
512
700
  case MENU.freeze:
513
701
  this.#freeze(col);
514
702
  break;
515
- case MENU.unfreeze:
516
- this.#freeze(-1);
517
- break;
518
- case MENU.widthAdaptive:
519
- case MENU.widthStandard:
520
- case MENU.widthFill:
521
- this.#setWidthMode(widthModeOption(menuKey));
522
- break;
523
- case MENU.reset:
524
- this.#reset();
525
- break;
526
703
  }
527
704
  }
528
705
 
@@ -556,6 +733,7 @@ class ResultTable {
556
733
  // assuming `col + 1` stuck.
557
734
  this.#table.setFrozenColCount(col < 0 ? 0 : col + 1);
558
735
  this.#frozen = this.#table.frozenColCount;
736
+ this.#updateUnfreeze();
559
737
  }
560
738
 
561
739
  /**
@@ -569,10 +747,10 @@ class ResultTable {
569
747
  * change starts from a clean slate — while the user's row heights survive.
570
748
  */
571
749
  #setWidthMode(mode: (typeof WIDTH_MODES)[number]): void {
572
- if (mode.menuKey === this.#widthMode) {
750
+ if (mode.id === this.#widthMode) {
573
751
  return;
574
752
  }
575
- this.#widthMode = mode.menuKey;
753
+ this.#widthMode = mode.id;
576
754
  this.#table.widthMode = mode.widthMode;
577
755
  this.#table.autoFillWidth = mode.autoFillWidth;
578
756
  this.#table.updateColumns(this.#columns(this.#displayOrder()), {
@@ -585,7 +763,7 @@ class ResultTable {
585
763
  this.#wrapped.clear();
586
764
  this.#frozen = 0;
587
765
  // The width mode is a view switch too, so "reset" returns it to the default.
588
- this.#widthMode = MENU.widthAdaptive;
766
+ this.#widthMode = WIDTH_MODES[0].id;
589
767
  // `updateOption` (unlike `updateColumns`) also resets the sort state, and
590
768
  // with both caches cleared it drops the dragged widths/heights too — a true
591
769
  // "back to the initial view". `#options()` carries the query's column order,
@@ -594,12 +772,14 @@ class ResultTable {
594
772
  clearColWidthCache: true,
595
773
  clearRowHeightCache: true,
596
774
  });
775
+ this.#updateWidthLabel();
776
+ this.#updateUnfreeze();
597
777
  }
598
778
 
599
779
  /** The whole visible grid as tab-separated text, in current display order. */
600
780
  #allText(): string {
601
781
  const table = this.#table;
602
- const lines = [this.#result.columns.join('\t')];
782
+ const lines = [this.#displayOrder().join('\t')];
603
783
  for (let row = table.columnHeaderLevelCount; row < table.rowCount; row += 1) {
604
784
  const cells: string[] = [];
605
785
  for (let col = 0; col < table.colCount; col += 1) {
@@ -610,6 +790,25 @@ class ResultTable {
610
790
  return lines.join('\n');
611
791
  }
612
792
 
793
+ /**
794
+ * The whole visible grid as CSV, in the current display order (the header
795
+ * included). Distinct from {@link #allText}, which is tab-separated and only
796
+ * ever lands on the clipboard: a downloaded file is opened in a spreadsheet,
797
+ * so it has to be genuinely comma-separated and quoted.
798
+ */
799
+ csv(): string {
800
+ const table = this.#table;
801
+ const lines = [this.#displayOrder().map(csvCell).join(',')];
802
+ for (let row = table.columnHeaderLevelCount; row < table.rowCount; row += 1) {
803
+ const cells: string[] = [];
804
+ for (let col = 0; col < table.colCount; col += 1) {
805
+ cells.push(csvCell(stringify(table.getCellRawValue(col, row))));
806
+ }
807
+ lines.push(cells.join(','));
808
+ }
809
+ return lines.join('\n');
810
+ }
811
+
613
812
  async #copy(text: string): Promise<void> {
614
813
  try {
615
814
  await navigator.clipboard.writeText(text);
@@ -659,7 +858,8 @@ function tableSkeleton(): HTMLElement {
659
858
  }
660
859
 
661
860
  /**
662
- * Mounts a VTable list into `parent`, creating the `.dfk-sql-table` box itself.
861
+ * Mounts a VTable list into `parent`, creating the `.dfk-sql-table` box itself,
862
+ * and fills `actions` with the controls that act on the table as a whole.
663
863
  *
664
864
  * VTable is canvas-rendered and measures its container at construction time, so
665
865
  * the box gets an explicit height through a custom property (which the
@@ -669,12 +869,13 @@ function tableSkeleton(): HTMLElement {
669
869
  * without any resize plumbing through the component.
670
870
  *
671
871
  * A failed `import()` (offline, CDN blocked) degrades to the error view instead
672
- * of rejecting the render.
872
+ * of rejecting the render; the CSV export keeps working from the raw rows.
673
873
  */
674
874
  async function mountTable(
675
875
  parent: HTMLElement,
676
876
  result: QueryResult,
677
877
  labels: Record<string, string>,
878
+ actions: HTMLElement,
678
879
  ): Promise<PreviewTableHandle> {
679
880
  const document = parent.ownerDocument;
680
881
  const record = el('div', {class: 'dfk-sql-table'});
@@ -685,40 +886,84 @@ async function mountTable(
685
886
 
686
887
  try {
687
888
  const vtable = await import('@visactor/vtable');
688
- const table = new ResultTable(vtable, record, result, labels);
889
+ const table = new ResultTable(vtable, record, result, labels, actions);
689
890
  // VTable draws into the same box; the ghost is one sibling too many. It is
690
891
  // dropped after construction (not by VTable) so a failed import can still
691
892
  // hand the box over to the error view.
692
893
  skeleton.remove();
693
- return {dispose: () => table.dispose()};
894
+ return {dispose: () => table.dispose(), csv: () => table.csv()};
694
895
  } catch (error) {
695
896
  skeleton.remove();
696
897
  record.appendChild(errorBlock(document, errorText(labels, messageOf(error))));
697
- return {dispose: () => {}};
898
+ return {dispose: () => {}, csv: () => csvText(result)};
698
899
  }
699
900
  }
700
901
 
902
+ /**
903
+ * The trailing `Table` tab, as a plain {@link PreviewTabItem} like any other.
904
+ *
905
+ * It is built up front — before the tab is ever shown — because the tab strip
906
+ * needs the item's `actions` container in its constructor, while the table
907
+ * itself is only created on first activation (VTable is the heaviest lazy
908
+ * import in the kit). `download` therefore reads a live handle, and answers
909
+ * `null` until the table exists rather than exporting something else.
910
+ */
911
+ function tableItem(
912
+ result: QueryResult,
913
+ labels: Record<string, string>,
914
+ host: HTMLElement,
915
+ ): PreviewTabItem {
916
+ const actions = el('div', {class: 'dfk-sql-tab-actions'});
917
+ let handle: PreviewTableHandle | null = null;
918
+ return {
919
+ label: labels.table ?? 'Table',
920
+ actions,
921
+ mount: async (panel) => {
922
+ handle = await mountTable(panel, result, labels, actions);
923
+ return () => {
924
+ handle?.dispose();
925
+ handle = null;
926
+ };
927
+ },
928
+ download: (): DownloadPayload | null => {
929
+ if (handle === null) {
930
+ return null;
931
+ }
932
+ return {
933
+ name: sectionFileName(host, 'csv', {fallback: 'table'}),
934
+ mime: 'text/csv;charset=utf-8',
935
+ text: handle.csv(),
936
+ };
937
+ },
938
+ };
939
+ }
940
+
701
941
  const tableRenderer: Renderer = async ({host, labels, fullscreenButton}, result) => {
702
942
  const tabs = new PreviewTabs(
703
943
  host,
704
- [],
705
- labels.table ?? 'Table',
706
- (panel) => mountTable(panel, result, labels),
944
+ [tableItem(result, labels, host)],
945
+ labels.download ?? 'Download',
707
946
  fullscreenButton,
708
947
  );
709
948
  return () => tabs.dispose();
710
949
  };
711
950
 
712
951
  /** Plain text: one line per row, columns tab-joined. */
713
- function textBlock(document: Document, result: QueryResult): HTMLPreElement {
714
- const pre = document.createElement('pre');
715
- pre.className = 'dfk-sql-text';
952
+ function rowsText(result: QueryResult): string {
716
953
  const lines = [result.columns.join('\t')];
717
954
  for (const row of result.rows) {
718
- lines.push(result.columns.map((c) => stringify(row[c])).join('\t'));
955
+ lines.push(result.columns.map((column) => stringify(row[column])).join('\t'));
719
956
  }
720
- pre.textContent = lines.join('\n');
721
- return pre;
957
+ return lines.join('\n');
958
+ }
959
+
960
+ /** The raw rows as CSV; the export used when VTable itself never loaded. */
961
+ function csvText(result: QueryResult): string {
962
+ const lines = [result.columns.map(csvCell).join(',')];
963
+ for (const row of result.rows) {
964
+ lines.push(result.columns.map((column) => csvCell(stringify(row[column]))).join(','));
965
+ }
966
+ return lines.join('\n');
722
967
  }
723
968
 
724
969
  /**
@@ -726,16 +971,24 @@ function textBlock(document: Document, result: QueryResult): HTMLPreElement {
726
971
  * trailing `Table` tab, so a scalar can still be inspected as a table.
727
972
  */
728
973
  const textRenderer: Renderer = async ({host, labels, fullscreenButton}, result) => {
974
+ const text = rowsText(result);
729
975
  const tabs = new PreviewTabs(
730
976
  host,
731
977
  [
732
978
  {
733
979
  label: labels.text ?? 'Text',
734
- mount: (panel) => panel.appendChild(textBlock(panel.ownerDocument, result)),
980
+ mount: (panel) => {
981
+ panel.appendChild(el('pre', {class: 'dfk-sql-text', text}));
982
+ },
983
+ download: () => ({
984
+ name: sectionFileName(host, 'txt', {fallback: 'text'}),
985
+ mime: 'text/plain;charset=utf-8',
986
+ text,
987
+ }),
735
988
  },
989
+ tableItem(result, labels, host),
736
990
  ],
737
- labels.table ?? 'Table',
738
- (panel) => mountTable(panel, result, labels),
991
+ labels.download ?? 'Download',
739
992
  fullscreenButton,
740
993
  );
741
994
  return () => tabs.dispose();
@@ -757,48 +1010,6 @@ function resolveField(config: RunnableSqlConfig, result: QueryResult): string |
757
1010
  return result.columns.length === 1 ? result.columns[0] : null;
758
1011
  }
759
1012
 
760
- /**
761
- * Parses SVG markup from a result cell into a node this document can host.
762
- *
763
- * `image/svg+xml` is strict XML: malformed markup (unclosed tags, a bare `&`,
764
- * an HTML `<br>`) comes back as a `<parsererror>` element rather than throwing,
765
- * so the caller can degrade to text. Script-bearing and event-handler content
766
- * is stripped — inline SVG is *not* isolated (use the `iframe` renderer for
767
- * untrusted markup).
768
- */
769
- function parseSvgMarkup(document: Document, markup: string): SVGElement | null {
770
- if (!markup.trim()) {
771
- return null;
772
- }
773
- const parsed = new DOMParser().parseFromString(markup, 'image/svg+xml');
774
- const root = parsed.documentElement;
775
- if (!root || root.localName === 'parsererror' || root.namespaceURI !== SVG_NAMESPACE) {
776
- return null;
777
- }
778
- stripActiveContent(root);
779
- return document.importNode(root, true) as unknown as SVGElement;
780
- }
781
-
782
- /** Removes the parts of an SVG document that could execute or navigate. */
783
- function stripActiveContent(root: Element): void {
784
- for (const node of root.querySelectorAll('script, foreignObject')) {
785
- node.remove();
786
- }
787
- const visit = (element: Element): void => {
788
- for (const attribute of [...element.attributes]) {
789
- const name = attribute.name.toLowerCase();
790
- const value = attribute.value.replace(/\s/g, '').toLowerCase();
791
- if (name.startsWith('on') || ((name === 'href' || name === 'xlink:href') && value.startsWith('javascript:'))) {
792
- element.removeAttribute(attribute.name);
793
- }
794
- }
795
- for (const child of element.children) {
796
- visit(child);
797
- }
798
- };
799
- visit(root);
800
- }
801
-
802
1013
  /** Applies `option.width` / `option.height` as custom properties the CSS consumes. */
803
1014
  function applyPreviewSize(node: HTMLElement, config: RunnableSqlConfig): void {
804
1015
  const {width, height} = config.option ?? {};
@@ -810,17 +1021,34 @@ function applyPreviewSize(node: HTMLElement, config: RunnableSqlConfig): void {
810
1021
  }
811
1022
  }
812
1023
 
813
- /** How a preview panel is filled for one row of the result. */
1024
+ /** What a preview tab shows for one row of the result. */
814
1025
  type PreviewKind = 'iframe' | 'svg' | 'mermaid';
815
1026
 
816
- function mountPreviewPanel(
1027
+ /** A result cell as markup: `NULL` means there is nothing to show. */
1028
+ function markupOf(value: unknown): string {
1029
+ return value === null || value === undefined ? '' : String(value);
1030
+ }
1031
+
1032
+ /**
1033
+ * Builds one figure tab: the row's markup, shown the way its `kind` calls for.
1034
+ *
1035
+ * All three kinds are built *before* the tab is shown, because the tab strip
1036
+ * needs their `actions` (svg / mermaid) in its own constructor; `mount` only
1037
+ * appends a node that already exists. That is what lets `svg` and `mermaid` sit
1038
+ * inside the result panel with no frame of their own — the panel is the frame —
1039
+ * and hand their controls to the strip rather than floating them over the
1040
+ * content, which is also why the file travels out as a payload instead of a
1041
+ * button. An embedded `<dfk-mermaid>` is driven from the outside through
1042
+ * `setFullscreen`; an `iframe` has no viewer and so no fullscreen interest.
1043
+ */
1044
+ function createFigure(
817
1045
  kind: PreviewKind,
818
- panel: HTMLElement,
819
- value: unknown,
1046
+ markup: string,
820
1047
  label: string,
821
1048
  config: RunnableSqlConfig,
822
- ): void {
823
- const markup = value === null || value === undefined ? '' : String(value);
1049
+ host: HTMLElement,
1050
+ figureLabels: FigureLabels,
1051
+ ): PreviewTabItem {
824
1052
  if (kind === 'iframe') {
825
1053
  const frame = el('iframe', {
826
1054
  class: 'dfk-sql-frame',
@@ -835,43 +1063,62 @@ function mountPreviewPanel(
835
1063
  },
836
1064
  });
837
1065
  applyPreviewSize(frame, config);
838
- panel.appendChild(frame);
839
- return;
1066
+ return {
1067
+ label,
1068
+ mount: (panel) => {
1069
+ panel.appendChild(frame);
1070
+ },
1071
+ download: () => ({
1072
+ name: sectionFileName(host, 'html', {fallback: 'preview'}),
1073
+ mime: 'text/html;charset=utf-8',
1074
+ text: markup,
1075
+ }),
1076
+ };
840
1077
  }
841
1078
 
842
1079
  if (kind === 'mermaid') {
843
1080
  // The cell is handed to the kit's own diagram element rather than rendered
844
- // here: `<dfk-mermaid>` loads mermaid through the page-wide render queue and
845
- // brings the zoom / fullscreen / edit / download chrome with it. A `mermaid`
846
- // result and a ```mermaid fence therefore behave identically, and there is
1081
+ // here: `<dfk-mermaid>` loads mermaid through the page-wide render queue, so
1082
+ // a `mermaid` result and a ```mermaid fence share one implementation — and
847
1083
  // one place that knows about the dark-mode-first-load fix.
848
1084
  //
849
- // The source travels as an attribute (the element's attribute seed) rather
850
- // than through a setter, so this works whether or not the element has been
851
- // upgraded yet: an attribute set before insertion is read at upgrade time.
852
- panel.appendChild(el('dfk-mermaid', {attrs: {source: markup}}));
853
- return;
854
- }
855
-
856
- const svg = parseSvgMarkup(panel.ownerDocument, markup);
857
- if (!svg) {
858
- // Not SVG: show the markup as text rather than an empty panel.
859
- panel.appendChild(el('pre', {class: 'dfk-sql-text', text: markup}));
860
- return;
861
- }
862
- const holder = el('div', {class: 'dfk-sql-svg'});
863
- holder.appendChild(svg);
864
- applyPreviewSize(holder, config);
865
- panel.appendChild(holder);
1085
+ // `embedded` makes it shed its frame and its floating cluster; the source
1086
+ // travels as an attribute (the element's attribute seed), so this works
1087
+ // whether or not the element has been upgraded yet. Reading `actions` seeds
1088
+ // the element, which is why it is safe to ask before inserting it.
1089
+ const element = el('dfk-mermaid', {attrs: {embedded: '', source: markup}});
1090
+ return {
1091
+ label,
1092
+ actions: element.actions,
1093
+ mount: (panel) => {
1094
+ panel.appendChild(element);
1095
+ },
1096
+ download: () => element.downloadPayload(),
1097
+ setFullscreen: (value) => element.setFullscreen(value),
1098
+ };
1099
+ }
1100
+
1101
+ const viewer = new SvgViewer(parseSvgMarkup(host.ownerDocument, markup), markup, figureLabels, 'figure');
1102
+ applyPreviewSize(viewer.root, config);
1103
+ return {
1104
+ label,
1105
+ actions: viewer.actions,
1106
+ mount: (panel) => {
1107
+ panel.appendChild(viewer.root);
1108
+ return () => viewer.dispose();
1109
+ },
1110
+ download: () => viewer.downloadPayload(),
1111
+ setFullscreen: (value) => viewer.setFullscreen(value),
1112
+ };
866
1113
  }
867
1114
 
868
1115
  /**
869
1116
  * Builds a preview renderer: one tab per row, then the raw rows in the trailing
870
- * `Table` tab. `iframe`, `svg` and `mermaid` share everything except how a panel
871
- * is filled.
1117
+ * `Table` tab. `iframe`, `svg` and `mermaid` share everything except how a
1118
+ * figure is built.
872
1119
  */
873
1120
  function previewRenderer(kind: PreviewKind): Renderer {
874
- return async ({host, config, labels, fullscreenButton}, result) => {
1121
+ return async ({host, config, labels, fullscreenButton, onFullscreenChange}, result) => {
875
1122
  const field = resolveField(config, result);
876
1123
  if (!field) {
877
1124
  host.replaceChildren(
@@ -883,23 +1130,29 @@ function previewRenderer(kind: PreviewKind): Renderer {
883
1130
  return;
884
1131
  }
885
1132
 
1133
+ // A figure zooms only in the result area's fullscreen, which the renderer
1134
+ // hears about through the subscription below.
1135
+ const figureLabels: FigureLabels = {
1136
+ reset: labels.resetZoom ?? 'Reset zoom',
1137
+ edit: labels.editSource ?? 'Edit source',
1138
+ editTitle: labels.svgSource ?? 'SVG source',
1139
+ apply: labels.apply ?? 'Apply',
1140
+ cancel: labels.cancel ?? 'Cancel',
1141
+ };
886
1142
  const tabName = config.tab_name;
887
1143
  const items: PreviewTabItem[] = result.rows.map((row, index) => {
888
1144
  const value = tabName ? row[tabName] : undefined;
889
- return {
890
- label: value === null || value === undefined ? `${labels.row ?? 'Row'} ${index + 1}` : stringify(value),
891
- mount: (panel) => mountPreviewPanel(kind, panel, row[field], '', config),
892
- };
1145
+ const label = value === null || value === undefined ? `${labels.row ?? 'Row'} ${index + 1}` : stringify(value);
1146
+ return createFigure(kind, markupOf(row[field]), label, config, host, figureLabels);
893
1147
  });
1148
+ items.push(tableItem(result, labels, host));
894
1149
 
895
- const tabs = new PreviewTabs(
896
- host,
897
- items,
898
- labels.table ?? 'Table',
899
- (panel) => mountTable(panel, result, labels),
900
- fullscreenButton,
901
- );
902
- return () => tabs.dispose();
1150
+ const tabs = new PreviewTabs(host, items, labels.download ?? 'Download', fullscreenButton);
1151
+ const unsubscribe = kind === 'iframe' ? null : onFullscreenChange((value) => tabs.setFullscreen(value));
1152
+ return () => {
1153
+ unsubscribe?.();
1154
+ tabs.dispose();
1155
+ };
903
1156
  };
904
1157
  }
905
1158