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.
@@ -1,19 +1,11 @@
1
- import type {PanzoomObject} from '@panzoom/panzoom';
2
- import type {CodeEditor} from '../codemirror';
3
- import {mountCodeEditor} from '../codemirror';
4
1
  import {IconButton} from '../IconButton';
5
2
  import {el, HTMLElementBase} from '../dom';
3
+ import {saveDownload, sectionFileName, type DownloadPayload} from '../download';
4
+ import {PanZoomView} from '../panzoom-view';
5
+ import {SourceDialog} from '../source-dialog';
6
6
  import {parseMermaidConfig, type DfkMermaidConfig, type MermaidColorMode} from './config';
7
- import {
8
- documentColorMode,
9
- loadPanzoom,
10
- parseMermaidSvg,
11
- renderMermaid,
12
- serializeMermaidSvg,
13
- watchColorMode,
14
- } from './render';
7
+ import {documentColorMode, parseMermaidSvg, renderMermaid, serializeMermaidSvg, watchColorMode} from './render';
15
8
  import {mermaidStyles} from './styles';
16
- import {diagramFileName} from './title';
17
9
 
18
10
  /**
19
11
  * `<dfk-mermaid>` — a ```mermaid fence rendered as a diagram, produced by
@@ -26,16 +18,18 @@ import {diagramFileName} from './title';
26
18
  * rendering happens here rather than inside a React tree, the same code serves
27
19
  * the runnable-SQL `mermaid` output — see the renderer in `sql/renderers.ts`.
28
20
  *
29
- * What a reader gets on top of the diagram:
21
+ * Two ways to use the element:
30
22
  *
31
- * - **Zoom and pan** — wheel to zoom, drag to pan (`@panzoom/panzoom` on the
32
- * content box, so the SVG node itself is never mutated; the download
33
- * re-serialises that same untouched node, see `serializeMermaidSvg`). Panning
34
- * only engages once the diagram is zoomed, which is what lets the page keep
35
- * scrolling normally over a diagram that fits.
36
- * - **Reset zoom**, **fullscreen**, **source editing** (a CodeMirror dialog),
37
- * and **download SVG** as floating icon buttons in the top-right corner, the
38
- * same idiom as the kit's code blocks.
23
+ * - **Standalone** (a fence, the default) — the diagram floats the usual icon
24
+ * cluster in its top-right corner: reset zoom, source editing, download, and a
25
+ * fullscreen toggle of its own; zoom and pan turn on only in that fullscreen.
26
+ * - **Embedded** (`embedded`, set by the runnable-SQL renderer) — the element
27
+ * sheds its frame and its floating cluster, because the SQL result area already
28
+ * draws both. Its own zoom controls travel out as {@link actions} (reset zoom /
29
+ * source editing) for the result's tab strip, its download travels out as
30
+ * {@link downloadPayload} for the same strip's download button, and zoom is
31
+ * driven from outside by {@link setFullscreen} — the result area's fullscreen,
32
+ * which the element then fills.
39
33
  *
40
34
  * Everything lives in the shadow root, diagram included: mermaid ships an inline
41
35
  * `<style>` inside every SVG it renders, and one shadow root per diagram is what
@@ -47,10 +41,13 @@ import {diagramFileName} from './title';
47
41
  * document, the same way a new query result is.
48
42
  *
49
43
  * Content entry is an **attribute seed** (see CONVENTIONS.md rule 5 exception):
50
- * `source` / `config` are read once in `connectedCallback`, because neither
51
- * producer — the remark plugin, or the runnable-SQL `mermaid` renderer — has a
52
- * React mount point to call a setter from. Reading once to initialise is not an
53
- * attribute→render loop, so the retained-mode contract still holds.
44
+ * `source` / `config` / `embedded` are read once, because none of the producers —
45
+ * the remark plugin, the runnable-SQL `mermaid` renderer — has a React mount point
46
+ * to call a setter from. Reading once to initialise is not an attribute→render
47
+ * loop, so the retained-mode contract still holds. Seeding is deferred until the
48
+ * first need ({@link #ensureSeeded}) rather than pinned to `connectedCallback`:
49
+ * the SQL renderer has to reach {@link actions} *before* inserting the element, to
50
+ * hand the container to the tab strip it is building.
54
51
  */
55
52
 
56
53
  type MermaidLabels = {
@@ -93,15 +90,8 @@ const LABELS: Record<string, MermaidLabels> = {
93
90
  },
94
91
  };
