duckfn-docs-kit 0.3.0 → 0.4.0
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 +294 -234
- package/README.md +10 -6
- package/dist/IconButton.d.ts +22 -0
- package/dist/codemirror.d.ts +29 -0
- package/dist/index.d.ts +28 -7
- package/dist/index.js +2 -2
- package/dist/mermaid/DfkMermaid.d.ts +7 -0
- package/dist/mermaid/config.d.ts +48 -0
- package/dist/mermaid/remark.d.ts +40 -0
- package/dist/mermaid/remark.js +32 -0
- package/dist/mermaid/render.d.ts +94 -0
- package/dist/mermaid/styles.d.ts +6 -0
- package/dist/mermaid/title.d.ts +23 -0
- package/dist/{register-CALCwFBv.js → register-Dev_kc3Z.js} +891 -558
- package/dist/remark.d.ts +1 -1
- package/dist/sql/client.js +1 -1
- package/dist/sql/remark.d.ts +6 -1
- package/dist/sql/renderers.d.ts +2 -0
- package/package.json +4 -1
- package/src/IconButton.ts +50 -0
- package/src/codemirror.ts +88 -0
- package/src/index.ts +31 -7
- package/src/mermaid/DfkMermaid.css +289 -0
- package/src/mermaid/DfkMermaid.ts +557 -0
- package/src/mermaid/config.ts +74 -0
- package/src/mermaid/remark.ts +98 -0
- package/src/mermaid/render.ts +178 -0
- package/src/mermaid/styles.ts +24 -0
- package/src/mermaid/title.ts +127 -0
- package/src/register.ts +3 -0
- package/src/remark.ts +1 -1
- package/src/sql/DfkSql.css +13 -10
- package/src/sql/DfkSql.ts +11 -46
- package/src/sql/remark.ts +6 -1
- package/src/sql/renderers.ts +24 -3
- package/src/sql/sql.css +13 -11
- package/dist/sql/editor.d.ts +0 -16
- package/src/sql/editor.ts +0 -75
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import type {PanzoomObject} from '@panzoom/panzoom';
|
|
2
|
+
import type {CodeEditor} from '../codemirror';
|
|
3
|
+
import {mountCodeEditor} from '../codemirror';
|
|
4
|
+
import {IconButton} from '../IconButton';
|
|
5
|
+
import {el, HTMLElementBase} from '../dom';
|
|
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';
|
|
15
|
+
import {mermaidStyles} from './styles';
|
|
16
|
+
import {diagramFileName} from './title';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `<dfk-mermaid>` — a ```mermaid fence rendered as a diagram, produced by
|
|
20
|
+
* `remarkMermaid`.
|
|
21
|
+
*
|
|
22
|
+
* This element *is* the kit's mermaid integration: it replaces
|
|
23
|
+
* `@docusaurus/theme-mermaid`, whose React component cannot avoid the two
|
|
24
|
+
* upstream defects `./render.ts` documents (the dark-mode first-load flash and
|
|
25
|
+
* the empty diagram, both from rendering twice and concurrently). Because the
|
|
26
|
+
* rendering happens here rather than inside a React tree, the same code serves
|
|
27
|
+
* the runnable-SQL `mermaid` output — see the renderer in `sql/renderers.ts`.
|
|
28
|
+
*
|
|
29
|
+
* What a reader gets on top of the diagram:
|
|
30
|
+
*
|
|
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.
|
|
39
|
+
*
|
|
40
|
+
* Everything lives in the shadow root, diagram included: mermaid ships an inline
|
|
41
|
+
* `<style>` inside every SVG it renders, and one shadow root per diagram is what
|
|
42
|
+
* keeps those styles from leaking into the page (and into each other).
|
|
43
|
+
*
|
|
44
|
+
* Retained-mode: the structure is built once in the constructor and held in
|
|
45
|
+
* fields; the `#render*` / `#set*` helpers mutate the nodes they own. The only
|
|
46
|
+
* wholesale replacement is the rendered SVG itself — a new render *is* a new
|
|
47
|
+
* document, the same way a new query result is.
|
|
48
|
+
*
|
|
49
|
+
* 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.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
type MermaidLabels = {
|
|
57
|
+
reset: string;
|
|
58
|
+
fullscreen: string;
|
|
59
|
+
exitFullscreen: string;
|
|
60
|
+
edit: string;
|
|
61
|
+
download: string;
|
|
62
|
+
apply: string;
|
|
63
|
+
cancel: string;
|
|
64
|
+
editTitle: string;
|
|
65
|
+
rendering: string;
|
|
66
|
+
renderFailed: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const LABELS: Record<string, MermaidLabels> = {
|
|
70
|
+
en: {
|
|
71
|
+
reset: 'Reset zoom',
|
|
72
|
+
fullscreen: 'Fullscreen',
|
|
73
|
+
exitFullscreen: 'Exit fullscreen',
|
|
74
|
+
edit: 'Edit diagram source',
|
|
75
|
+
download: 'Download SVG',
|
|
76
|
+
apply: 'Apply',
|
|
77
|
+
cancel: 'Cancel',
|
|
78
|
+
editTitle: 'Mermaid source',
|
|
79
|
+
rendering: 'Rendering diagram…',
|
|
80
|
+
renderFailed: 'Could not render the diagram',
|
|
81
|
+
},
|
|
82
|
+
'zh-hans': {
|
|
83
|
+
reset: '还原缩放',
|
|
84
|
+
fullscreen: '全屏',
|
|
85
|
+
exitFullscreen: '退出全屏',
|
|
86
|
+
edit: '编辑图表源码',
|
|
87
|
+
download: '下载 SVG',
|
|
88
|
+
apply: '应用',
|
|
89
|
+
cancel: '取消',
|
|
90
|
+
editTitle: 'Mermaid 源码',
|
|
91
|
+
rendering: '正在渲染图表…',
|
|
92
|
+
renderFailed: '图表渲染失败',
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
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;
|
|
105
|
+
|
|
106
|
+
export class DfkMermaid extends HTMLElementBase {
|
|
107
|
+
readonly #canvas = el('div', {class: 'dfk-mermaid-canvas', hidden: true});
|
|
108
|
+
/**
|
|
109
|
+
* The panzoom viewport, which also clips: the transform runs on the content
|
|
110
|
+
* box *inside* it, so a zoomed diagram cannot spill over the page.
|
|
111
|
+
*/
|
|
112
|
+
readonly #viewport = el('div', {class: 'dfk-mermaid-viewport'});
|
|
113
|
+
/** The transform target; holds the rendered `<svg>` and nothing else. */
|
|
114
|
+
readonly #content = el('div', {class: 'dfk-mermaid-content'});
|
|
115
|
+
readonly #actions = el('div', {class: 'dfk-mermaid-actions'});
|
|
116
|
+
readonly #message = el('p', {
|
|
117
|
+
class: 'dfk-mermaid-message',
|
|
118
|
+
attrs: {'aria-live': 'polite'},
|
|
119
|
+
hidden: true,
|
|
120
|
+
});
|
|
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',
|
|
132
|
+
});
|
|
133
|
+
readonly #cancelBtn = el('button', {class: 'dfk-mermaid-dialog-button', type: 'button'});
|
|
134
|
+
|
|
135
|
+
#labels: MermaidLabels = LABELS.en;
|
|
136
|
+
#config: DfkMermaidConfig = parseMermaidConfig(null);
|
|
137
|
+
#source = '';
|
|
138
|
+
/**
|
|
139
|
+
* The diagram currently on screen, held as a *node* rather than as mermaid's
|
|
140
|
+
* returned string: the download re-serialises it (`serializeMermaidSvg`), which
|
|
141
|
+
* is the only way to get well-formed SVG out of mermaid's HTML-serialised
|
|
142
|
+
* output. `null` until a diagram renders.
|
|
143
|
+
*/
|
|
144
|
+
#svg: SVGElement | null = null;
|
|
145
|
+
#colorMode: MermaidColorMode | null = null;
|
|
146
|
+
#seeded = false;
|
|
147
|
+
#expanded = false;
|
|
148
|
+
/** Whether the document-level Esc handler is currently attached. */
|
|
149
|
+
#escBound = false;
|
|
150
|
+
/** Invalidates an in-flight render when a newer one starts or the element leaves. */
|
|
151
|
+
#renderToken = 0;
|
|
152
|
+
#panzoom: PanzoomObject | null = null;
|
|
153
|
+
#panzoomLoading = false;
|
|
154
|
+
#editor: CodeEditor | null = null;
|
|
155
|
+
#editorLoading = false;
|
|
156
|
+
#unwatchColorMode: (() => void) | null = null;
|
|
157
|
+
|
|
158
|
+
readonly #onEsc = (event: KeyboardEvent): void => {
|
|
159
|
+
if (event.key === 'Escape' && this.#expanded && !this.#dialog.open) {
|
|
160
|
+
this.#setExpanded(false);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
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
|
+
readonly #onColorModeChange = (): void => {
|
|
179
|
+
if (documentColorMode() !== this.#colorMode) {
|
|
180
|
+
void this.#render();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
constructor() {
|
|
185
|
+
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
|
+
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
|
+
);
|
|
222
|
+
|
|
223
|
+
const shadow = this.attachShadow({mode: 'open'});
|
|
224
|
+
shadow.adoptedStyleSheets = [mermaidStyles()];
|
|
225
|
+
// No slot: nothing is ever handed in as a child (the source travels as an
|
|
226
|
+
// attribute), so the light DOM stays empty and there is nothing to hide.
|
|
227
|
+
shadow.append(this.#canvas, this.#message, this.#dialog);
|
|
228
|
+
this.#applyLabels();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
connectedCallback(): void {
|
|
232
|
+
if (!this.#seeded) {
|
|
233
|
+
this.#seed();
|
|
234
|
+
this.#seeded = true;
|
|
235
|
+
}
|
|
236
|
+
// Registered here rather than in the constructor: a listener on an *external*
|
|
237
|
+
// object has to be paired with a removal, and connect/disconnect is where
|
|
238
|
+
// that pairing is observable (React may remount the element).
|
|
239
|
+
this.#unwatchColorMode ??= watchColorMode(this.#onColorModeChange);
|
|
240
|
+
void this.#ensurePanzoom();
|
|
241
|
+
// Only when there is nothing on screen: a reconnect after a move already
|
|
242
|
+
// carries its diagram, and re-rendering it would flash for no reason.
|
|
243
|
+
if (this.#source && !this.#content.hasChildNodes()) {
|
|
244
|
+
void this.#render();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
disconnectedCallback(): void {
|
|
249
|
+
this.#setExpanded(false);
|
|
250
|
+
this.#unwatchColorMode?.();
|
|
251
|
+
this.#unwatchColorMode = null;
|
|
252
|
+
// Panzoom binds move/up on `document`, so it outlives the element unless it
|
|
253
|
+
// 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');
|
|
259
|
+
this.#renderToken += 1;
|
|
260
|
+
this.#editor?.destroy();
|
|
261
|
+
this.#editor = null;
|
|
262
|
+
if (this.#dialog.open) {
|
|
263
|
+
this.#dialog.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
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.
|
|
281
|
+
*/
|
|
282
|
+
#seed(): void {
|
|
283
|
+
this.#labels =
|
|
284
|
+
LABELS[(document.documentElement.getAttribute('lang') ?? 'en').toLowerCase()] ??
|
|
285
|
+
LABELS.en;
|
|
286
|
+
const source = this.getAttribute('source');
|
|
287
|
+
if (source !== null) {
|
|
288
|
+
this.#source = source;
|
|
289
|
+
}
|
|
290
|
+
this.#config = parseMermaidConfig(this.getAttribute('config'));
|
|
291
|
+
this.#applyLabels();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#applyLabels(): void {
|
|
295
|
+
this.#resetBtn.setLabel(this.#labels.reset);
|
|
296
|
+
this.#editBtn.setLabel(this.#labels.edit);
|
|
297
|
+
this.#downloadBtn.setLabel(this.#labels.download);
|
|
298
|
+
this.#fullscreenBtn.setLabel(
|
|
299
|
+
this.#expanded ? this.#labels.exitFullscreen : this.#labels.fullscreen,
|
|
300
|
+
);
|
|
301
|
+
this.#dialogTitle.textContent = this.#labels.editTitle;
|
|
302
|
+
this.#cancelBtn.textContent = this.#labels.cancel;
|
|
303
|
+
this.#applyBtn.textContent = this.#labels.apply;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// --- Rendering -------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Renders the source for the page's current colour mode and puts the result on
|
|
310
|
+
* screen. Re-entrancy is handled by {@link #renderToken}: a render that was
|
|
311
|
+
* superseded (a newer one started, or the element was disconnected) drops its
|
|
312
|
+
* result instead of racing the newer one into the DOM.
|
|
313
|
+
*
|
|
314
|
+
* The queue inside `render.ts` is what makes this safe at all — mermaid is one
|
|
315
|
+
* mutable singleton, so two diagrams rendering at once corrupt each other.
|
|
316
|
+
*/
|
|
317
|
+
async #render(): Promise<void> {
|
|
318
|
+
if (!this.#source) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const token = (this.#renderToken += 1);
|
|
322
|
+
const colorMode = documentColorMode();
|
|
323
|
+
this.#colorMode = colorMode;
|
|
324
|
+
// A re-render keeps the old diagram up while the new one is in flight, so the
|
|
325
|
+
// status line is only for the first paint (or for an error that left nothing).
|
|
326
|
+
if (this.#svg === null) {
|
|
327
|
+
this.#setMessage(this.#labels.rendering, false);
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const output = await renderMermaid({
|
|
331
|
+
source: this.#source,
|
|
332
|
+
config: this.#config,
|
|
333
|
+
colorMode,
|
|
334
|
+
});
|
|
335
|
+
if (token !== this.#renderToken || !this.isConnected) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const svg = parseMermaidSvg(this.ownerDocument, output.svg);
|
|
339
|
+
if (!svg) {
|
|
340
|
+
throw new Error(this.#labels.renderFailed);
|
|
341
|
+
}
|
|
342
|
+
this.#svg = svg;
|
|
343
|
+
this.#content.replaceChildren(svg);
|
|
344
|
+
// Mermaid's own hook for click handlers on nodes; it takes the container
|
|
345
|
+
// that holds the SVG.
|
|
346
|
+
output.bind?.(this.#content);
|
|
347
|
+
this.#canvas.hidden = false;
|
|
348
|
+
this.#setMessage('', false);
|
|
349
|
+
this.#setActionsAvailable(true);
|
|
350
|
+
this.#resetView();
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (token !== this.#renderToken || !this.isConnected) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this.#svg = null;
|
|
356
|
+
this.#content.replaceChildren();
|
|
357
|
+
this.#canvas.hidden = true;
|
|
358
|
+
this.#setActionsAvailable(false);
|
|
359
|
+
this.#setMessage(`${this.#labels.renderFailed}: ${messageOf(error)}`, true);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#setMessage(text: string, isError: boolean): void {
|
|
364
|
+
this.#message.textContent = text;
|
|
365
|
+
this.#message.hidden = text === '';
|
|
366
|
+
this.#message.classList.toggle('dfk-mermaid-message-error', isError);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Reset zoom and download only mean something once a diagram is on screen. */
|
|
370
|
+
#setActionsAvailable(available: boolean): void {
|
|
371
|
+
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;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
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
|
+
// --- Fullscreen ------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
#setExpanded(value: boolean): void {
|
|
460
|
+
this.#expanded = value;
|
|
461
|
+
this.#canvas.classList.toggle('dfk-mermaid-expanded', value);
|
|
462
|
+
this.#fullscreenBtn.setIcon(value ? 'lucide:minimize' : 'lucide:maximize');
|
|
463
|
+
this.#applyLabels();
|
|
464
|
+
if (value !== this.#escBound) {
|
|
465
|
+
if (value) {
|
|
466
|
+
document.addEventListener('keydown', this.#onEsc);
|
|
467
|
+
} else {
|
|
468
|
+
document.removeEventListener('keydown', this.#onEsc);
|
|
469
|
+
}
|
|
470
|
+
this.#escBound = value;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// --- Download --------------------------------------------------------------
|
|
475
|
+
|
|
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
|
+
*/
|
|
483
|
+
#download(): void {
|
|
484
|
+
if (this.#svg === null) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
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
|
+
}
|
|
500
|
+
|
|
501
|
+
// --- Source editing --------------------------------------------------------
|
|
502
|
+
|
|
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
|
+
*/
|
|
508
|
+
#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;
|
|
546
|
+
void this.#render();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function messageOf(error: unknown): string {
|
|
552
|
+
return error instanceof Error ? error.message : String(error);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function prefersReducedMotion(): boolean {
|
|
556
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
557
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mermaid look and palette, shared by the Node-side remark plugin (which
|
|
3
|
+
* stamps it onto every `<dfk-mermaid>` it emits) and the browser element (whose
|
|
4
|
+
* fallback when no config travels with the element).
|
|
5
|
+
*
|
|
6
|
+
* Pure data with no imports, like `sql/runtimeConfig.ts`: both sides have to
|
|
7
|
+
* agree on the shape, and neither may drag the other into its bundle — the
|
|
8
|
+
* remark plugin runs in Docusaurus' Node build, the element in the browser.
|
|
9
|
+
*
|
|
10
|
+
* Why the config travels per element instead of living in the component: the
|
|
11
|
+
* palette is the one part of a diagram that is *site-specific*. Passing it
|
|
12
|
+
* through the site's `docusaurus.config.ts` (as an option of `remarkMermaid`)
|
|
13
|
+
* keeps that choice where the rest of the site's theme is decided, and keeps a
|
|
14
|
+
* downstream docs site from having to fork the kit to change two colour names.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The two colour modes a diagram is rendered for. */
|
|
18
|
+
export type MermaidColorMode = 'light' | 'dark';
|
|
19
|
+
|
|
20
|
+
/** A partial config, as a site writes it. */
|
|
21
|
+
export interface DfkMermaidConfigInput {
|
|
22
|
+
/** Mermaid theme name per colour mode; missing sides fall back to the default. */
|
|
23
|
+
theme?: Partial<Record<MermaidColorMode, string>>;
|
|
24
|
+
/** Extra mermaid options, spread into `initialize()` after `theme`. */
|
|
25
|
+
options?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A resolved config: both themes present, options ready to spread. */
|
|
29
|
+
export interface DfkMermaidConfig {
|
|
30
|
+
theme: Record<MermaidColorMode, string>;
|
|
31
|
+
options: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The kit's default: the `neo` look (mermaid's flatter, rounder chrome) with the
|
|
36
|
+
* redux palette — `redux-color` in light mode, `redux-dark-color` in dark.
|
|
37
|
+
*
|
|
38
|
+
* `look` has no per-mode counterpart in mermaid, so it belongs in `options`;
|
|
39
|
+
* `theme` is the per-mode one. Both are easy to get wrong *silently*: mermaid
|
|
40
|
+
* ignores an unrecognised value and falls back, so a change is verified in a
|
|
41
|
+
* browser, not by a build.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_MERMAID_CONFIG: DfkMermaidConfig = {
|
|
44
|
+
theme: {light: 'redux-color', dark: 'redux-dark-color'},
|
|
45
|
+
options: {look: 'neo'},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Merges a site's overrides over {@link DEFAULT_MERMAID_CONFIG}. */
|
|
49
|
+
export function resolveMermaidConfig(input?: DfkMermaidConfigInput | null): DfkMermaidConfig {
|
|
50
|
+
return {
|
|
51
|
+
theme: {
|
|
52
|
+
light: input?.theme?.light ?? DEFAULT_MERMAID_CONFIG.theme.light,
|
|
53
|
+
dark: input?.theme?.dark ?? DEFAULT_MERMAID_CONFIG.theme.dark,
|
|
54
|
+
},
|
|
55
|
+
options: {...DEFAULT_MERMAID_CONFIG.options, ...input?.options},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parses the `config` attribute the remark plugin writes. Anything unreadable
|
|
61
|
+
* falls back to the default rather than failing a diagram, and only the two
|
|
62
|
+
* known keys are read — the attribute is content, not a channel for arbitrary
|
|
63
|
+
* configuration.
|
|
64
|
+
*/
|
|
65
|
+
export function parseMermaidConfig(raw: string | null | undefined): DfkMermaidConfig {
|
|
66
|
+
if (!raw) {
|
|
67
|
+
return resolveMermaidConfig();
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return resolveMermaidConfig(JSON.parse(raw) as DfkMermaidConfigInput);
|
|
71
|
+
} catch {
|
|
72
|
+
return resolveMermaidConfig();
|
|
73
|
+
}
|
|
74
|
+
}
|