duckfn-docs-kit 0.3.0 → 0.4.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 (47) hide show
  1. package/AGENTS.md +326 -234
  2. package/README.md +10 -6
  3. package/dist/IconButton.d.ts +22 -0
  4. package/dist/codemirror.d.ts +29 -0
  5. package/dist/download.d.ts +51 -0
  6. package/dist/index.d.ts +28 -7
  7. package/dist/index.js +2 -2
  8. package/dist/mermaid/DfkMermaid.d.ts +25 -0
  9. package/dist/mermaid/config.d.ts +48 -0
  10. package/dist/mermaid/remark.d.ts +40 -0
  11. package/dist/mermaid/remark.js +32 -0
  12. package/dist/mermaid/render.d.ts +92 -0
  13. package/dist/mermaid/styles.d.ts +6 -0
  14. package/dist/panzoom-view.d.ts +17 -0
  15. package/dist/{register-CALCwFBv.js → register-wdwf0LC4.js} +1365 -738
  16. package/dist/remark.d.ts +1 -1
  17. package/dist/source-dialog.d.ts +35 -0
  18. package/dist/sql/PreviewTabs.d.ts +39 -23
  19. package/dist/sql/SvgViewer.d.ts +53 -0
  20. package/dist/sql/client.js +1 -1
  21. package/dist/sql/harness.js +1 -1
  22. package/dist/sql/remark.d.ts +6 -1
  23. package/dist/sql/renderers.d.ts +9 -0
  24. package/package.json +6 -2
  25. package/src/IconButton.ts +50 -0
  26. package/src/codemirror.ts +97 -0
  27. package/src/download.ts +169 -0
  28. package/src/index.ts +31 -7
  29. package/src/mermaid/DfkMermaid.css +326 -0
  30. package/src/mermaid/DfkMermaid.ts +411 -0
  31. package/src/mermaid/config.ts +74 -0
  32. package/src/mermaid/remark.ts +98 -0
  33. package/src/mermaid/render.ts +172 -0
  34. package/src/mermaid/styles.ts +24 -0
  35. package/src/panzoom-view.ts +235 -0
  36. package/src/register.ts +3 -0
  37. package/src/remark.ts +1 -1
  38. package/src/source-dialog.ts +126 -0
  39. package/src/sql/DfkSql.css +13 -10
  40. package/src/sql/DfkSql.ts +60 -51
  41. package/src/sql/PreviewTabs.ts +98 -52
  42. package/src/sql/SvgViewer.ts +155 -0
  43. package/src/sql/remark.ts +6 -1
  44. package/src/sql/renderers.ts +433 -159
  45. package/src/sql/sql.css +189 -19
  46. package/dist/sql/editor.d.ts +0 -16
  47. package/src/sql/editor.ts +0 -75
@@ -1,14 +1,20 @@
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.
9
13
  *
10
14
  * The registry is the seam later phases plug into. It ships `table` (VisActor
11
15
  * VTable), a `text` fallback, the markup previews `iframe` / `html` / `svg`,
16
+ * `mermaid` (which hands the cell to the kit's own `<dfk-mermaid>` element, so a
17
+ * query can produce a diagram the reader can zoom, expand, edit and download),
12
18
  * plus the `error` view every renderer shares.
13
19
  *
14
20
  * Heavy dependencies (`@visactor/vtable`) load through dynamic `import()`
@@ -28,6 +34,13 @@ export interface RenderContext {
28
34
  * renderer only borrows it so every result has the same chrome.
29
35
  */
30
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;
31
44
  }
32
45
 
33
46
  /**
@@ -44,6 +57,13 @@ export type Renderer = (
44
57
  result: QueryResult,
45
58
  ) => Promise<void | (() => void)>;
46
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
+
47
67
  /**
48
68
  * The default `sandbox` for the `iframe` renderer: scripts run (HTML reports
49
69
  * draw their charts with them), but `allow-same-origin` is deliberately absent,
@@ -60,27 +80,26 @@ const ROW_HEIGHT = 30;
60
80
  const HEADER_HEIGHT = 32;
61
81
 
62
82
  /**
63
- * Stable `menuKey`s for the context-menu items. VTable fires the click through
64
- * 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
65
85
  * `menuKey = menuItem.menuKey || menuItem.text`, so giving every item an
66
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.
67
92
  */