95
92
 
96
- /**
97
- * The zoom range. `MIN_SCALE` is the fit-to-box scale: the diagram opens there
98
- * and cannot go below it, which is what makes "zoomed" a clean yes/no — it is
99
- * exactly the state in which a drag pans, the wheel has something to undo, and
100
- * the cursor stops promising plain text.
101
- */
102
- const MIN_SCALE = 1;
103
- const MAX_SCALE = 8;
104
- const ZOOM_STEP = 0.25;
93
+ /** What an embedded diagram is called when nothing on the page says otherwise. */
94
+ const FALLBACK_FILE = 'mermaid-diagram';
105
95
 
106
96
  export class DfkMermaid extends HTMLElementBase {
107
97
  readonly #canvas = el('div', {class: 'dfk-mermaid-canvas', hidden: true});
@@ -112,29 +102,34 @@ export class DfkMermaid extends HTMLElementBase {
112
102
  readonly #viewport = el('div', {class: 'dfk-mermaid-viewport'});
113
103
  /** The transform target; holds the rendered `<svg>` and nothing else. */
114
104
  readonly #content = el('div', {class: 'dfk-mermaid-content'});
115
- readonly #actions = el('div', {class: 'dfk-mermaid-actions'});
105
+ readonly #view = new PanZoomView(this.#viewport, this.#content);
106
+ /**
107
+ * The zoom/source controls. Placed inside `#canvas` (floating) when standalone
108
+ * and handed out through {@link actions} when embedded; the class name is set at
109
+ * seed time, because which stylesheet has to reach it depends on that.
110
+ */
111
+ readonly #actions = el('div');
116
112
  readonly #message = el('p', {
117
113
  class: 'dfk-mermaid-message',
118
114
  attrs: {'aria-live': 'polite'},
119
115
  hidden: true,
120
116
  });
121
- readonly #resetBtn: IconButton;
122
- readonly #editBtn: IconButton;
123
- readonly #downloadBtn: IconButton;
124
- readonly #fullscreenBtn: IconButton;
125
- readonly #dialog = el('dialog', {class: 'dfk-mermaid-dialog'});
126
- readonly #dialogTitle = el('h2', {class: 'dfk-mermaid-dialog-title'});
127
- /** Where the CodeMirror editor mounts, inside the dialog. */
128
- readonly #editorHost = el('div', {class: 'dfk-mermaid-dialog-editor'});
129
- readonly #applyBtn = el('button', {
130
- class: 'dfk-mermaid-dialog-button dfk-mermaid-dialog-apply',
131
- type: 'button',
117
+ readonly #resetBtn = new IconButton('lucide:rotate-ccw', () => this.#view.reset());
118
+ readonly #editBtn = new IconButton('lucide:pencil', () => this.#openEditor());
119
+ /** Standalone only; embedded diagrams download through the result chrome. */
120
+ #downloadBtn: IconButton | null = null;
121
+ /** Standalone only; embedded diagrams zoom in the result area's fullscreen. */
122
+ #fullscreenBtn: IconButton | null = null;
123
+ readonly #dialog = new SourceDialog({
124
+ title: LABELS.en.editTitle,
125
+ apply: LABELS.en.apply,
126
+ cancel: LABELS.en.cancel,
132
127
  });
133
- readonly #cancelBtn = el('button', {class: 'dfk-mermaid-dialog-button', type: 'button'});
134
128
 
135
129
  #labels: MermaidLabels = LABELS.en;
136
130
  #config: DfkMermaidConfig = parseMermaidConfig(null);
137
131
  #source = '';
132
+ #embedded = false;
138
133
  /**
139
134
  * The diagram currently on screen, held as a *node* rather than as mermaid's
140
135
  * returned string: the download re-serialises it (`serializeMermaidSvg`), which
@@ -144,37 +139,21 @@ export class DfkMermaid extends HTMLElementBase {
144
139
  #svg: SVGElement | null = null;
145
140
  #colorMode: MermaidColorMode | null = null;
146
141
  #seeded = false;
142
+ /** Standalone fullscreen, which is also this element's zoom switch. */
147
143
  #expanded = false;
144
+ /** Embedded fullscreen, driven from outside; also the zoom switch. */
145
+ #fullscreen = false;
148
146
  /** Whether the document-level Esc handler is currently attached. */
149
147
  #escBound = false;
