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.
- package/AGENTS.md +326 -234
- package/README.md +10 -6
- package/dist/IconButton.d.ts +22 -0
- package/dist/codemirror.d.ts +29 -0
- package/dist/download.d.ts +51 -0
- package/dist/index.d.ts +28 -7
- package/dist/index.js +2 -2
- package/dist/mermaid/DfkMermaid.d.ts +25 -0
- package/dist/mermaid/config.d.ts +48 -0
- package/dist/mermaid/remark.d.ts +40 -0
- package/dist/mermaid/remark.js +32 -0
- package/dist/mermaid/render.d.ts +92 -0
- package/dist/mermaid/styles.d.ts +6 -0
- package/dist/panzoom-view.d.ts +17 -0
- package/dist/{register-CALCwFBv.js → register-wdwf0LC4.js} +1365 -738
- package/dist/remark.d.ts +1 -1
- package/dist/source-dialog.d.ts +35 -0
- package/dist/sql/PreviewTabs.d.ts +39 -23
- package/dist/sql/SvgViewer.d.ts +53 -0
- package/dist/sql/client.js +1 -1
- package/dist/sql/harness.js +1 -1
- package/dist/sql/remark.d.ts +6 -1
- package/dist/sql/renderers.d.ts +9 -0
- package/package.json +6 -2
- package/src/IconButton.ts +50 -0
- package/src/codemirror.ts +97 -0
- package/src/download.ts +169 -0
- package/src/index.ts +31 -7
- package/src/mermaid/DfkMermaid.css +326 -0
- package/src/mermaid/DfkMermaid.ts +411 -0
- package/src/mermaid/config.ts +74 -0
- package/src/mermaid/remark.ts +98 -0
- package/src/mermaid/render.ts +172 -0
- package/src/mermaid/styles.ts +24 -0
- package/src/panzoom-view.ts +235 -0
- package/src/register.ts +3 -0
- package/src/remark.ts +1 -1
- package/src/source-dialog.ts +126 -0
- package/src/sql/DfkSql.css +13 -10
- package/src/sql/DfkSql.ts +60 -51
- package/src/sql/PreviewTabs.ts +98 -52
- package/src/sql/SvgViewer.ts +155 -0
- package/src/sql/remark.ts +6 -1
- package/src/sql/renderers.ts +433 -159
- package/src/sql/sql.css +189 -19
- package/dist/sql/editor.d.ts +0 -16
- package/src/sql/editor.ts +0 -75
package/src/sql/renderers.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
* `
|
|
94
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
function widthModeOption(
|
|
122
|
-
return WIDTH_MODES.find((mode) => mode.
|
|
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
|
|
233
|
-
* driven through VTable's own options and events so the canvas
|
|
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
|
|
259
|
-
#widthMode: string =
|
|
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
|
-
|
|
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.
|
|
750
|
+
if (mode.id === this.#widthMode) {
|
|
571
751
|
return;
|
|
572
752
|
}
|
|
573
|
-
this.#widthMode = mode.
|
|
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 =
|
|
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.#
|
|
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.
|
|
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
|
|
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((
|
|
955
|
+
lines.push(result.columns.map((column) => stringify(row[column])).join('\t'));
|
|
717
956
|
}
|
|
718
|
-
|
|
719
|
-
|
|
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) =>
|
|
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.
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
818
|
-
|
|
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
|
-
|
|
834
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
//
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
applyPreviewSize(
|
|
846
|
-
|
|
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 `
|
|
1117
|
+
* `Table` tab. `iframe`, `svg` and `mermaid` share everything except how a
|
|
1118
|
+
* figure is built.
|
|
852
1119
|
*/
|
|
853
|
-
function previewRenderer(kind:
|
|
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
|
-
|
|
870
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
(
|
|
880
|
-
|
|
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
|
/**
|