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.
- package/AGENTS.md +43 -11
- package/bin/sql-verify.mjs +0 -0
- package/dist/download.d.ts +51 -0
- package/dist/index.js +1 -1
- package/dist/mermaid/DfkMermaid.d.ts +18 -0
- package/dist/mermaid/render.d.ts +0 -2
- package/dist/panzoom-view.d.ts +17 -0
- package/dist/{register-Dev_kc3Z.js → register-wdwf0LC4.js} +1141 -847
- 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/renderers.d.ts +7 -0
- package/package.json +3 -2
- package/src/codemirror.ts +14 -5
- package/src/{mermaid/title.ts → download.ts} +66 -24
- package/src/mermaid/DfkMermaid.css +52 -15
- package/src/mermaid/DfkMermaid.ts +144 -290
- package/src/mermaid/render.ts +0 -6
- package/src/panzoom-view.ts +235 -0
- package/src/source-dialog.ts +126 -0
- package/src/sql/DfkSql.ts +49 -5
- package/src/sql/PreviewTabs.ts +98 -52
- package/src/sql/SvgViewer.ts +155 -0
- package/src/sql/collect.ts +136 -136
- package/src/sql/renderers.ts +423 -170
- package/src/sql/sql.css +176 -8
- package/src/sql/verify.ts +339 -339
- package/dist/mermaid/title.d.ts +0 -23
package/src/sql/renderers.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
* `
|
|
96
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
function widthModeOption(
|
|
124
|
-
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];
|
|
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
|
|
235
|
-
* driven through VTable's own options and events so the canvas
|
|
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
|
|
261
|
-
#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;
|
|
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
|
-
|
|
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.
|
|
750
|
+
if (mode.id === this.#widthMode) {
|
|
573
751
|
return;
|
|
574
752
|
}
|
|
575
|
-
this.#widthMode = mode.
|
|
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 =
|
|
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.#
|
|
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.
|
|
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
|
|
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((
|
|
955
|
+
lines.push(result.columns.map((column) => stringify(row[column])).join('\t'));
|
|
719
956
|
}
|
|
720
|
-
|
|
721
|
-
|
|
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) =>
|
|
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.
|
|
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
|
-
/**
|
|
1024
|
+
/** What a preview tab shows for one row of the result. */
|
|
814
1025
|
type PreviewKind = 'iframe' | 'svg' | 'mermaid';
|
|
815
1026
|
|
|
816
|
-
|
|
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
|
-
|
|
819
|
-
value: unknown,
|
|
1046
|
+
markup: string,
|
|
820
1047
|
label: string,
|
|
821
1048
|
config: RunnableSqlConfig,
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
839
|
-
|
|
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
|
|
845
|
-
//
|
|
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
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
|
871
|
-
* is
|
|
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
|
-
|
|
890
|
-
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
(
|
|
900
|
-
|
|
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
|
|