150
148
  /** Invalidates an in-flight render when a newer one starts or the element leaves. */
151
149
  #renderToken = 0;
152
- #panzoom: PanzoomObject | null = null;
153
- #panzoomLoading = false;
154
- #editor: CodeEditor | null = null;
155
- #editorLoading = false;
156
150
  #unwatchColorMode: (() => void) | null = null;
157
151
 
158
152
  readonly #onEsc = (event: KeyboardEvent): void => {
159
- if (event.key === 'Escape' && this.#expanded && !this.#dialog.open) {
153
+ if (event.key === 'Escape' && this.#expanded && !this.#dialog.root.open) {
160
154
  this.#setExpanded(false);
161
155
  }
162
156
  };
163
- readonly #onWheel = (event: WheelEvent): void => {
164
- this.#panzoom?.zoomWithWheel(event);
165
- };
166
- /** The cursor follows the zoom state, so it never promises a pan that cannot happen. */
167
- readonly #onPanzoomChange = (): void => {
168
- this.#canvas.classList.toggle('dfk-mermaid-zoomed', this.#isZoomed());
169
- };
170
- readonly #onPanzoomEnd = (): void => {
171
- this.#canvas.classList.remove('dfk-mermaid-grabbing');
172
- };
173
- readonly #onPanzoomStart = (): void => {
174
- // `panzoomstart` fires at fit too, where the gesture was left to the browser;
175
- // only a drag that will really pan gets the closed hand.
176
- this.#canvas.classList.toggle('dfk-mermaid-grabbing', this.#isZoomed());
177
- };
178
157
  readonly #onColorModeChange = (): void => {
