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
|
@@ -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
|
-
*
|
|
21
|
+
* Two ways to use the element:
|
|
30
22
|
*
|
|
31
|
-
* - **
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
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 #
|
|
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:
|
|
122
|
-
readonly #editBtn:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
*
|
|
271
|
-
*
|
|
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
|
|
298
|
-
this.#fullscreenBtn
|
|
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.#
|
|
302
|
-
|
|
303
|
-
|
|
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.#
|
|
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.#
|
|
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
|
-
/**
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
485
|
-
|
|
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.
|
|
510
|
-
|
|
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
|
-
}
|
package/src/mermaid/render.ts
CHANGED
|
@@ -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
|
*
|