duckfn-docs-kit 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +326 -234
  2. package/README.md +10 -6
  3. package/dist/IconButton.d.ts +22 -0
  4. package/dist/codemirror.d.ts +29 -0
  5. package/dist/download.d.ts +51 -0
  6. package/dist/index.d.ts +28 -7
  7. package/dist/index.js +2 -2
  8. package/dist/mermaid/DfkMermaid.d.ts +25 -0
  9. package/dist/mermaid/config.d.ts +48 -0
  10. package/dist/mermaid/remark.d.ts +40 -0
  11. package/dist/mermaid/remark.js +32 -0
  12. package/dist/mermaid/render.d.ts +92 -0
  13. package/dist/mermaid/styles.d.ts +6 -0
  14. package/dist/panzoom-view.d.ts +17 -0
  15. package/dist/{register-CALCwFBv.js → register-wdwf0LC4.js} +1365 -738
  16. package/dist/remark.d.ts +1 -1
  17. package/dist/source-dialog.d.ts +35 -0
  18. package/dist/sql/PreviewTabs.d.ts +39 -23
  19. package/dist/sql/SvgViewer.d.ts +53 -0
  20. package/dist/sql/client.js +1 -1
  21. package/dist/sql/harness.js +1 -1
  22. package/dist/sql/remark.d.ts +6 -1
  23. package/dist/sql/renderers.d.ts +9 -0
  24. package/package.json +6 -2
  25. package/src/IconButton.ts +50 -0
  26. package/src/codemirror.ts +97 -0
  27. package/src/download.ts +169 -0
  28. package/src/index.ts +31 -7
  29. package/src/mermaid/DfkMermaid.css +326 -0
  30. package/src/mermaid/DfkMermaid.ts +411 -0
  31. package/src/mermaid/config.ts +74 -0
  32. package/src/mermaid/remark.ts +98 -0
  33. package/src/mermaid/render.ts +172 -0
  34. package/src/mermaid/styles.ts +24 -0
  35. package/src/panzoom-view.ts +235 -0
  36. package/src/register.ts +3 -0
  37. package/src/remark.ts +1 -1
  38. package/src/source-dialog.ts +126 -0
  39. package/src/sql/DfkSql.css +13 -10
  40. package/src/sql/DfkSql.ts +60 -51
  41. package/src/sql/PreviewTabs.ts +98 -52
  42. package/src/sql/SvgViewer.ts +155 -0
  43. package/src/sql/remark.ts +6 -1
  44. package/src/sql/renderers.ts +433 -159
  45. package/src/sql/sql.css +189 -19
  46. package/dist/sql/editor.d.ts +0 -16
  47. package/src/sql/editor.ts +0 -75
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The one place the kit talks to mermaid: the load, the render queue, the config
3
+ * the renderer is initialised with, and the two document-level facts a diagram
4
+ * needs (which colour mode the page is in, and how a rendered SVG gets into a
5
+ * shadow tree).
6
+ *
7
+ * Two upstream problems are solved here, and neither can be fixed from a site's
8
+ * `docusaurus.config.ts` or from the diagram source:
9
+ *
10
+ * 1. **The flash on a dark-mode first load, and the blank diagram.** Docusaurus'
11
+ * `useColorMode()` deliberately lags behind on the first client render (its
12
+ * state is initialised in an effect, to avoid hydration mismatches), so a
13
+ * theme-aware renderer paints once with the light palette and then again with
14
+ * the dark one: the light one lands on screen briefly (the flash), and the two
15
+ * overlap inside mermaid's mutable singleton, which can resolve with an empty
16
+ * SVG (an empty container, no error — facebook/docusaurus#8357). This module
17
+ * reads `<html data-theme>` instead: the attribute the inline script in
18
+ * `<head>` writes *before* the first paint, and the one the page CSS keys off.
19
+ * Nothing is rendered until it is known, so each diagram is rendered exactly
20
+ * once per mode, in the mode the page is really in.
21
+ * 2. **Concurrent renders.** Mermaid is a mutable singleton that cannot render
22
+ * two diagrams at once (`mermaid.initialize()` sets one global config, and the
23
+ * renderer mutates it as it goes): docusaurus#8357 asks to "render them
24
+ * sequentially one after the other", pointing at mermaid-js/mermaid#3577.
25
+ * Every render therefore goes through a single queue.
26
+ *
27
+ * This is browser-only code: `mermaid` and every DOM API are reached lazily, so
28
+ * Docusaurus' Node prerender can import the module graph without evaluating any
29
+ * of it.
30
+ */
31
+
32
+ import type {DfkMermaidConfig, MermaidColorMode} from './config';
33
+
34
+ /** The mermaid singleton, exactly as the package's default export types it. */
35
+ type Mermaid = (typeof import('mermaid'))['default'];
36
+ type RenderResult = Awaited<ReturnType<Mermaid['render']>>;
37
+ type MermaidConfig = Parameters<Mermaid['initialize']>[0];
38
+
39
+ /**
40
+ * One queue for the whole page: mermaid's `render()` calls are serialised,
41
+ * never concurrent. A rejected task must not poison the queue, hence the
42
+ * `catch` on the tail that the next task chains from.
43
+ */
44
+ let queue: Promise<unknown> = Promise.resolve();
45
+
46
+ function enqueue<T>(task: () => Promise<T>): Promise<T> {
47
+ const result = queue.then(task, task);
48
+ queue = result.catch(() => undefined);
49
+ return result;
50
+ }
51
+
52
+ let mermaidModule: Promise<Mermaid> | null = null;
53
+
54
+ /**
55
+ * Loads (once) and returns the mermaid singleton. A dynamic `import()` so a page
56
+ * with no diagram never downloads it, and the Node prerender never evaluates it.
57
+ */
58
+ function loadMermaid(): Promise<Mermaid> {
59
+ mermaidModule ??= import('mermaid').then((module) => module.default);
60
+ return mermaidModule;
61
+ }
62
+
63
+ /** Keeps the ids mermaid is handed unique across every diagram on a page. */
64
+ let sequence = 0;
65
+
66
+ /** How a diagram is rendered: its source, its config, and its colour mode. */
67
+ export interface MermaidRenderRequest {
68
+ source: string;
69
+ config: DfkMermaidConfig;
70
+ colorMode: MermaidColorMode;
71
+ }
72
+
73
+ /** A rendered diagram: the SVG markup, plus mermaid's bind hook for click handlers. */
74
+ export interface MermaidRenderOutput {
75
+ svg: string;
76
+ bind?(container: Element): void;
77
+ }
78
+
79
+ /**
80
+ * Renders one diagram, through the page-wide queue. Each render re-initialises
81
+ * mermaid with this diagram's config first: mermaid has two config levels and the
82
+ * site-wide one can only be set through `initialize()`.
83
+ */
84
+ export function renderMermaid({
85
+ source,
86
+ config,
87
+ colorMode,
88
+ }: MermaidRenderRequest): Promise<MermaidRenderOutput> {
89
+ const id = `dfk-mermaid-svg-${(sequence += 1)}`;
90
+ return enqueue(async () => {
91
+ const mermaid = await loadMermaid();
92
+ const options: MermaidConfig = {
93
+ startOnLoad: false,
94
+ ...config.options,
95
+ // `DfkMermaidConfig.theme` is a plain string on purpose — the kit does not
96
+ // pin mermaid's theme list (the shared config must stay dependency-free,
97
+ // and a site may name a theme a newer mermaid adds). This is the one place
98
+ // the value meets mermaid's own union, so the narrowing happens here.
99
+ theme: config.theme[colorMode] as MermaidConfig['theme'],
100
+ };
101
+ mermaid.initialize(options);
102
+ try {
103
+ const result: RenderResult = await mermaid.render(id, source);
104
+ return {svg: result.svg, bind: result.bindFunctions};
105
+ } catch (error) {
106
+ // Mermaid leaves a stray SVG/message in the DOM on error
107
+ // (https://github.com/mermaid-js/mermaid/issues/3205).
108
+ document.querySelector(`#d${id}`)?.remove();
109
+ throw error;
110
+ }
111
+ });
112
+ }
113
+
114
+ /** The colour mode the page is actually in, read from the pre-paint attribute. */
115
+ export function documentColorMode(): MermaidColorMode {
116
+ return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
117
+ }
118
+
119
+ /**
120
+ * Calls `listener` whenever the page's colour mode changes, and returns the
121
+ * unsubscribe. The `<head>` script only *writes* the attribute on load, so a
122
+ * mode switch made later has to be observed — that is how a diagram follows the
123
+ * theme toggle.
124
+ */
125
+ export function watchColorMode(listener: () => void): () => void {
126
+ const observer = new MutationObserver(listener);
127
+ observer.observe(document.documentElement, {attributeFilter: ['data-theme']});
128
+ return () => observer.disconnect();
129
+ }
130
+
131
+ /**
132
+ * Turns mermaid's SVG markup into a node a shadow tree can host.
133
+ *
134
+ * Mermaid serialises through `innerHTML`, so its output is *HTML*, not strict
135
+ * XML: an HTML label can carry a bare `<br>`, which `image/svg+xml` would reject.
136
+ * Parsing as `text/html` and taking the `<svg>` element gives the right
137
+ * namespace for free (the HTML parser enters foreign content for `<svg>`) and
138
+ * keeps markup out of `innerHTML`, which the kit's conventions rule out.
139
+ *
140
+ * Returns `null` when there is no `<svg>` to be found, so the caller can show an
141
+ * error instead of an empty box.
142
+ */
143
+ export function parseMermaidSvg(owner: Document, svg: string): SVGElement | null {
144
+ const parsed = new DOMParser().parseFromString(svg, 'text/html');
145
+ const root = parsed.body.querySelector('svg');
146
+ return root ? (owner.importNode(root, true) as unknown as SVGElement) : null;
147
+ }
148
+
149
+ /**
150
+ * Serialises a rendered diagram into standalone SVG markup.
151
+ *
152
+ * Mermaid hands its SVG back as an **HTML** string (it stringifies a detached
153
+ * element through `innerHTML`), and HTML serialisation writes a void element
154
+ * without a closing slash: a `<br/>` the author put inside a label comes back as
155
+ * `<br>`. That is fine where it lands — the page's HTML parser reads it, and
156
+ * `parseMermaidSvg()` parses it the same way — but it is fatal for a *file*: a
157
+ * browser opening an `.svg` parses XML, and `<br>` inside a `<p>` there is
158
+ * `Opening and ending tag mismatch: br … and p`, with the drawing cut off at the
159
+ * first error.
160
+ *
161
+ * Re-serialising the live node with `XMLSerializer` fixes both halves at once:
162
+ * XML serialisation closes every element, and it emits the namespace declarations
163
+ * a standalone document needs (`xmlns` on the `<svg>`, and one on any
164
+ * `foreignObject` subtree, whose XHTML content inherits its namespace from the
165
+ * page rather than carrying it as an attribute).
166
+ *
167
+ * This is why the element keeps the rendered `<svg>` node for the download
168
+ * instead of mermaid's own string: only the node can be re-serialised.
169
+ */
170
+ export function serializeMermaidSvg(svg: SVGElement): string {
171
+ return new XMLSerializer().serializeToString(svg);
172
+ }
@@ -0,0 +1,24 @@
1
+ // The `<dfk-mermaid>` shadow-root CSS as an inline string (Vite `?inline`
2
+ // import), mirroring `home/styles.ts` and `sql/styles.ts`. Everything the
3
+ // component draws lives in the shadow tree — the diagram included, which is what
4
+ // keeps mermaid's inline `<style>` (it ships one inside every SVG) scoped to this
5
+ // one component instead of leaking into the page or into a sibling diagram.
6
+ //
7
+ // The type declaration lives in `vite-env.d.ts`; `tsc` never resolves the
8
+ // `?inline` suffix, so the module graph stays buildable without Vite running.
9
+ import mermaidCss from './DfkMermaid.css?inline';
10
+
11
+ let sheet: CSSStyleSheet | null = null;
12
+
13
+ /**
14
+ * The one parsed stylesheet shared by every `<dfk-mermaid>` shadow root.
15
+ * Created lazily: `CSSStyleSheet` does not exist during Docusaurus' Node
16
+ * prerender, and only the browser ever calls this.
17
+ */
18
+ export function mermaidStyles(): CSSStyleSheet {
19
+ if (!sheet) {
20
+ sheet = new CSSStyleSheet();
21
+ sheet.replaceSync(mermaidCss);
22
+ }
23
+ return sheet;
24
+ }
@@ -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
+ }
package/src/register.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import 'iconify-icon';
5
5
  import {DfkFeatures} from './home/DfkFeatures';