68
93
  const MENU = {
69
94
  copyCell: 'dfk-copy-cell',
70
- copyAll: 'dfk-copy-all',
71
95
  wrap: 'dfk-wrap',
72
96
  unwrap: 'dfk-unwrap',
73
97
  freeze: 'dfk-freeze',
74
- unfreeze: 'dfk-unfreeze',
75
- reset: 'dfk-reset',
76
- widthAdaptive: 'dfk-width-adaptive',
77
- widthStandard: 'dfk-width-standard',
78
- widthFill: 'dfk-width-fill',
79
98
  } as const;
80
99
 
81
100
  /**
82
- * The column-width view modes the context menu switches between. Each is a
83
- * 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:
84
103
  *
85
104
  * - `adaptive` (the default) hands the container width to the columns: every
86
105
  * column keeps its measured content as its share, so the table always fills
@@ -90,26 +109,27 @@ const MENU = {
90
109
  * - `standard` + `autoFillWidth` keeps content widths but stretches them to
91
110
  * fill when the content happens to be narrower than the box.
92
111
  *
93
- * `label`/`fallback` are the labels key and its English default, so a consumer
94
- * 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.
95
115
  */
96
116
  const WIDTH_MODES = [
97
117
  {
98
- menuKey: MENU.widthAdaptive,
118
+ id: 'dfk-width-adaptive',
99
119
  label: 'widthAdaptive',
100
120
  widthMode: 'adaptive',
101
121
  autoFillWidth: false,
102
122
  fallback: 'Fill the width',
103
123
  },
104
124
  {
105
- menuKey: MENU.widthStandard,
125
+ id: 'dfk-width-standard',
106
126
  label: 'widthStandard',
107
127
  widthMode: 'standard',
108
128
  autoFillWidth: false,
109
129
  fallback: 'Content widths, scroll sideways',
110
130
  },
111
131
  {
112
- menuKey: MENU.widthFill,
132
+ id: 'dfk-width-fill',
113
133
  label: 'widthFill',
114
134
  widthMode: 'standard',
115
135
  autoFillWidth: true,
@@ -117,9 +137,9 @@ const WIDTH_MODES = [
117
137
  },
118
138
  ] as const;
119
139
 
120
- /** The width-mode table row for a `MENU.*` key (the default when unknown). */
121
- function widthModeOption(menuKey: string): (typeof WIDTH_MODES)[number] {
122
- 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];
123
143
  }
124
144
 
125
145
  /** Theme shape VTable accepts in the constructor / `updateTheme`. */
@@ -129,6 +149,14 @@ type VTableModule = typeof import('@visactor/vtable');
129
149
  /** The two official themes the table switches between. */
130
150
  type VTableThemeName = 'DEFAULT' | 'DARK';
131
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
+ >;
132
160
  /**
133
161
  * The context-menu item shape. Mirrors VTable's `MenuListItem`, which is not
134
162
  * re-exported from the package root, so it is spelled out locally.
@@ -208,8 +236,6 @@ function compareValues(a: unknown, b: unknown, order: string): -1 | 0 | 1 {
208
236
  return (String(order).toLowerCase() === 'desc' ? -sign : sign) as -1 | 0 | 1;
209
237
  }
210
238
 
211
- const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
212
-
213
239
  function messageOf(error: unknown): string {
214
240
  return error instanceof Error ? error.message : String(error);
215
241
  }
@@ -226,12 +252,28 @@ function errorText(labels: Record<string, string>, detail: string): string {
226
252
  return `${labels.error ?? 'Error'}: ${detail}`;
227
253
  }
228
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
+
229
264
  /**
230
265
  * A VTable result grid with the interaction layer a docs example wants:
231
266
  * sortable columns, clipboard copy, resizable rows/columns, draggable headers,
232
- * cross-highlight on hover, per-column text wrapping and column freezing — all
233
- * driven through VTable's own options and events so the canvas stays the single
234
- * 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.
235
277
  *
236
278
  * All styling comes from VTable's own themes (`themes.DEFAULT` / `themes.DARK`,
237
279
  * picked below by the document's colour scheme). The table deliberately does
@@ -249,14 +291,23 @@ class ResultTable {
249
291
  readonly #labels: Record<string, string>;
250
292
  /** VTable's theme namespace, taken from the dynamic `import()`. */
251
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;
252
301
  /** Fields whose column currently wraps (row height switches to `auto`). */
253
302
  readonly #wrapped = new Set<string>();
254
303
  #frozen = 0;
255
304
  #frame = 0;
256
305
  /** The official theme currently applied, so a repaint happens only on change. */
