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.
- package/AGENTS.md +326 -234
- package/README.md +10 -6
- package/dist/IconButton.d.ts +22 -0
- package/dist/codemirror.d.ts +29 -0
- package/dist/download.d.ts +51 -0
- package/dist/index.d.ts +28 -7
- package/dist/index.js +2 -2
- package/dist/mermaid/DfkMermaid.d.ts +25 -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 +92 -0
- package/dist/mermaid/styles.d.ts +6 -0
- package/dist/panzoom-view.d.ts +17 -0
- package/dist/{register-CALCwFBv.js → register-wdwf0LC4.js} +1365 -738
- package/dist/remark.d.ts +1 -1
- 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/remark.d.ts +6 -1
- package/dist/sql/renderers.d.ts +9 -0
- package/package.json +6 -2
- package/src/IconButton.ts +50 -0
- package/src/codemirror.ts +97 -0
- package/src/download.ts +169 -0
- package/src/index.ts +31 -7
- package/src/mermaid/DfkMermaid.css +326 -0
- package/src/mermaid/DfkMermaid.ts +411 -0
- package/src/mermaid/config.ts +74 -0
- package/src/mermaid/remark.ts +98 -0
- package/src/mermaid/render.ts +172 -0
- package/src/mermaid/styles.ts +24 -0
- package/src/panzoom-view.ts +235 -0
- package/src/register.ts +3 -0
- package/src/remark.ts +1 -1
- package/src/source-dialog.ts +126 -0
- package/src/sql/DfkSql.css +13 -10
- package/src/sql/DfkSql.ts +60 -51
- package/src/sql/PreviewTabs.ts +98 -52
- package/src/sql/SvgViewer.ts +155 -0
- package/src/sql/remark.ts +6 -1
- package/src/sql/renderers.ts +433 -159
- package/src/sql/sql.css +189 -19
- package/dist/sql/editor.d.ts +0 -16
- 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.
|
|
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
|
+
}
|
package/src/sql/DfkSql.css
CHANGED
|
@@ -159,10 +159,13 @@
|
|
|
159
159
|
|
|
160
160
|
/* --- Icon buttons and their tooltips ------------------------------------- */
|
|
161
161
|
|
|
162
|
-
/*
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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-
|
|
182
|
-
.dfk-
|
|
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-
|
|
190
|
+
.dfk-icon-button:disabled {
|
|
188
191
|
opacity: 0.45;
|
|
189
192
|
cursor: progress;
|
|
190
193
|
}
|
|
191
194
|
|
|
192
|
-
.dfk-
|
|
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-
|
|
200
|
+
.dfk-icon-on {
|
|
198
201
|
color: var(--ifm-color-primary, #14459b);
|
|
199
202
|
}
|
|
200
203
|
|
|
201
|
-
.dfk-
|
|
204
|
+
.dfk-icon {
|
|
202
205
|
font-size: 1rem;
|
|
203
206
|
}
|
|
204
207
|
|