6
6
  import {DfkHero} from './home/DfkHero';
7
+ import {DfkMermaid} from './mermaid/DfkMermaid';
7
8
  import {DfkNextSteps} from './home/DfkNextSteps';
8
9
  import {DfkSql} from './sql/DfkSql';
9
10
 
@@ -12,6 +13,7 @@ const TAGS = {
12
13
  features: 'dfk-features',
13
14
  nextSteps: 'dfk-next-steps',
14
15
  sql: 'dfk-sql',
16
+ mermaid: 'dfk-mermaid',
15
17
  } as const;
16
18
 
17
19
  /**
@@ -31,6 +33,7 @@ export function registerDfkElements(): void {
31
33
  [TAGS.features]: DfkFeatures,
32
34
  [TAGS.nextSteps]: DfkNextSteps,
33
35
  [TAGS.sql]: DfkSql,
36
+ [TAGS.mermaid]: DfkMermaid,
34
37
  })) {
35
38
  if (!customElements.get(name)) {
36
39
  customElements.define(name, ctor);
package/src/remark.ts CHANGED
@@ -15,7 +15,7 @@ import type {Plugin} from 'unified';
15
15
  export const DEFAULT_VERSION_PLACEHOLDER = '{{DUCKFN_VERSION}}';
16
16
 
17
17
  export interface VersionPlaceholderOptions {
18
- /** The real version string to substitute in, e.g. `0.0.15`. */
18
+ /** The real version string to substitute in, e.g. `0.0.17`. */
19
19
  version: string;