179
158
  if (documentColorMode() !== this.#colorMode) {
180
159
  void this.#render();
@@ -183,61 +162,32 @@ export class DfkMermaid extends HTMLElementBase {
183
162
 
184
163
  constructor() {
185
164
  super();
186
- this.#resetBtn = new IconButton('lucide:rotate-ccw', () => this.#resetView());
187
- this.#editBtn = new IconButton('lucide:pencil', () => this.#openEditor());
188
- this.#downloadBtn = new IconButton('lucide:download', () => this.#download());
189
- this.#fullscreenBtn = new IconButton('lucide:maximize', () =>
190
- this.#setExpanded(!this.#expanded),
191
- );
192
-
193
- this.#actions.append(
194
- this.#resetBtn.root,
195
- this.#editBtn.root,
196
- this.#downloadBtn.root,
197
- this.#fullscreenBtn.root,
198
- );
199
165
  this.#viewport.appendChild(this.#content);
200
- this.#canvas.append(this.#viewport, this.#actions);
201
- // panzoom reports state as DOM `CustomEvent`s dispatched on the element it
202
- // transforms (`@panzoom/panzoom` v4 has no `on()` API), so they are listened
203
- // for here, on our own node — which also means they need no teardown, and
204
- // that re-creating the panzoom instance after a reconnect cannot double them.
205
- this.#content.addEventListener('panzoomchange', this.#onPanzoomChange);
206
- this.#content.addEventListener('panzoomstart', this.#onPanzoomStart);
207
- this.#content.addEventListener('panzoomend', this.#onPanzoomEnd);
208
-
209
- this.#applyBtn.addEventListener('click', () => this.#applyEdit());
210
- this.#cancelBtn.addEventListener('click', () => this.#dialog.close());
211
- this.#dialog.append(
212
- el('div', {class: 'dfk-mermaid-dialog-body'}, (body) =>
213
- body.append(
214
- this.#dialogTitle,
215
- this.#editorHost,
216
- el('div', {class: 'dfk-mermaid-dialog-footer'}, (footer) =>
217
- footer.append(this.#cancelBtn, this.#applyBtn),
218
- ),
219
- ),
220
- ),
221
- );
166
+ this.#canvas.appendChild(this.#viewport);
222
167
 
223
168
  const shadow = this.attachShadow({mode: 'open'});
224
169
  shadow.adoptedStyleSheets = [mermaidStyles()];
225
170
  // No slot: nothing is ever handed in as a child (the source travels as an
226
171
  // attribute), so the light DOM stays empty and there is nothing to hide.
227
- shadow.append(this.#canvas, this.#message, this.#dialog);
172
+ shadow.append(this.#canvas, this.#message, this.#dialog.root);
228
173
  this.#applyLabels();
229
174
  }
230
175
 
176
+ /**
177
+ * The zoom/source controls an embedded diagram offers, for the host to place in
178
+ * its own chrome. Empty (and unused) when standalone — the element keeps them.
179
+ */
180
+ get actions(): HTMLElement {
181
+ this.#ensureSeeded();
182
+ return this.#actions;
183
+ }
184
+
231
185
  connectedCallback(): void {
232
- if (!this.#seeded) {
233
- this.#seed();
234
- this.#seeded = true;
235
- }
186
+ this.#ensureSeeded();
236
187
  // Registered here rather than in the constructor: a listener on an *external*
237
188
  // object has to be paired with a removal, and connect/disconnect is where
238
189
  // that pairing is observable (React may remount the element).
239
190
  this.#unwatchColorMode ??= watchColorMode(this.#onColorModeChange);
240
- void this.#ensurePanzoom();
241
191
  // Only when there is nothing on screen: a reconnect after a move already
242
192
  // carries its diagram, and re-rendering it would flash for no reason.
243
193
  if (this.#source && !this.#content.hasChildNodes()) {
@@ -251,34 +201,53 @@ export class DfkMermaid extends HTMLElementBase {
251
201
  this.#unwatchColorMode = null;
252
202
  // Panzoom binds move/up on `document`, so it outlives the element unless it
253
203
  // is torn down here.
254
- this.#viewport.removeEventListener('wheel', this.#onWheel);
255
- this.#panzoom?.destroy();
256
- this.#panzoom = null;
257
- // The zoom-state classes describe the instance that is now gone.
258
- this.#canvas.classList.remove('dfk-mermaid-zoomed', 'dfk-mermaid-grabbing');
204
+ this.#view.destroy();
259
205
  this.#renderToken += 1;
260
- this.#editor?.destroy();
261
- this.#editor = null;
262
- if (this.#dialog.open) {
263
- this.#dialog.close();
206
+ this.#dialog.destroy();
207
+ }
208
+
209
+ /**
210
+ * Turns zoom/pan on or off for an embedded diagram. The host calls this with its
211
+ * own fullscreen state: an embedded diagram zooms exactly where its result area
212
+ * is expanded, and fills that area while it is.
213
+ */
214
+ setFullscreen(value: boolean): void {
215
+ this.#ensureSeeded();
216
+ this.#fullscreen = value;
217
+ this.classList.toggle('dfk-mermaid-fullscreen', value);
218
+ this.#view.setActive(value);
219
+ }
220
+
221
+ /**
222
+ * The file this diagram would be saved as, or `null` while nothing is rendered.
223
+ * An embedded diagram hands this to the result chrome's download button, which
224
+ * is why the element does not save it itself.
225
+ */
226
+ downloadPayload(): DownloadPayload | null {
227
+ if (this.#svg === null) {
228
+ return null;
264
229
  }
230
+ return {
231
+ name: sectionFileName(this, 'svg', {source: this.#source, fallback: FALLBACK_FILE}),
232
+ mime: 'image/svg+xml;charset=utf-8',
233
+ text: serializeMermaidSvg(this.#svg),
234
+ };
265
235
  }
266
236
 
267
237
  /**
268
- * Reads the `source` / `config` attributes once.
269
- *
270
- * This is the element's *only* content entry, for both producers: the remark
271
- * plugin emits the attributes at build time, and the runnable-SQL `mermaid`
272
- * renderer sets them on the element it creates. Neither has a React mount point
273
- * to call a setter from, which is the exception CONVENTIONS.md rule 5 makes for
274
- * plugin-generated elements — and reading them once to initialise is not an
275
- * attribute→render loop, so the retained-mode contract still holds.
276
- *
277
- * `source` is only applied when the attribute is actually present, so a caller
278
- * that sets the attribute before inserting the element keeps it: the attribute
279
- * is read at *upgrade* time, which for an element created by
280
- * `document.createElement` after `customElements.define` is the insertion.
238
+ * Reads the `source` / `config` / `embedded` attributes once, and builds the
239
+ * part of the structure that depends on the mode. Idempotent, and callable
240
+ * before connection (see {@link actions}): the SQL renderer reads the attributes
241
+ * it set through `el()` before it inserts the element.
281
242
  */
243
+ #ensureSeeded(): void {
244
+ if (this.#seeded) {
245
+ return;
246
+ }
247
+ this.#seeded = true;
248
+ this.#seed();
249
+ }
250
+
282
251
  #seed(): void {
283
252
  this.#labels =
284
253
  LABELS[(document.documentElement.getAttribute('lang') ?? 'en').toLowerCase()] ??
@@ -288,19 +257,42 @@ export class DfkMermaid extends HTMLElementBase {
288
257
  this.#source = source;
289
258
  }
290
259
  this.#config = parseMermaidConfig(this.getAttribute('config'));
260
+ this.#embedded = this.hasAttribute('embedded');
261
+ if (this.#embedded) {
262
+ // The result chrome places this container and styles it (see `sql.css`);
263
+ // the class is neutral because a shadow boundary is not involved here.
264
+ this.#actions.className = 'dfk-sql-tab-actions';
265
+ this.#actions.append(this.#resetBtn.root, this.#editBtn.root);
266
+ } else {
267
+ this.#downloadBtn = new IconButton('lucide:download', () => this.#download());
268
+ this.#fullscreenBtn = new IconButton('lucide:maximize', () =>
269
+ this.#setExpanded(!this.#expanded),
270
+ );
271
+ this.#actions.className = 'dfk-mermaid-actions';
272
+ this.#actions.append(
273
+ this.#resetBtn.root,
274
+ this.#editBtn.root,
275
+ this.#downloadBtn.root,
276
+ this.#fullscreenBtn.root,
277
+ );
278
+ this.#canvas.appendChild(this.#actions);
279
+ }
291
280
  this.#applyLabels();
281
+ this.#setActionsAvailable(false);
292
282
  }
293
283
 
294
284
  #applyLabels(): void {
295
285
  this.#resetBtn.setLabel(this.#labels.reset);
296
286
  this.#editBtn.setLabel(this.#labels.edit);
297
- this.#downloadBtn.setLabel(this.#labels.download);
298
- this.#fullscreenBtn.setLabel(
287
+ this.#downloadBtn?.setLabel(this.#labels.download);
288
+ this.#fullscreenBtn?.setLabel(
299
289
  this.#expanded ? this.#labels.exitFullscreen : this.#labels.fullscreen,
300
290
  );
301
- this.#dialogTitle.textContent = this.#labels.editTitle;
302
- this.#cancelBtn.textContent = this.#labels.cancel;
303
- this.#applyBtn.textContent = this.#labels.apply;
291
+ this.#dialog.setLabels({
292
+ title: this.#labels.editTitle,
293
+ apply: this.#labels.apply,
294
+ cancel: this.#labels.cancel,
295
+ });
304
296
  }