257
306
  #appliedTheme?: VTableThemeName;
258
- /** The active width mode, one of `WIDTH_MODES`' keys (default: adaptive). */
259
- #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;
260
311
  #observer?: ResizeObserver;
261
312
  #themeObserver?: MutationObserver;
262
313
 
@@ -265,13 +316,26 @@ class ResultTable {
265
316
  record: HTMLElement,
266
317
  result: QueryResult,
267
318
  labels: Record<string, string>,
319
+ actions: HTMLElement,
268
320
  ) {
269
321
  this.#record = record;
270
322
  this.#result = result;
271
323
  this.#labels = labels;
272
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));
273
336
  this.#table = new vtable.ListTable(this.#options());
274
337
  this.#appliedTheme = this.#themeName();
338
+ this.#buildActions();
275
339
 
276
340
  // `resize()` re-measures and repaints inside `record`, so running it straight
277
341
  // from the observer callback feeds the resulting box change back into the very
@@ -301,6 +365,152 @@ class ResultTable {
301
365
  this.#table.on('dropdown_menu_click', (args) => this.#onMenu(args));
302
366
  }
303
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
+
304
514
  #columns(order: readonly string[]): TableColumns {
305
515
  return order.map((field) => ({
306
516
  field,
@@ -431,46 +641,29 @@ class ResultTable {
431
641
  };
432
642
  }
433
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
+ */
434
650
  #menuItems(field: string): TableMenuItem[] {
435
651
  const labels = this.#labels;
436
652
  const wrapped = this.#wrapped.has(field);
437
- const freezeOrUnfreeze: TableMenuItem =
438
- this.#frozen > 0
439
- ? {text: labels.unfreezeColumns ?? 'Unfreeze columns', menuKey: MENU.unfreeze}
440
- : {text: labels.freezeColumn ?? 'Freeze up to here', menuKey: MENU.freeze};
441
653
  return [
442
654
  {text: field, type: 'title'},
443
655
  {type: 'split'},
444
656
  {text: labels.copy ?? 'Copy cell', menuKey: MENU.copyCell},
445
- {text: labels.copyAll ?? 'Copy table', menuKey: MENU.copyAll},
446
657
  {
447
658
  text: wrapped
448
659
  ? (labels.unwrapColumn ?? 'Stop wrapping column')
449
660
  : (labels.wrapColumn ?? 'Wrap column'),
450
661
  menuKey: wrapped ? MENU.unwrap : MENU.wrap,
451
662
  },
452
- freezeOrUnfreeze,
453
- {type: 'split'},
454
- // The width modes sit right above "reset": both are view switches.
455
- {text: labels.widthMode ?? 'Column width', children: this.#widthModeItems()},
456
- {text: labels.resetView ?? 'Reset view', menuKey: MENU.reset},
663
+ {text: labels.freezeColumn ?? 'Freeze up to here', menuKey: MENU.freeze},
457
664
  ];
458
665
  }
459
666
 
460
- /**
461
- * The width-mode submenu. The vendor html menu has no check state of its own
462
- * (its `--select` highlight is driven by `menu.dropDownMenuHighlight`, which
463
- * only resolves against the cell being clicked), so the active mode carries a
464
- * leading tick — item text goes through `innerHTML`, but a plain character is
465
- * safe.
466
- */
467
- #widthModeItems(): TableMenuItem[] {
468
- return WIDTH_MODES.map((mode) => ({
469
- text: `${mode.menuKey === this.#widthMode ? '✓ ' : ''}${this.#labels[mode.label] ?? mode.fallback}`,
470
- menuKey: mode.menuKey,
471
- }));
472
- }
473
-
474
667
  /**
475
668
  * Dispatch a context-menu click.
476
669
  *
@@ -494,9 +687,6 @@ class ResultTable {
494
687
  case MENU.copyCell:
495
688
  void this.#copy(this.#cellText(col, row));
496
689
  break;
497
- case MENU.copyAll:
498
- void this.#copy(this.#allText());
499
- break;
500
690
  case MENU.wrap:
501
691
  if (field) {
502
692
  this.#toggleWrap(field, true);
@@ -510,17 +700,6 @@ class ResultTable {
510
700
  case MENU.freeze:
511
701
  this.#freeze(col);
512
702
  break;
513
- case MENU.unfreeze:
514
- this.#freeze(-1);
515
- break;
516
- case MENU.widthAdaptive:
517
- case MENU.widthStandard:
518
- case MENU.widthFill:
519
- this.#setWidthMode(widthModeOption(menuKey));
520
- break;
521
- case MENU.reset:
522
- this.#reset();
523
- break;
524
703
  }
525
704
  }
526
705
 
@@ -554,6 +733,7 @@ class ResultTable {
554
733
  // assuming `col + 1` stuck.
555
734
  this.#table.setFrozenColCount(col < 0 ? 0 : col + 1);
556
735
  this.#frozen = this.#table.frozenColCount;
736
+ this.#updateUnfreeze();
557
737
  }
558
738
 
559
739
  /**
@@ -567,10 +747,10 @@ class ResultTable {
567
747
  * change starts from a clean slate — while the user's row heights survive.
568
748
  */
569
749
  #setWidthMode(mode: (typeof WIDTH_MODES)[number]): void {
570
- if (mode.menuKey === this.#widthMode) {
750
+ if (mode.id === this.#widthMode) {
571
751
  return;
572
752
  }
573
- this.#widthMode = mode.menuKey;
753
+ this.#widthMode = mode.id;
574
754
  this.#table.widthMode = mode.widthMode;
575
755
  this.#table.autoFillWidth = mode.autoFillWidth;
576
756
  this.#table.updateColumns(this.#columns(this.#displayOrder()), {
@@ -583,7 +763,7 @@ class ResultTable {
583
763
  this.#wrapped.clear();
584
764
  this.#frozen = 0;
585
765
  // The width mode is a view switch too, so "reset" returns it to the default.
586
- this.#widthMode = MENU.widthAdaptive;
766
+ this.#widthMode = WIDTH_MODES[0].id;
587
767
  // `updateOption` (unlike `updateColumns`) also resets the sort state, and
588
768
  // with both caches cleared it drops the dragged widths/heights too — a true
589
769
  // "back to the initial view". `#options()` carries the query's column order,
@@ -592,12 +772,14 @@ class ResultTable {
592
772
  clearColWidthCache: true,
593
773
  clearRowHeightCache: true,
594
774
  });
775
+ this.#updateWidthLabel();
776
+ this.#updateUnfreeze();
595
777
  }
596
778
 
597
779
  /** The whole visible grid as tab-separated text, in current display order. */
598
780
  #allText(): string {
599
781
  const table = this.#table;
600
- const lines = [this.#result.columns.join('\t')];
782
+ const lines = [this.#displayOrder().join('\t')];
601
783
  for (let row = table.columnHeaderLevelCount; row < table.rowCount; row += 1) {
602
784
  const cells: string[] = [];
603
785
  for (let col = 0; col < table.colCount; col += 1) {
@@ -608,6 +790,25 @@ class ResultTable {
608
790
  return lines.join('\n');
609
791
  }
610
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
+
611
812
  async #copy(text: string): Promise<void> {
612
813
  try {
613
814
  await navigator.clipboard.writeText(text);
@@ -657,7 +858,8 @@ function tableSkeleton(): HTMLElement {
657
858
  }
658
859
 
659
860
  /**
660
- * 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.
661
863
  *
662
864
  * VTable is canvas-rendered and measures its container at construction time, so
663
865
  * the box gets an explicit height through a custom property (which the
@@ -667,12 +869,13 @@ function tableSkeleton(): HTMLElement {
667
869
  * without any resize plumbing through the component.
668
870
  *
669
871
  * A failed `import()` (offline, CDN blocked) degrades to the error view instead
670
- * of rejecting the render.
872
+ * of rejecting the render; the CSV export keeps working from the raw rows.
671
873
  */
672
874
  async function mountTable(
673
875
  parent: HTMLElement,
674
876
  result: QueryResult,
675
877
  labels: Record<string, string>,
878
+ actions: HTMLElement,
676
879
  ): Promise<PreviewTableHandle> {
677
880
  const document = parent.ownerDocument;
678
881
  const record = el('div', {class: 'dfk-sql-table'});
@@ -683,40 +886,84 @@ async function mountTable(
683
886
 
684
887
  try {
685
888
  const vtable = await import('@visactor/vtable');
686
- const table = new ResultTable(vtable, record, result, labels);
889
+ const table = new ResultTable(vtable, record, result, labels, actions);
687
890
  // VTable draws into the same box; the ghost is one sibling too many. It is
688
891
  // dropped after construction (not by VTable) so a failed import can still
689
892
  // hand the box over to the error view.
690
893
  skeleton.remove();
691
- return {dispose: () => table.dispose()};
894
+ return {dispose: () => table.dispose(), csv: () => table.csv()};
692
895
  } catch (error) {
693
896
  skeleton.remove();
694
897
  record.appendChild(errorBlock(document, errorText(labels, messageOf(error))));
695
- return {dispose: () => {}};
898
+ return {dispose: () => {}, csv: () => csvText(result)};
696
899
  }
697
900
  }
698
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
+
699
941
  const tableRenderer: Renderer = async ({host, labels, fullscreenButton}, result) => {
700
942
  const tabs = new PreviewTabs(
701
943
  host,
702
- [],
703
- labels.table ?? 'Table',
704
- (panel) => mountTable(panel, result, labels),
944
+ [tableItem(result, labels, host)],
945
+ labels.download ?? 'Download',
705
946
  fullscreenButton,
706
947
  );
707
948
  return () => tabs.dispose();
708
949
  };
709
950
 
710
951
  /** Plain text: one line per row, columns tab-joined. */
711
- function textBlock(document: Document, result: QueryResult): HTMLPreElement {
712
- const pre = document.createElement('pre');
713
- pre.className = 'dfk-sql-text';
952
+ function rowsText(result: QueryResult): string {
714
953
  const lines = [result.columns.join('\t')];
715
954
  for (const row of result.rows) {
716
- lines.push(result.columns.map((c) => stringify(row[c])).join('\t'));
955
+ lines.push(result.columns.map((column) => stringify(row[column])).join('\t'));
717
956
  }
718
- pre.textContent = lines.join('\n');
719
- 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');
720
967
  }
721
968
 
722
969
  /**
@@ -724,16 +971,24 @@ function textBlock(document: Document, result: QueryResult): HTMLPreElement {
724
971
  * trailing `Table` tab, so a scalar can still be inspected as a table.
725
972
  */
726
973
  const textRenderer: Renderer = async ({host, labels, fullscreenButton}, result) => {
974
+ const text = rowsText(result);
727
975
  const tabs = new PreviewTabs(
728
976
  host,
729
977
  [
730
978
  {
731
979
  label: labels.text ?? 'Text',
732
- 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
+ }),
733
988
  },
989
+ tableItem(result, labels, host),
734
990
  ],
735
- labels.table ?? 'Table',
736
- (panel) => mountTable(panel, result, labels),
991
+ labels.download ?? 'Download',
737
992
  fullscreenButton,
738
993
  );
739
994
  return () => tabs.dispose();
@@ -755,48 +1010,6 @@ function resolveField(config: RunnableSqlConfig, result: QueryResult): string |
755
1010
  return result.columns.length === 1 ? result.columns[0] : null;
756
1011
  }
757
1012
 
758
- /**
759
- * Parses SVG markup from a result cell into a node this document can host.
760
- *
761
- * `image/svg+xml` is strict XML: malformed markup (unclosed tags, a bare `&`,
762
- * an HTML `<br>`) comes back as a `<parsererror>` element rather than throwing,
763
- * so the caller can degrade to text. Script-bearing and event-handler content
764
- * is stripped — inline SVG is *not* isolated (use the `iframe` renderer for
765
- * untrusted markup).
766
- */
767
- function parseSvgMarkup(document: Document, markup: string): SVGElement | null {
768
- if (!markup.trim()) {
769
- return null;
770
- }
771
- const parsed = new DOMParser().parseFromString(markup, 'image/svg+xml');
772
- const root = parsed.documentElement;
773
- if (!root || root.localName === 'parsererror' || root.namespaceURI !== SVG_NAMESPACE) {
774
- return null;
775
- }
776
- stripActiveContent(root);
777
- return document.importNode(root, true) as unknown as SVGElement;
778
- }
779
-
780
- /** Removes the parts of an SVG document that could execute or navigate. */
781
- function stripActiveContent(root: Element): void {
782
- for (const node of root.querySelectorAll('script, foreignObject')) {
783
- node.remove();
784
- }
785
- const visit = (element: Element): void => {
786
- for (const attribute of [...element.attributes]) {
787
- const name = attribute.name.toLowerCase();
788
- const value = attribute.value.replace(/\s/g, '').toLowerCase();
789
- if (name.startsWith('on') || ((name === 'href' || name === 'xlink:href') && value.startsWith('javascript:'))) {
790
- element.removeAttribute(attribute.name);
791
- }
792
- }
793
- for (const child of element.children) {
794
- visit(child);
795
- }
796
- };
797
- visit(root);
798
- }
799
-
800
1013
  /** Applies `option.width` / `option.height` as custom properties the CSS consumes. */
801
1014
  function applyPreviewSize(node: HTMLElement, config: RunnableSqlConfig): void {
802
1015
  const {width, height} = config.option ?? {};
@@ -808,14 +1021,34 @@ function applyPreviewSize(node: HTMLElement, config: RunnableSqlConfig): void {
808
1021
  }
809
1022
  }
810
1023
 
811
- function mountPreviewPanel(
812
- kind: 'iframe' | 'svg',
813
- panel: HTMLElement,
814
- value: unknown,
1024
+ /** What a preview tab shows for one row of the result. */
1025
+ type PreviewKind = 'iframe' | 'svg' | 'mermaid';
1026
+
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(
1045
+ kind: PreviewKind,
1046
+ markup: string,
815
1047
  label: string,
816
1048
  config: RunnableSqlConfig,
817
- ): void {
818
- const markup = value === null || value === undefined ? '' : String(value);
1049
+ host: HTMLElement,
1050
+ figureLabels: FigureLabels,
1051
+ ): PreviewTabItem {
819
1052
  if (kind === 'iframe') {
820
1053
  const frame = el('iframe', {
821
1054
  class: 'dfk-sql-frame',
@@ -830,28 +1063,62 @@ function mountPreviewPanel(
830
1063
  },
831
1064
  });
832
1065
  applyPreviewSize(frame, config);
833
- panel.appendChild(frame);
834
- 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
+ };
835
1077
  }
836
1078
 
837
- const svg = parseSvgMarkup(panel.ownerDocument, markup);
838
- if (!svg) {
839
- // Not SVG: show the markup as text rather than an empty panel.
840
- panel.appendChild(el('pre', {class: 'dfk-sql-text', text: markup}));
841
- return;
1079
+ if (kind === 'mermaid') {
1080
+ // The cell is handed to the kit's own diagram element rather than rendered
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
1083
+ // one place that knows about the dark-mode-first-load fix.
1084
+ //
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
+ };
842
1099
  }
843
- const holder = el('div', {class: 'dfk-sql-svg'});
844
- holder.appendChild(svg);
845
- applyPreviewSize(holder, config);
846
- panel.appendChild(holder);
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
+ };
847
1113
  }
848
1114
 
849
1115
  /**
850
1116
  * Builds a preview renderer: one tab per row, then the raw rows in the trailing
851
- * `Table` tab. `iframe` and `svg` share everything except how a panel is filled.
1117
+ * `Table` tab. `iframe`, `svg` and `mermaid` share everything except how a
1118
+ * figure is built.
852
1119
  */
853
- function previewRenderer(kind: 'iframe' | 'svg'): Renderer {
854
- return async ({host, config, labels, fullscreenButton}, result) => {
1120
+ function previewRenderer(kind: PreviewKind): Renderer {
1121
+ return async ({host, config, labels, fullscreenButton, onFullscreenChange}, result) => {
855
1122
  const field = resolveField(config, result);
856
1123
  if (!field) {
857
1124
  host.replaceChildren(
@@ -863,23 +1130,29 @@ function previewRenderer(kind: 'iframe' | 'svg'): Renderer {
863
1130
  return;
864
1131
  }
865
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
+ };
866
1142
  const tabName = config.tab_name;
867
1143
  const items: PreviewTabItem[] = result.rows.map((row, index) => {
868
1144
  const value = tabName ? row[tabName] : undefined;
869
- return {
870
- label: value === null || value === undefined ? `${labels.row ?? 'Row'} ${index + 1}` : stringify(value),
871
- mount: (panel) => mountPreviewPanel(kind, panel, row[field], '', config),
872
- };
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);
873
1147
  });
1148
+ items.push(tableItem(result, labels, host));
874
1149
 
875
- const tabs = new PreviewTabs(
876
- host,
877
- items,
878
- labels.table ?? 'Table',
879
- (panel) => mountTable(panel, result, labels),
880
- fullscreenButton,
881
- );
882
- 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
+ };
883
1156
  };
884
1157
  }
885
1158
 
@@ -900,6 +1173,7 @@ const registry: Record<string, Renderer> = {
900
1173
  // `html` is the historical spelling of the same renderer; both stay valid.
901
1174
  html: previewRenderer('iframe'),
902
1175
  svg: previewRenderer('svg'),
1176
+ mermaid: previewRenderer('mermaid'),
903
1177
  };
904
1178
 
905
1179
  /**