20
20
  /** Override the token if a site uses a different one. */
21
21
  placeholder?: string;
@@ -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
+ }
@@ -159,10 +159,13 @@
159
159
 
160
160
  /* --- Icon buttons and their tooltips ------------------------------------- */
161
161
 
162
- /* Duplicated in `sql.css`: these buttons live in this shadow tree, while the
163
- fullscreen toggle a renderer parks in the result's tab strip lives in the
164
- light DOM, where a shadow rule could not reach it. Keep both copies in sync. */
165
- .dfk-sql-icon-button {
162
+ /* The class names are written by the shared `IconButton` widget (`src/IconButton.ts`).
163
+ The rules are duplicated in `sql.css` and `DfkMermaid.css`: these buttons live
164
+ in this shadow tree, the fullscreen toggle a renderer parks in the result's tab
165
+ strip lives in the light DOM, and a diagram's cluster lives in another shadow
166
+ tree entirely — a shadow rule can only ever reach one of the three. Keep the
167
+ copies in sync. */
168
+ .dfk-icon-button {
166
169
  position: relative;
167
170
  display: inline-flex;
168
171
  align-items: center;
@@ -178,27 +181,27 @@
178
181
  transition: background 0.12s ease, color 0.12s ease;
179
182
  }
180
183
 
181
- .dfk-sql-icon-button:hover:not(:disabled),
182
- .dfk-sql-icon-button:focus-visible {
184
+ .dfk-icon-button:hover:not(:disabled),
185
+ .dfk-icon-button:focus-visible {
183
186
  background: var(--ifm-color-emphasis-200, #e6e6e6);
184
187
  color: var(--ifm-color-primary, #14459b);
185
188
  }
186
189
 
187
- .dfk-sql-icon-button:disabled {
190
+ .dfk-icon-button:disabled {
188
191
  opacity: 0.45;
189
192
  cursor: progress;
190
193
  }
191
194
 
192
- .dfk-sql-icon-button[hidden] {
195
+ .dfk-icon-button[hidden] {
193
196
  display: none;
194
197
  }
195
198
 
196
199
  /* A sticky state, e.g. the wrap toggle while wrapping is on. */
197
- .dfk-sql-icon-on {
200
+ .dfk-icon-on {
198
201
  color: var(--ifm-color-primary, #14459b);
199
202
  }
200
203
 
201
- .dfk-sql-icon {
204
+ .dfk-icon {
202
205
  font-size: 1rem;
203
206
  }
204
207