305
297
 
306
298
  // --- Rendering -------------------------------------------------------------
@@ -340,20 +332,19 @@ export class DfkMermaid extends HTMLElementBase {
340
332
  throw new Error(this.#labels.renderFailed);
341
333
  }
342
334
  this.#svg = svg;
343
- this.#content.replaceChildren(svg);
335
+ this.#view.setContent(svg);
344
336
  // Mermaid's own hook for click handlers on nodes; it takes the container
345
337
  // that holds the SVG.
346
338
  output.bind?.(this.#content);
347
339
  this.#canvas.hidden = false;
348
340
  this.#setMessage('', false);
349
341
  this.#setActionsAvailable(true);
350
- this.#resetView();
351
342
  } catch (error) {
352
343
  if (token !== this.#renderToken || !this.isConnected) {
353
344
  return;
354
345
  }
355
346
  this.#svg = null;
356
- this.#content.replaceChildren();
347
+ this.#view.setContent(null);
357
348
  this.#canvas.hidden = true;
358
349
  this.#setActionsAvailable(false);
359
350
  this.#setMessage(`${this.#labels.renderFailed}: ${messageOf(error)}`, true);
@@ -366,100 +357,23 @@ export class DfkMermaid extends HTMLElementBase {
366
357
  this.#message.classList.toggle('dfk-mermaid-message-error', isError);
367
358
  }
368
359
 
369
- /** Reset zoom and download only mean something once a diagram is on screen. */
360
+ /** Editing and resetting only mean something once a diagram is on screen. */
370
361
  #setActionsAvailable(available: boolean): void {
371
362
  this.#resetBtn.root.hidden = !available;
372
- this.#downloadBtn.root.hidden = !available;
373
- }
374
-
375
- // --- Zoom and pan ----------------------------------------------------------
376
-
377
- /**
378
- * Binds panzoom to the content box. `@panzoom/panzoom` is an enhancement, not a
379
- * requirement: if its chunk never arrives, the diagram still renders, only
380
- * wheel zoom and dragging stay inert.
381
- *
382
- * Three settings carry the interaction contract:
383
- *
384
- * - `panOnlyWhenZoomed` — a diagram that already fits must not swallow drags,
385
- * so panning only engages once it is enlarged. That is also what keeps
386
- * `touchAction: 'pan-y'` meaningful: vertical page scrolling stays the
387
- * browser's, pinch and horizontal drags go to the diagram.
388
- * - `handleStartEvent` — panzoom's default takes the gesture on *every*
389
- * pointerdown (`preventDefault` + `stopPropagation`), which costs the reader
390
- * text selection at every zoom level. Handing the gesture over only when a
391
- * drag will really pan is what makes the labels selectable while the diagram
392
- * is at fit. Nothing else blocks it: panzoom's move listener is `passive` and
393
- * never calls `preventDefault`.
394
- * - no `cursor` option — panzoom would then put `grab` on the element for good,
395
- * over text that is perfectly selectable. The cursor is driven by the zoom
396
- * state instead, from `DfkMermaid.css`.
397
- */
398
- async #ensurePanzoom(): Promise<void> {
399
- if (this.#panzoom !== null || this.#panzoomLoading) {
400
- return;
401
- }
402
- this.#panzoomLoading = true;
403
- try {
404
- const Panzoom = await loadPanzoom();
405
- if (!this.isConnected) {
406
- return;
407
- }
408
- this.#viewport.addEventListener('wheel', this.#onWheel, {passive: false});
409
- this.#panzoom = Panzoom(this.#content, {
410
- maxScale: MAX_SCALE,
411
- minScale: MIN_SCALE,
412
- step: ZOOM_STEP,
413
- panOnlyWhenZoomed: true,
414
- touchAction: 'pan-y',
415
- // panzoom's own default is `move`, written inline on the element — the
416
- // cursor has to stay a CSS decision (see `DfkMermaid.css`), so it is
417
- // switched off here.
418
- cursor: '',
419
- handleStartEvent: (event) => {
420
- if (!this.#isZoomed()) {
421
- return;
422
- }
423
- event.preventDefault();
424
- event.stopPropagation();
425
- },
426
- });
427
- // panzoom also forces `user-select: none` inline on the element *and its
428
- // parent*, with no option to prevent it — that alone made every label in
429
- // every diagram unselectable. It exists to stop a drag from selecting while
430
- // panning, which `handleStartEvent` already covers: whenever a pan will
431
- // happen, the gesture is taken with `preventDefault()` before the browser
432
- // can start a selection. panzoom writes these styles only here and in
433
- // `setOptions`, which this component never calls, so clearing them once is
434
- // enough.
435
- this.#content.style.userSelect = '';
436
- this.#viewport.style.userSelect = '';
437
- } catch {
438
- // Nothing to report: the diagram is already usable without pan/zoom.
439
- } finally {
440
- this.#panzoomLoading = false;
363
+ this.#editBtn.root.hidden = !available;
364
+ if (this.#downloadBtn) {
365
+ this.#downloadBtn.root.hidden = !available;
441
366
  }
442
367
  }
