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.
@@ -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
- /** Context-menu labels for the result table (see `renderers.ts`). */
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
- /** Width-mode submenu of the result table (see `renderers.ts`). */
62
- widthMode: string;
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
- widthMode: 'Column width',
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
- widthMode: '列宽模式',
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) => {