duckfn-docs-kit 0.4.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 +43 -11
- 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/renderers.ts +423 -170
- package/src/sql/sql.css +176 -8
- package/dist/mermaid/title.d.ts +0 -23
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type {PanzoomObject} from '@panzoom/panzoom';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The zoom / pan behaviour behind a rendered figure, shared by `<dfk-mermaid>`
|
|
5
|
+
* and the SQL result `svg` viewer.
|
|
6
|
+
*
|
|
7
|
+
* It owns no structure: the caller builds the viewport and the content box (each
|
|
8
|
+
* with its own class names, so each consumer's stylesheet can reach them) and
|
|
9
|
+
* hands both over. What lives here is the interaction contract — when panzoom may
|
|
10
|
+
* take the gesture, and how the zoom state is reflected back onto the viewport.
|
|
11
|
+
*
|
|
12
|
+
* **Zoom is opt-in and reversible.** panzoom starts disabled and only
|
|
13
|
+
* {@link setActive} turns it on, which is what lets an inline figure leave the
|
|
14
|
+
* page's scrolling alone: a wheel over a figure that is merely displayed is the
|
|
15
|
+
* browser's, and only a figure the reader has opened moves (the SQL result's
|
|
16
|
+
* fullscreen, a diagram's own). Deactivating resets the view to fit, so the next
|
|
17
|
+
* activation starts from the whole figure.
|
|
18
|
+
*
|
|
19
|
+
* This is browser-only code: `@panzoom/panzoom` is reached through a dynamic
|
|
20
|
+
* `import()`, so a page whose figures are never zoomed never loads it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The zoom range. `MIN_SCALE` is the fit-to-box scale: the figure opens there and
|
|
25
|
+
* cannot go below it, which is what makes "zoomed" a clean yes/no — it is exactly
|
|
26
|
+
* the state in which a drag pans, the wheel has something to undo, and the cursor
|
|
27
|
+
* stops promising plain text.
|
|
28
|
+
*/
|
|
29
|
+
const MIN_SCALE = 1;
|
|
30
|
+
const MAX_SCALE = 8;
|
|
31
|
+
const ZOOM_STEP = 0.25;
|
|
32
|
+
|
|
33
|
+
/** The pan/zoom library, loaded once per page on first use. */
|
|
34
|
+
let panzoomModule: Promise<typeof import('@panzoom/panzoom')['default']> | null = null;
|
|
35
|
+
|
|
36
|
+
function loadPanzoom(): Promise<typeof import('@panzoom/panzoom')['default']> {
|
|
37
|
+
panzoomModule ??= import('@panzoom/panzoom').then((module) => module.default);
|
|
38
|
+
return panzoomModule;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class PanZoomView {
|
|
42
|
+
readonly #viewport: HTMLElement;
|
|
43
|
+
readonly #content: HTMLElement;
|
|
44
|
+
#panzoom: PanzoomObject | null = null;
|
|
45
|
+
#loading = false;
|
|
46
|
+
#active = false;
|
|
47
|
+
/** Whether the viewport's wheel listener is currently attached. */
|
|
48
|
+
#wheelBound = false;
|
|
49
|
+
|
|
50
|
+
readonly #onWheel = (event: WheelEvent): void => {
|
|
51
|
+
this.#panzoom?.zoomWithWheel(event);
|
|
52
|
+
};
|
|
53
|
+
/** The cursor follows the zoom state, so it never promises a pan that cannot happen. */
|
|
54
|
+
readonly #onChange = (): void => {
|
|
55
|
+
this.#viewport.classList.toggle('dfk-panzoom-zoomed', this.zoomed);
|
|
56
|
+
};
|
|
57
|
+
readonly #onStart = (): void => {
|
|
58
|
+
// `panzoomstart` fires at fit too, where the gesture was left to the browser;
|
|
59
|
+
// only a drag that will really pan gets the closed hand.
|
|
60
|
+
this.#viewport.classList.toggle('dfk-panzoom-grabbing', this.zoomed);
|
|
61
|
+
};
|
|
62
|
+
readonly #onEnd = (): void => {
|
|
63
|
+
this.#viewport.classList.remove('dfk-panzoom-grabbing');
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
constructor(viewport: HTMLElement, content: HTMLElement) {
|
|
67
|
+
this.#viewport = viewport;
|
|
68
|
+
this.#content = content;
|
|
69
|
+
// panzoom reports state as DOM `CustomEvent`s dispatched on the element it
|
|
70
|
+
// transforms (`@panzoom/panzoom` v4 has no `on()` API), so they are listened
|
|
71
|
+
// for here, on the content box — which also means they need no teardown, and
|
|
72
|
+
// that re-creating the panzoom instance after a reconnect cannot double them.
|
|
73
|
+
content.addEventListener('panzoomchange', this.#onChange);
|
|
74
|
+
content.addEventListener('panzoomstart', this.#onStart);
|
|
75
|
+
content.addEventListener('panzoomend', this.#onEnd);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether the figure is enlarged past its fit-to-box size. */
|
|
79
|
+
get zoomed(): boolean {
|
|
80
|
+
return (this.#panzoom?.getScale() ?? MIN_SCALE) > MIN_SCALE;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Replaces the figure on screen and returns the view to fit. */
|
|
84
|
+
setContent(node: Node | null): void {
|
|
85
|
+
this.#content.replaceChildren(...(node ? [node] : []));
|
|
86
|
+
this.reset();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Turns wheel zoom and dragging on or off. Activating loads panzoom on first
|
|
91
|
+
* use (a figure nobody zooms never pays for the chunk); deactivating leaves the
|
|
92
|
+
* view at fit, so the figure a reader returns to is the whole one.
|
|
93
|
+
*/
|
|
94
|
+
setActive(active: boolean): void {
|
|
95
|
+
if (active === this.#active) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.#active = active;
|
|
99
|
+
if (active) {
|
|
100
|
+
void this.#ensurePanzoom();
|
|
101
|
+
} else {
|
|
102
|
+
this.reset();
|
|
103
|
+
this.#setEnabled(false);
|
|
104
|
+
this.#unbindWheel();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
reset(): void {
|
|
109
|
+
this.#panzoom?.reset({
|
|
110
|
+
// Zooming back to fit is a transition the reader did not ask to skip, but
|
|
111
|
+
// one they may have asked not to have.
|
|
112
|
+
animate: !prefersReducedMotion(),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Releases the panzoom instance and returns the viewport to its idle state. */
|
|
117
|
+
destroy(): void {
|
|
118
|
+
this.#active = false;
|
|
119
|
+
this.#unbindWheel();
|
|
120
|
+
this.#panzoom?.destroy();
|
|
121
|
+
this.#panzoom = null;
|
|
122
|
+
this.#viewport.classList.remove('dfk-panzoom-zoomed', 'dfk-panzoom-grabbing');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Binds panzoom to the content box. `@panzoom/panzoom` is an enhancement, not a
|
|
127
|
+
* requirement: if its chunk never arrives, the figure still renders, only wheel
|
|
128
|
+
* zoom and dragging stay inert.
|
|
129
|
+
*
|
|
130
|
+
* Three settings carry the interaction contract:
|
|
131
|
+
*
|
|
132
|
+
* - `panOnlyWhenZoomed` — a figure that already fits must not swallow drags, so
|
|
133
|
+
* panning only engages once it is enlarged. That is also what keeps
|
|
134
|
+
* `touchAction: 'pan-y'` meaningful: vertical page scrolling stays the
|
|
135
|
+
* browser's, pinch and horizontal drags go to the figure.
|
|
136
|
+
* - `handleStartEvent` — panzoom's default takes the gesture on *every*
|
|
137
|
+
* pointerdown (`preventDefault` + `stopPropagation`), which costs the reader
|
|
138
|
+
* text selection at every zoom level. Handing the gesture over only when a
|
|
139
|
+
* drag will really pan is what makes the labels selectable while the figure is
|
|
140
|
+
* at fit. Nothing else blocks it: panzoom's move listener is `passive` and
|
|
141
|
+
* never calls `preventDefault`.
|
|
142
|
+
* - no `cursor` option — panzoom would then put `grab` on the element for good,
|
|
143
|
+
* over text that is perfectly selectable. The cursor is driven by the zoom
|
|
144
|
+
* state instead, from each consumer's stylesheet.
|
|
145
|
+
*/
|
|
146
|
+
async #ensurePanzoom(): Promise<void> {
|
|
147
|
+
if (this.#panzoom !== null) {
|
|
148
|
+
this.#setEnabled(true);
|
|
149
|
+
this.#bindWheel();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (this.#loading) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.#loading = true;
|
|
156
|
+
try {
|
|
157
|
+
const Panzoom = await loadPanzoom();
|
|
158
|
+
// Deactivated while the chunk was in flight: nothing to build, and the next
|
|
159
|
+
// activation starts from here again.
|
|
160
|
+
if (!this.#active || this.#panzoom !== null) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.#panzoom = Panzoom(this.#content, {
|
|
164
|
+
maxScale: MAX_SCALE,
|
|
165
|
+
minScale: MIN_SCALE,
|
|
166
|
+
step: ZOOM_STEP,
|
|
167
|
+
panOnlyWhenZoomed: true,
|
|
168
|
+
touchAction: 'pan-y',
|
|
169
|
+
// panzoom's own default is `move`, written inline on the element — the
|
|
170
|
+
// cursor has to stay a CSS decision (see the consumers' stylesheets), so
|
|
171
|
+
// it is switched off here.
|
|
172
|
+
cursor: '',
|
|
173
|
+
disablePan: false,
|
|
174
|
+
disableZoom: false,
|
|
175
|
+
handleStartEvent: (event) => {
|
|
176
|
+
if (!this.zoomed) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
event.stopPropagation();
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
this.#clearPanzoomStyles();
|
|
184
|
+
this.#bindWheel();
|
|
185
|
+
} catch {
|
|
186
|
+
// Nothing to report: the figure is already usable without pan/zoom.
|
|
187
|
+
} finally {
|
|
188
|
+
this.#loading = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#setEnabled(enabled: boolean): void {
|
|
193
|
+
if (this.#panzoom === null) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.#panzoom.setOptions({disablePan: !enabled, disableZoom: !enabled});
|
|
197
|
+
this.#clearPanzoomStyles();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* panzoom forces `user-select: none` inline on the element *and its parent*,
|
|
202
|
+
* with no option to prevent it — that alone made every label in every diagram
|
|
203
|
+
* unselectable. It exists to stop a drag from selecting while panning, which
|
|
204
|
+
* `handleStartEvent` already covers: whenever a pan will happen, the gesture is
|
|
205
|
+
* taken with `preventDefault()` before the browser can start a selection.
|
|
206
|
+
* panzoom writes these styles when the instance is created and again on every
|
|
207
|
+
* `setOptions`, so they are cleared after each.
|
|
208
|
+
*/
|
|
209
|
+
#clearPanzoomStyles(): void {
|
|
210
|
+
this.#content.style.userSelect = '';
|
|
211
|
+
this.#viewport.style.userSelect = '';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#bindWheel(): void {
|
|
215
|
+
if (this.#wheelBound) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this.#wheelBound = true;
|
|
219
|
+
// `passive: false` because `zoomWithWheel()` calls `preventDefault()` — a
|
|
220
|
+
// passive listener could not stop the page from scrolling underneath.
|
|
221
|
+
this.#viewport.addEventListener('wheel', this.#onWheel, {passive: false});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#unbindWheel(): void {
|
|
225
|
+
if (!this.#wheelBound) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.#wheelBound = false;
|
|
229
|
+
this.#viewport.removeEventListener('wheel', this.#onWheel);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function prefersReducedMotion(): boolean {
|
|
234
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
235
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type {CodeEditor} from './codemirror';
|
|
2
|
+
import {mountCodeEditor} from './codemirror';
|
|
3
|
+
import {el} from './dom';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The "edit the source" dialog shared by `<dfk-mermaid>` and the SQL result `svg`
|
|
7
|
+
* viewer: a modal holding a plain-text CodeMirror editor and an Apply / Cancel
|
|
8
|
+
* pair.
|
|
9
|
+
*
|
|
10
|
+
* The editor writes back through the callback {@link open} is given rather than
|
|
11
|
+
* through an event, so the owner keeps its own source as the single truth — the
|
|
12
|
+
* dialog never renders anything and never decides what a change means.
|
|
13
|
+
*
|
|
14
|
+
* The `<dialog>` is built once and reused; the editor mounts on first open and is
|
|
15
|
+
* kept, because a reader who edits one diagram is likely to edit the next. The
|
|
16
|
+
* dialog is shown *before* the editor mounts: CodeMirror measures its container as
|
|
17
|
+
* it is constructed, and a `display: none` dialog measures to zero.
|
|
18
|
+
*
|
|
19
|
+
* This is browser-only code.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface SourceDialogLabels {
|
|
23
|
+
title: string;
|
|
24
|
+
apply: string;
|
|
25
|
+
cancel: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class SourceDialog {
|
|
29
|
+
readonly root = el('dialog', {class: 'dfk-source-dialog'});
|
|
30
|
+
readonly #title = el('h2', {class: 'dfk-source-dialog-title'});
|
|
31
|
+
/** Where the CodeMirror editor mounts, inside the dialog. */
|
|
32
|
+
readonly #editorHost = el('div', {class: 'dfk-source-dialog-editor'});
|
|
33
|
+
readonly #applyBtn = el('button', {
|
|
34
|
+
class: 'dfk-source-dialog-button dfk-source-dialog-apply',
|
|
35
|
+
type: 'button',
|
|
36
|
+
});
|
|
37
|
+
readonly #cancelBtn = el('button', {class: 'dfk-source-dialog-button', type: 'button'});
|
|
38
|
+
|
|
39
|
+
/** The source the dialog was opened with, and the baseline for "was it edited". */
|
|
40
|
+
#source = '';
|
|
41
|
+
#onApply: ((value: string) => void) | null = null;
|
|
42
|
+
#editor: CodeEditor | null = null;
|
|
43
|
+
#editorLoading = false;
|
|
44
|
+
|
|
45
|
+
constructor(labels: SourceDialogLabels) {
|
|
46
|
+
this.#applyBtn.addEventListener('click', () => this.#apply());
|
|
47
|
+
this.#cancelBtn.addEventListener('click', () => this.root.close());
|
|
48
|
+
this.root.append(
|
|
49
|
+
el('div', {class: 'dfk-source-dialog-body'}, (body) =>
|
|
50
|
+
body.append(
|
|
51
|
+
this.#title,
|
|
52
|
+
this.#editorHost,
|
|
53
|
+
el('div', {class: 'dfk-source-dialog-footer'}, (footer) =>
|
|
54
|
+
footer.append(this.#cancelBtn, this.#applyBtn),
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
this.setLabels(labels);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setLabels(labels: SourceDialogLabels): void {
|
|
63
|
+
this.#title.textContent = labels.title;
|
|
64
|
+
this.#cancelBtn.textContent = labels.cancel;
|
|
65
|
+
this.#applyBtn.textContent = labels.apply;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Shows the dialog with `source` in the editor. `onApply` receives the edited
|
|
70
|
+
* text — only when it differs from what was opened, so an unedited Apply is a
|
|
71
|
+
* plain close.
|
|
72
|
+
*/
|
|
73
|
+
open(source: string, onApply: (value: string) => void): void {
|
|
74
|
+
this.#source = source;
|
|
75
|
+
this.#onApply = onApply;
|
|
76
|
+
this.root.showModal();
|
|
77
|
+
if (this.#editor) {
|
|
78
|
+
this.#editor.setValue(source);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
void this.#mountEditor();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Closes the dialog and releases the editor. The dialog node stays in place. */
|
|
85
|
+
destroy(): void {
|
|
86
|
+
this.#editor?.destroy();
|
|
87
|
+
this.#editor = null;
|
|
88
|
+
if (this.root.open) {
|
|
89
|
+
this.root.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async #mountEditor(): Promise<void> {
|
|
94
|
+
if (this.#editorLoading) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.#editorLoading = true;
|
|
98
|
+
try {
|
|
99
|
+
// No language: the dialog edits mermaid source or SVG markup, neither of
|
|
100
|
+
// which has a first-party CodeMirror grammar here, and the reader is
|
|
101
|
+
// touching up a figure rather than writing SQL.
|
|
102
|
+
const editor = await mountCodeEditor(this.#editorHost, this.#source, () => undefined);
|
|
103
|
+
if (!this.root.isConnected || !this.root.open) {
|
|
104
|
+
// Closed (or detached) while the modules were loading: nothing will ever
|
|
105
|
+
// dispose this editor, so dispose it here.
|
|
106
|
+
editor.destroy();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
editor.setWrap(true);
|
|
110
|
+
this.#editor = editor;
|
|
111
|
+
} catch {
|
|
112
|
+
// The dialog stays open with an empty editor box; a second click retries.
|
|
113
|
+
} finally {
|
|
114
|
+
this.#editorLoading = false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#apply(): void {
|
|
119
|
+
const edited = this.#editor?.getValue();
|
|
120
|
+
this.root.close();
|
|
121
|
+
if (edited !== undefined && edited !== this.#source) {
|
|
122
|
+
this.#source = edited;
|
|
123
|
+
this.#onApply?.(edited);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/sql/DfkSql.ts
CHANGED
|
@@ -50,7 +50,7 @@ type SqlLabels = {
|
|
|
50
50
|
wrapOff: string;
|
|
51
51
|
copy: string;
|
|
52
52
|
copied: string;
|
|
53
|
-
/**
|
|
53
|
+
/** Result-table chrome: the tab strip's controls and the context menu. */
|
|
54
54
|
copyAll: string;
|
|
55
55
|
wrapColumn: string;
|
|
56
56
|
unwrapColumn: string;
|
|
@@ -58,11 +58,19 @@ type SqlLabels = {
|
|
|
58
58
|
unfreezeColumns: string;
|
|
59
59
|
resetView: string;
|
|
60
60
|
noData: string;
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
search: string;
|
|
62
|
+
/** Width-mode button at the right end of the result tab strip. */
|
|
63
63
|
widthAdaptive: string;
|
|
64
64
|
widthStandard: string;
|
|
65
65
|
widthFill: string;
|
|
66
|
+
/** The tab strip's download button (every result format). */
|
|
67
|
+
download: string;
|
|
68
|
+
/** Figure controls: the svg viewer, and embedded `<dfk-mermaid>`. */
|
|
69
|
+
resetZoom: string;
|
|
70
|
+
editSource: string;
|
|
71
|
+
svgSource: string;
|
|
72
|
+
apply: string;
|
|
73
|
+
cancel: string;
|
|
66
74
|
running: string;
|
|
67
75
|
initializing: string;
|
|
68
76
|
loadingExtensions: string;
|
|
@@ -93,10 +101,16 @@ const LABELS: Record<string, SqlLabels> = {
|
|
|
93
101
|
unfreezeColumns: 'Unfreeze columns',
|
|
94
102
|
resetView: 'Reset view',
|
|
95
103
|
noData: 'No rows',
|
|
96
|
-
|
|
104
|
+
search: 'Search',
|
|
97
105
|
widthAdaptive: 'Fill the width',
|
|
98
106
|
widthStandard: 'Content widths, scroll sideways',
|
|
99
107
|
widthFill: 'Content first, fill when it fits',
|
|
108
|
+
download: 'Download',
|
|
109
|
+
resetZoom: 'Reset zoom',
|
|
110
|
+
editSource: 'Edit source',
|
|
111
|
+
svgSource: 'SVG source',
|
|
112
|
+
apply: 'Apply',
|
|
113
|
+
cancel: 'Cancel',
|
|
100
114
|
running: 'Running…',
|
|
101
115
|
initializing: 'Initializing DuckDB…',
|
|
102
116
|
loadingExtensions: 'Loading extensions…',
|
|
@@ -125,10 +139,16 @@ const LABELS: Record<string, SqlLabels> = {
|
|
|
125
139
|
unfreezeColumns: '取消冻结',
|
|
126
140
|
resetView: '重置视图',
|
|
127
141
|
noData: '无数据',
|
|
128
|
-
|
|
142
|
+
search: '搜索',
|
|
129
143
|
widthAdaptive: '铺满宽度',
|
|
130
144
|
widthStandard: '按内容列宽(可横向滚动)',
|
|
131
145
|
widthFill: '内容优先,装得下就铺满',
|
|
146
|
+
download: '下载',
|
|
147
|
+
resetZoom: '还原缩放',
|
|
148
|
+
editSource: '编辑源码',
|
|
149
|
+
svgSource: 'SVG 源码',
|
|
150
|
+
apply: '应用',
|
|
151
|
+
cancel: '取消',
|
|
132
152
|
running: '执行中…',
|
|
133
153
|
initializing: '正在初始化 DuckDB…',
|
|
134
154
|
loadingExtensions: '正在加载扩展…',
|
|
@@ -206,6 +226,13 @@ export class DfkSql extends HTMLElementBase {
|
|
|
206
226
|
#resultHost: HTMLElement | null = null;
|
|
207
227
|
#editor: CodeEditor | null = null;
|
|
208
228
|
#disposeResult: (() => void) | null = null;
|
|
229
|
+
/**
|
|
230
|
+
* Renderers watching the result area's fullscreen state. A figure zooms only
|
|
231
|
+
* where the result is expanded, so its renderer subscribes through
|
|
232
|
+
* `RenderContext.onFullscreenChange` and forwards every change — including the
|
|
233
|
+
* one at subscription time — to its viewer.
|
|
234
|
+
*/
|
|
235
|
+
readonly #fullscreenListeners = new Set<(value: boolean) => void>();
|
|
209
236
|
|
|
210
237
|
constructor() {
|
|
211
238
|
super();
|
|
@@ -443,6 +470,22 @@ export class DfkSql extends HTMLElementBase {
|
|
|
443
470
|
}
|
|
444
471
|
this.#escBound = value;
|
|
445
472
|
}
|
|
473
|
+
for (const listener of this.#fullscreenListeners) {
|
|
474
|
+
listener(value);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Subscribes a renderer to the fullscreen state above, calling back with the
|
|
480
|
+
* current value straight away (a renderer can mount while the result is
|
|
481
|
+
* already expanded) and returning the unsubscribe.
|
|
482
|
+
*/
|
|
483
|
+
#onFullscreenChange(listener: (value: boolean) => void): () => void {
|
|
484
|
+
this.#fullscreenListeners.add(listener);
|
|
485
|
+
listener(this.#expanded);
|
|
486
|
+
return () => {
|
|
487
|
+
this.#fullscreenListeners.delete(listener);
|
|
488
|
+
};
|
|
446
489
|
}
|
|
447
490
|
|
|
448
491
|
// --- Run -------------------------------------------------------------------
|
|
@@ -549,6 +592,7 @@ export class DfkSql extends HTMLElementBase {
|
|
|
549
592
|
config: this.#config,
|
|
550
593
|
labels: this.#labels,
|
|
551
594
|
fullscreenButton: this.#fullscreenBtn.root,
|
|
595
|
+
onFullscreenChange: (listener) => this.#onFullscreenChange(listener),
|
|
552
596
|
};
|
|
553
597
|
const renderer = rendererFor(this.#config, result);
|
|
554
598
|
void renderer(context, result).then((dispose) => {
|