443
368
 
444
- /** Whether the diagram is enlarged past its fit-to-box size. */
445
- #isZoomed(): boolean {
446
- return (this.#panzoom?.getScale() ?? MIN_SCALE) > MIN_SCALE;
447
- }
448
-
449
- #resetView(): void {
450
- this.#panzoom?.reset({
451
- // Zooming back to fit is a transition the reader did not ask to skip, but
452
- // one they may have asked not to have.
453
- animate: !prefersReducedMotion(),
454
- });
455
- }
456
-
457
369
  // --- Fullscreen ------------------------------------------------------------
458
370
 
371
+ /** The standalone fullscreen toggle; also switches zoom on and off. */
459
372
  #setExpanded(value: boolean): void {
460
373
  this.#expanded = value;
461
374
  this.#canvas.classList.toggle('dfk-mermaid-expanded', value);
462
- this.#fullscreenBtn.setIcon(value ? 'lucide:minimize' : 'lucide:maximize');
375
+ this.#view.setActive(value);
376
+ this.#fullscreenBtn?.setIcon(value ? 'lucide:minimize' : 'lucide:maximize');
463
377
  this.#applyLabels();
464
378
  if (value !== this.#escBound) {
465
379
  if (value) {
@@ -473,85 +387,25 @@ export class DfkMermaid extends HTMLElementBase {
473
387
 
474
388
  // --- Download --------------------------------------------------------------
475
389
 
476
- /**
477
- * Saves the diagram as a standalone `.svg` file.
478
- *
479
- * The markup is re-serialised from the rendered node, not taken from mermaid's
480
- * return value — that one is HTML, and its void elements come out unclosed,
481
- * which a browser opening the file as XML rejects. See `serializeMermaidSvg`.
482
- */
390
+ /** Saves the diagram as a standalone `.svg` file (see {@link downloadPayload}). */
483
391
  #download(): void {
484
- if (this.#svg === null) {
485
- return;
392
+ const payload = this.downloadPayload();
393
+ if (payload) {
394
+ saveDownload(payload);
486
395
  }
487
- const markup = serializeMermaidSvg(this.#svg);
488
- const blob = new Blob([markup], {type: 'image/svg+xml;charset=utf-8'});
489
- const url = URL.createObjectURL(blob);
490
- // Named after the section the diagram sits in (see `title.ts`), so the reader
491
- // gets `2. Registration.svg` rather than a second `mermaid-diagram.svg`.
492
- const link = el('a', {href: url, download: diagramFileName(this.#source, this)});
493
- // Anchored in the shadow tree for the click; a detached anchor is ignored by
494
- // some browsers, and by then the download has already been handed to it.
495
- this.shadowRoot?.appendChild(link);
496
- link.click();
497
- link.remove();
498
- window.setTimeout(() => URL.revokeObjectURL(url), 0);
499
396
  }
500
397
 
501
398
  // --- Source editing --------------------------------------------------------
502
399
 
503
- /**
504
- * Opens the source dialog, mounting the editor on first use. The dialog is
505
- * shown *before* the editor mounts: CodeMirror measures its container as it is
506
- * constructed, and a `display: none` dialog measures to zero.
507
- */
400
+ /** Opens the source dialog; the edited text is re-rendered on Apply. */
508
401
  #openEditor(): void {
509
- this.#dialog.showModal();
510
- if (this.#editor) {
511
- this.#editor.setValue(this.#source);
512
- return;
513
- }
514
- void this.#mountEditor();
515
- }
516
-
517
- async #mountEditor(): Promise<void> {
518
- if (this.#editorLoading) {
519
- return;
520
- }
521
- this.#editorLoading = true;
522
- try {
523
- // No language: there is no first-party CodeMirror grammar for mermaid, and
524
- // the dialog is for touching up a diagram, not writing SQL.
525
- const editor = await mountCodeEditor(this.#editorHost, this.#source, () => undefined);
526
- if (!this.isConnected || !this.#dialog.open) {
527
- // Closed while the modules were loading: nothing will ever dispose this
528
- // editor, so dispose it here.
529
- editor.destroy();
530
- return;
531
- }
532
- editor.setWrap(true);
533
- this.#editor = editor;
534
- } catch {
535
- // The dialog stays open with an empty editor box; a second click retries.
536
- } finally {
537
- this.#editorLoading = false;
538
- }
539
- }
540
-
541
- #applyEdit(): void {
542
- const edited = this.#editor?.getValue();
543
- this.#dialog.close();
544
- if (edited !== undefined && edited !== this.#source) {
545
- this.#source = edited;
402
+ this.#dialog.open(this.#source, (value) => {
403
+ this.#source = value;
546
404
  void this.#render();
547
- }
405
+ });
548
406
  }
549
407
  }
550
408
 
551
409
  function messageOf(error: unknown): string {
552
410
  return error instanceof Error ? error.message : String(error);
553
411
  }
554
-
555
- function prefersReducedMotion(): boolean {
556
- return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
557
- }
@@ -146,12 +146,6 @@ export function parseMermaidSvg(owner: Document, svg: string): SVGElement | null
146
146
  return root ? (owner.importNode(root, true) as unknown as SVGElement) : null;
147
147
  }
148
148
 
149
- /** The pan/zoom library, loaded once on first use (see `DfkMermaid`). */
150
- export async function loadPanzoom(): Promise<typeof import('@panzoom/panzoom')['default']> {
151
- const module = await import('@panzoom/panzoom');
152
- return module.default;
153
- }
154
-
155
149
  /**
156
150
  * Serialises a rendered diagram into standalone SVG markup.
157
151
  *