@matfsw/docusaurus-plantuml-plugin 1.3.0 → 1.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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Deep links into diagrams: `#graph?highlight-node=<TARGET>`.
3
+ *
4
+ * Every diagram on the page reacts to the hash — none of them needs an id of its own, and a
5
+ * diagram that does not contain the target simply does nothing. The matcher below is a
6
+ * ladder: the first level that yields a match wins, so a deterministic, author-chosen ID
7
+ * always beats the loose text matching that exists for diagrams nobody annotated.
8
+ *
9
+ * Everything here is DOM-in, elements-out and free of React, which is what makes the ladder
10
+ * testable against synthetic SVG.
11
+ */
12
+ /** The hash prefix that separates diagram deep links from ordinary heading anchors. */
13
+ const HASH_PREFIX = '#graph?';
14
+ /** The query parameter carrying the node identifier. */
15
+ const NODE_PARAM = 'highlight-node';
16
+ /**
17
+ * Extracts the deeplink target from a location hash.
18
+ *
19
+ * Returns the decoded identifier — `%0A` becomes a real newline, so multiline labels can be
20
+ * addressed — or `null` when the hash is absent, not diagram-shaped, or names no node.
21
+ */
22
+ export function parseDiagramHash(hash) {
23
+ if (!hash.startsWith(HASH_PREFIX))
24
+ return null;
25
+ const target = new URLSearchParams(hash.slice(HASH_PREFIX.length)).get(NODE_PARAM);
26
+ return target === null || target.trim() === '' ? null : target;
27
+ }
28
+ /** Escapes a value for use inside a double-quoted CSS attribute selector. */
29
+ function escapeAttributeValue(value) {
30
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
31
+ }
32
+ /** The target of an `<a>` element, whichever spelling the engine used. */
33
+ function hrefOf(anchor) {
34
+ return anchor.getAttribute('href') ?? anchor.getAttribute('xlink:href');
35
+ }
36
+ /** Whether every line of the window contains its target line, case-insensitively. */
37
+ function windowMatches(texts, start, lines) {
38
+ return lines.every((line, index) => (texts[start + index]?.textContent ?? '').toLowerCase().includes(line));
39
+ }
40
+ /**
41
+ * Finds the node a deeplink target refers to inside one rendered diagram.
42
+ *
43
+ * The ladder, from most to least deterministic:
44
+ *
45
+ * 1. **Explicit id** — an element whose `id` equals the target. Graphviz emits these for
46
+ * `node [id="…"]`, giving a hidden, deterministic handle. (PlantUML's own `id`s are
47
+ * generated — `ent0001` — and deliberately not part of the contract.)
48
+ * 2. **PlantUML alias** — the engine writes every entity's alias into
49
+ * `data-qualified-name` on its `g.entity` group, so `component "X" as REACTION_1234`
50
+ * and `note "…" as REACTION_1234` are addressable by an id that never appears in the
51
+ * picture. This is PlantUML's spelling of a hidden deterministic id.
52
+ * 3. **Self-anchor** — an `<a>` whose href's hash parses to the same target, which doubles
53
+ * as the node's own permalink. Graphviz emits these for `node [URL="#graph?…"]`; the
54
+ * bundled PlantUML engine currently emits no anchors at all, so for PlantUML this level
55
+ * is future-proofing rather than an authoring channel.
56
+ * 4. **Graphviz node name** — the `<title>` Graphviz writes into every `g.node`, so plain
57
+ * DOT node names work with no annotation at all.
58
+ * 5. **Multiline label** — a target containing newlines (`%0A` in the URL) matches that many
59
+ * *consecutive* text lines, each containing its line of the target. The window stays
60
+ * inside one node's group where groups exist (Graphviz `g.node`, PlantUML `g.entity`)
61
+ * and runs over the document order otherwise — which is how a two-line label is told
62
+ * apart from two nodes that each contain one of the lines.
63
+ * 6. **Single-line substring** — the first text line containing the target,
64
+ * case-insensitively. This is what lets the unique half of a label (`12345` of
65
+ * `caminus-process-archive\n12345`) address the node without spelling out the rest.
66
+ */
67
+ export function findDeeplinkTarget(root, target) {
68
+ // A target with a newline is a multiline label, never an id or alias — and selector
69
+ // engines reject the literal newline outright.
70
+ if (!target.includes('\n')) {
71
+ // 1. Explicit id. An attribute selector rather than #…: ids here are author-chosen and
72
+ // may start with a digit or contain characters a bare id selector would reject.
73
+ const byId = root.querySelector(`[id="${escapeAttributeValue(target)}"]`);
74
+ if (byId)
75
+ return { elements: [byId], anchor: byId };
76
+ // 2. PlantUML alias.
77
+ const byAlias = root.querySelector(`[data-qualified-name="${escapeAttributeValue(target)}"]`);
78
+ if (byAlias)
79
+ return { elements: [byAlias], anchor: byAlias };
80
+ }
81
+ // 3. Self-anchor.
82
+ for (const anchor of Array.from(root.querySelectorAll('a'))) {
83
+ const href = hrefOf(anchor);
84
+ const hashIndex = href?.indexOf('#') ?? -1;
85
+ if (href !== null && hashIndex >= 0 && parseDiagramHash(href.slice(hashIndex)) === target) {
86
+ return { elements: [anchor], anchor };
87
+ }
88
+ }
89
+ // 4. Graphviz node name.
90
+ for (const title of Array.from(root.querySelectorAll('g.node > title'))) {
91
+ const node = title.parentElement;
92
+ if (node && (title.textContent ?? '').trim() === target) {
93
+ return { elements: [node], anchor: node };
94
+ }
95
+ }
96
+ const lines = target
97
+ .split('\n')
98
+ .map((line) => line.trim().toLowerCase())
99
+ .filter((line) => line !== '');
100
+ if (lines.length === 0)
101
+ return null;
102
+ // 5. Multiline label.
103
+ if (lines.length > 1) {
104
+ const groups = Array.from(root.querySelectorAll('g.node, g.entity'));
105
+ const scopes = groups.length > 0 ? groups : [root];
106
+ for (const scope of scopes) {
107
+ const texts = Array.from(scope.querySelectorAll('text'));
108
+ for (let start = 0; start + lines.length <= texts.length; start += 1) {
109
+ if (windowMatches(texts, start, lines)) {
110
+ const window = texts.slice(start, start + lines.length);
111
+ return { elements: window, anchor: window[0] };
112
+ }
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+ // 6. Single-line substring.
118
+ const needle = lines[0];
119
+ for (const text of Array.from(root.querySelectorAll('text'))) {
120
+ if ((text.textContent ?? '').toLowerCase().includes(needle)) {
121
+ return { elements: [text], anchor: text };
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ /**
127
+ * Scrolls the page to the first figure that claims a given deeplink navigation — and only
128
+ * that one.
129
+ *
130
+ * Diagrams evaluate the hash independently, so without an arbiter every matching figure
131
+ * would fight over the scroll position. The caller keys the claim on one *navigation* to
132
+ * one target (history entry key + pathname + target): the many diagrams reacting to a
133
+ * single navigation yield a single scroll, while following the same deep link again — a new
134
+ * history entry, unchanged hash — scrolls again. Keying on the target alone got that second
135
+ * follow wrong: the stale claim swallowed the scroll. On a page whose diagrams were
136
+ * force-rendered by the hash, renders complete in mount order, so the first claimant is in
137
+ * practice the first matching figure in the document.
138
+ */
139
+ let scrolledKey = null;
140
+ export function claimDeeplinkScroll(claimKey, figure) {
141
+ if (scrolledKey === claimKey)
142
+ return;
143
+ scrolledKey = claimKey;
144
+ figure.scrollIntoView({ block: 'center' });
145
+ }
146
+ /** Test-only: lets each test start with the scroll unclaimed. */
147
+ export function resetDeeplinkScroll() {
148
+ scrolledKey = null;
149
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The toolbar's icons, drawn rather than typed.
3
+ *
4
+ * These were symbol characters until 1.3.1, and one of them was invisible to a large group of
5
+ * readers: `⛶` U+26F6 SQUARE FOUR CORNERS has no glyph in any font that ships with a stock
6
+ * Linux desktop, so the maximize control rendered as a tofu box — on the demo site included.
7
+ * The other four only worked because DejaVu Sans happens to be installed, which a minimal
8
+ * container image or a locked-down corporate desktop does not promise. See issue #21.
9
+ *
10
+ * Two details keep the existing styling working with no CSS change at all:
11
+ *
12
+ * - `currentColor` inherits `.toolbarButton`'s `color: var(--ifm-font-color-base)`, so both
13
+ * colour modes keep working exactly as they did.
14
+ * - `1em` inherits its `font-size: 1rem`, so the icons size with the button — and with a
15
+ * reader who has scaled their font up — the way the characters used to.
16
+ *
17
+ * Every icon is `aria-hidden`: each `<button>` already carries an `aria-label`, which is the
18
+ * accessible name. Nothing here is announced.
19
+ */
20
+ import type { ReactElement } from 'react';
21
+ /** Replaces `−` U+2212. */
22
+ export declare function ZoomOutIcon(): ReactElement;
23
+ /** Replaces `+`. */
24
+ export declare function ZoomInIcon(): ReactElement;
25
+ /** Replaces `⟲` U+27F2: a circular arrow, open at the top right where the arrowhead sits. */
26
+ export declare function ResetZoomIcon(): ReactElement;
27
+ /**
28
+ * Fit to view: the maximize corners with the diagram drawn inside them, so the two controls
29
+ * read as siblings — both are about the frame, one keeps you in the page.
30
+ */
31
+ export declare function FitIcon(): ReactElement;
32
+ /** Replaces `⛶` U+26F6, the character that had no glyph. */
33
+ export declare function MaximizeIcon(): ReactElement;
34
+ /** Replaces `✕` U+2715, shown in place of maximize while the diagram fills the viewport. */
35
+ export declare function CloseIcon(): ReactElement;
36
+ /** The search toggle: a lens. */
37
+ export declare function SearchIcon(): ReactElement;
38
+ /** Previous search match. */
39
+ export declare function ChevronUpIcon(): ReactElement;
40
+ /** Next search match. */
41
+ export declare function ChevronDownIcon(): ReactElement;
42
+ /** The minimap toggle: a map frame with the viewport rectangle marked inside it. */
43
+ export declare function MinimapIcon(): ReactElement;
44
+ /**
45
+ * Replaces the literal `</>` of the source toggle.
46
+ *
47
+ * ASCII, so it never had the glyph problem — but left as text it would be the one control in
48
+ * the row still drawn in the page font, at a different weight and optical size from its
49
+ * neighbours, and moving with the reader's font settings while they stayed put.
50
+ */
51
+ export declare function SourceIcon(): ReactElement;
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const iconProps = {
3
+ viewBox: '0 0 16 16',
4
+ width: '1em',
5
+ height: '1em',
6
+ fill: 'none',
7
+ stroke: 'currentColor',
8
+ strokeWidth: 1.5,
9
+ strokeLinecap: 'round',
10
+ strokeLinejoin: 'round',
11
+ 'aria-hidden': true,
12
+ // Keeps the SVG out of the tab order in browsers that made it focusable by default.
13
+ focusable: false,
14
+ };
15
+ /** Replaces `−` U+2212. */
16
+ export function ZoomOutIcon() {
17
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M3 8h10" }) }));
18
+ }
19
+ /** Replaces `+`. */
20
+ export function ZoomInIcon() {
21
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M8 3v10M3 8h10" }) }));
22
+ }
23
+ /** Replaces `⟲` U+27F2: a circular arrow, open at the top right where the arrowhead sits. */
24
+ export function ResetZoomIcon() {
25
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M13 8A5 5 0 1 1 11.4 4.3" }), _jsx("path", { d: "M13 2v3h-3" })] }));
26
+ }
27
+ /**
28
+ * Fit to view: the maximize corners with the diagram drawn inside them, so the two controls
29
+ * read as siblings — both are about the frame, one keeps you in the page.
30
+ */
31
+ export function FitIcon() {
32
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" }), _jsx("rect", { x: "5.5", y: "5.5", width: "5", height: "5", rx: "1" })] }));
33
+ }
34
+ /** Replaces `⛶` U+26F6, the character that had no glyph. */
35
+ export function MaximizeIcon() {
36
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" }) }));
37
+ }
38
+ /** Replaces `✕` U+2715, shown in place of maximize while the diagram fills the viewport. */
39
+ export function CloseIcon() {
40
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) }));
41
+ }
42
+ /** The search toggle: a lens. */
43
+ export function SearchIcon() {
44
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("circle", { cx: "7", cy: "7", r: "4" }), _jsx("path", { d: "M10 10l4 4" })] }));
45
+ }
46
+ /** Previous search match. */
47
+ export function ChevronUpIcon() {
48
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M4 10l4-4 4 4" }) }));
49
+ }
50
+ /** Next search match. */
51
+ export function ChevronDownIcon() {
52
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M4 6l4 4 4-4" }) }));
53
+ }
54
+ /** The minimap toggle: a map frame with the viewport rectangle marked inside it. */
55
+ export function MinimapIcon() {
56
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("rect", { x: "2", y: "3", width: "12", height: "10", rx: "1" }), _jsx("rect", { x: "4.5", y: "7.5", width: "4", height: "3", fill: "currentColor", stroke: "none" })] }));
57
+ }
58
+ /**
59
+ * Replaces the literal `</>` of the source toggle.
60
+ *
61
+ * ASCII, so it never had the glyph problem — but left as text it would be the one control in
62
+ * the row still drawn in the page font, at a different weight and optical size from its
63
+ * neighbours, and moving with the reader's font settings while they stayed put.
64
+ */
65
+ export function SourceIcon() {
66
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M6 4L2 8l4 4M10 4l4 4-4 4" }) }));
67
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useId, useRef, useState } from 'react';
2
+ import { useCallback, useEffect, useId, useMemo, useRef, useState, } from 'react';
3
+ import { useLocation } from '@docusaurus/router';
3
4
  import { useColorMode } from '@docusaurus/theme-common';
4
5
  import { DATA_ATTR } from '../../constants.js';
5
6
  import { getSharedCache } from '../../runtime/cache.js';
@@ -7,6 +8,11 @@ import { PlantUmlError } from '../../runtime/errors.js';
7
8
  import { renderGraphvizDiagram } from '../../runtime/graphvizRenderer.js';
8
9
  import { renderDiagram } from '../../runtime/renderer.js';
9
10
  import { usePlantUmlConfig } from '../usePlantUmlConfig.js';
11
+ import { ChevronDownIcon, ChevronUpIcon, CloseIcon, FitIcon, MaximizeIcon, MinimapIcon, ResetZoomIcon, SearchIcon, SourceIcon, ZoomInIcon, ZoomOutIcon, } from './icons.js';
12
+ import { parseDiagramHash } from './deeplink.js';
13
+ import Minimap from './Minimap.js';
14
+ import { useDiagramDeeplink } from './useDiagramDeeplink.js';
15
+ import { useDiagramSearch } from './useDiagramSearch.js';
10
16
  import { useZoomPan } from './useZoomPan.js';
11
17
  import styles from './styles.module.css';
12
18
  /**
@@ -49,6 +55,9 @@ function messageFor(error) {
49
55
  export default function PlantUmlDiagram({ source, title, language, engine = 'plantuml', layout, zoom: zoomProp, showSource: showSourceProp, }) {
50
56
  const config = usePlantUmlConfig();
51
57
  const { colorMode } = useColorMode();
58
+ // The router, not `window.location`: `<Link>` navigations are `history.pushState` calls
59
+ // that fire no DOM event, so only the router sees every way the hash can change.
60
+ const routerLocation = useLocation();
52
61
  const containerRef = useRef(null);
53
62
  const hintId = useId();
54
63
  const sourcePanelId = useId();
@@ -67,8 +76,12 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
67
76
  const interactive = zoomProp ?? config?.options.zoom ?? true;
68
77
  const sourceAvailable = showSourceProp ?? config?.options.showSource ?? true;
69
78
  const [state, setState] = useState(INITIAL_STATE);
70
- const [inView, setInView] = useState(!lazy);
79
+ // A diagram deep link must be able to reach a diagram below the fold, so a `#graph?…`
80
+ // hash defeats lazy rendering. The router's server-side location has no hash, and the
81
+ // first paint is the placeholder either way, so hydration cannot mismatch.
82
+ const [inView, setInView] = useState(() => !lazy || parseDiagramHash(routerLocation.hash) !== null);
71
83
  const [sourceOpen, setSourceOpen] = useState(false);
84
+ const [minimapOpen, setMinimapOpen] = useState(false);
72
85
  const [copyState, setCopyState] = useState('idle');
73
86
  const copyTimer = useRef(undefined);
74
87
  // A pending "Copied" message must not fire into an unmounted component.
@@ -106,6 +119,48 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
106
119
  // gratuitous jump.
107
120
  resetKey: `${source}|${engine}|${resolvedLayout}|${renderDark ? 'dark' : 'light'}`,
108
121
  });
122
+ const search = useDiagramSearch({
123
+ enabled: interactive && state.status === 'ready',
124
+ zoom,
125
+ svg: state.svg,
126
+ });
127
+ useDiagramDeeplink({
128
+ ready: state.status === 'ready' && state.svg !== null,
129
+ svg: state.svg,
130
+ interactive,
131
+ zoom,
132
+ containerRef,
133
+ });
134
+ // A hash arriving *after* mount must defeat lazy rendering too — a deep link followed
135
+ // from another diagram on the same page may point below the fold. Driven by the router
136
+ // location rather than `hashchange`, which `<Link>` navigations never fire.
137
+ useEffect(() => {
138
+ if (!inView && parseDiagramHash(routerLocation.hash) !== null)
139
+ setInView(true);
140
+ }, [inView, routerLocation.hash]);
141
+ const searchToggleRef = useRef(null);
142
+ // Escape and the ✕ hand focus back to the toggle, so the keyboard is not left stranded on
143
+ // a control that just left the page.
144
+ const closeSearch = useCallback(() => {
145
+ search.close();
146
+ searchToggleRef.current?.focus();
147
+ }, [search]);
148
+ const onSearchKeyDown = useCallback((event) => {
149
+ if (event.key === 'Enter') {
150
+ event.preventDefault();
151
+ if (event.shiftKey)
152
+ search.previous();
153
+ else
154
+ search.next();
155
+ }
156
+ else if (event.key === 'Escape') {
157
+ event.preventDefault();
158
+ // Escape in the search closes the search. Without this, the document-level listener
159
+ // would also un-maximize the diagram in the same keystroke.
160
+ event.stopPropagation();
161
+ closeSearch();
162
+ }
163
+ }, [closeSearch, search]);
109
164
  useEffect(() => {
110
165
  if (!lazy || inView)
111
166
  return undefined;
@@ -200,13 +255,20 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
200
255
  ]);
201
256
  const label = title ?? `${engineName} diagram`;
202
257
  const busy = state.status === 'loading' || state.status === 'rendering';
258
+ /**
259
+ * One wrapper object per SVG string, not a fresh literal in the JSX: React 19 compares
260
+ * `dangerouslySetInnerHTML` by the wrapper's identity, not by `__html`, so an inline
261
+ * literal makes every re-render of the figure re-parse the whole SVG subtree — visibly
262
+ * so once anything marks the rendered elements, as the search's highlights do.
263
+ */
264
+ const svgHtml = useMemo(() => (state.svg === null ? null : { __html: state.svg }), [state.svg]);
203
265
  // The sanitized SVG always lives in this element, zoomable or not, so `role="img" > svg`
204
266
  // stays a stable contract for tests and for author CSS.
205
- const canvas = state.svg !== null && (_jsx("div", { className: !interactive && sourceOpen ? `${styles.canvas} ${styles.hiddenView}` : styles.canvas, role: "img", "aria-label": label,
267
+ const canvas = state.svg !== null && svgHtml !== null && (_jsx("div", { className: !interactive && sourceOpen ? `${styles.canvas} ${styles.hiddenView}` : styles.canvas, role: "img", "aria-label": label,
206
268
  // Sanitized above unless `sanitizeSvg: false` was explicitly opted into.
207
- dangerouslySetInnerHTML: { __html: state.svg } }));
269
+ dangerouslySetInnerHTML: svgHtml }));
208
270
  const copyControl = sourceAvailable && sourceOpen && state.status === 'ready' && (_jsx("button", { type: "button", className: `${styles.toolbarButton} ${styles.toolbarTextButton}`, "aria-label": `Copy ${engineName} source to clipboard`, onClick: copySource, children: "Copy" }));
209
- const sourceToggle = sourceAvailable && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": sourceOpen ? 'Hide diagram source' : 'Show diagram source', "aria-expanded": sourceOpen, "aria-controls": sourcePanelId, onClick: () => setSourceOpen((open) => !open), children: _jsx("span", { "aria-hidden": "true", children: '</>' }) }));
271
+ const sourceToggle = sourceAvailable && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": sourceOpen ? 'Hide diagram source' : 'Show diagram source', "aria-expanded": sourceOpen, "aria-controls": sourcePanelId, onClick: () => setSourceOpen((open) => !open), children: _jsx(SourceIcon, {}) }));
210
272
  /**
211
273
  * The source *replaces the diagram in its own frame* rather than appearing beneath it.
212
274
  *
@@ -224,7 +286,11 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
224
286
  [DATA_ATTR.theme]: dark ? 'dark' : 'light',
225
287
  ...(interactive ? { [DATA_ATTR.interactive]: 'true' } : {}),
226
288
  ...(zoom.maximized ? { [DATA_ATTR.maximized]: 'true' } : {}),
227
- ...(sourceOpen ? { [DATA_ATTR.sourceOpen]: 'true' } : {}), children: [state.status === 'ready' && state.svg !== null && !interactive && canvas, state.status === 'ready' && state.svg !== null && !interactive && sourceView, state.status === 'ready' && state.svg !== null && !interactive && sourceAvailable && (_jsxs("div", { className: styles.plainToolbar, role: "group", "aria-label": `${label} source controls`, children: [sourceToggle, copyControl] })), state.status === 'ready' && state.svg !== null && interactive && (_jsxs("div", { ref: zoom.stageRef, className: zoom.maximized ? `${styles.stage} ${styles.maximized}` : styles.stage, children: [_jsxs("div", { className: styles.stageBody, children: [_jsx("div", { ref: zoom.viewportRef, className: sourceOpen ? `${styles.viewport} ${styles.invisibleView}` : styles.viewport, tabIndex: sourceOpen ? -1 : 0, "aria-describedby": hintId, "aria-keyshortcuts": "Plus Minus 0 ArrowUp ArrowDown ArrowLeft ArrowRight", onKeyDown: zoom.onKeyDown, [DATA_ATTR.zoom]: '1', children: _jsx("div", { ref: zoom.layerRef, className: styles.layer, children: canvas }) }), sourceView] }), _jsxs("div", { className: styles.toolbar, role: "group", "aria-label": `${label} zoom controls`, children: [!sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom out", onClick: zoom.zoomOut, children: _jsx("span", { "aria-hidden": "true", children: "\u2212" }) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom in", onClick: zoom.zoomIn, children: _jsx("span", { "aria-hidden": "true", children: "+" }) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Reset zoom", onClick: zoom.reset, children: _jsx("span", { "aria-hidden": "true", children: "\u27F2" }) })), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Maximize diagram", "aria-pressed": zoom.maximized, onClick: zoom.toggleMaximize, children: _jsx("span", { "aria-hidden": "true", children: zoom.maximized ? '✕' : '⛶' }) }), sourceToggle, copyControl, _jsx("span", { ref: zoom.readoutRef, className: sourceOpen ? `${styles.readout} ${styles.hiddenView}` : styles.readout, "aria-hidden": "true", children: "100%" })] })] })), state.status === 'ready' && state.svg !== null && interactive && (_jsx("p", { id: hintId, className: styles.visuallyHidden, children: "Zoomable diagram. Use the zoom controls, or press plus and minus to zoom, the arrow keys to pan, and zero to reset." })), state.status !== 'ready' && state.status !== 'error' && (
289
+ ...(sourceOpen ? { [DATA_ATTR.sourceOpen]: 'true' } : {}),
290
+ ...(minimapOpen ? { [DATA_ATTR.minimapOpen]: 'true' } : {}),
291
+ ...(search.open ? { [DATA_ATTR.searchOpen]: 'true' } : {}), children: [state.status === 'ready' && state.svg !== null && !interactive && canvas, state.status === 'ready' && state.svg !== null && !interactive && sourceView, state.status === 'ready' && state.svg !== null && !interactive && sourceAvailable && (_jsxs("div", { className: styles.plainToolbar, role: "group", "aria-label": `${label} source controls`, children: [sourceToggle, copyControl] })), state.status === 'ready' && state.svg !== null && interactive && (_jsxs("div", { ref: zoom.stageRef, className: zoom.maximized ? `${styles.stage} ${styles.maximized}` : styles.stage, children: [_jsxs("div", { className: styles.stageBody, children: [_jsx("div", { ref: zoom.viewportRef, className: sourceOpen ? `${styles.viewport} ${styles.invisibleView}` : styles.viewport, tabIndex: sourceOpen ? -1 : 0, "aria-describedby": hintId, "aria-keyshortcuts": "Plus Minus 0 ArrowUp ArrowDown ArrowLeft ArrowRight", onKeyDown: zoom.onKeyDown, [DATA_ATTR.zoom]: '1', children: _jsx("div", { ref: zoom.layerRef, className: styles.layer, children: canvas }) }), sourceView] }), _jsxs("div", { className: styles.controls, children: [search.open && !sourceOpen && (_jsxs("div", { className: styles.searchBar, role: "search", "aria-label": `${label} search`, children: [_jsx("input", { ref: search.inputRef, type: "text", className: styles.searchInput, placeholder: "Search diagram", "aria-label": "Search diagram text", value: search.query, onChange: (event) => search.setQuery(event.target.value), onKeyDown: onSearchKeyDown }), _jsx("span", { className: styles.searchCount, role: "status", children: search.matchCount === 0
292
+ ? '0/0'
293
+ : `${search.currentIndex + 1}/${search.matchCount}` }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Previous match", onClick: search.previous, children: _jsx(ChevronUpIcon, {}) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Next match", onClick: search.next, children: _jsx(ChevronDownIcon, {}) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Close search", onClick: closeSearch, children: _jsx(CloseIcon, {}) })] })), _jsxs("div", { className: styles.toolbar, role: "group", "aria-label": `${label} zoom controls`, children: [!sourceOpen && (_jsx("button", { ref: searchToggleRef, type: "button", className: styles.toolbarButton, "aria-label": "Search diagram", "aria-expanded": search.open, onClick: search.toggle, children: _jsx(SearchIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom out", onClick: zoom.zoomOut, children: _jsx(ZoomOutIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom in", onClick: zoom.zoomIn, children: _jsx(ZoomInIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Reset zoom", onClick: zoom.reset, children: _jsx(ResetZoomIcon, {}) })), !sourceOpen && zoom.maximized && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Fit diagram to screen", onClick: zoom.fit, children: _jsx(FitIcon, {}) })), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Maximize diagram", "aria-pressed": zoom.maximized, onClick: zoom.toggleMaximize, children: zoom.maximized ? _jsx(CloseIcon, {}) : _jsx(MaximizeIcon, {}) }), sourceToggle, copyControl, _jsx("span", { ref: zoom.readoutRef, className: sourceOpen ? `${styles.readout} ${styles.hiddenView}` : styles.readout, "aria-hidden": "true", children: "100%" })] })] }), !sourceOpen && minimapOpen && (_jsx(Minimap, { svg: state.svg, zoom: zoom, onClose: () => setMinimapOpen(false) })), !sourceOpen && (_jsx("div", { className: styles.minimapBar, children: _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": minimapOpen ? 'Hide minimap' : 'Show minimap', "aria-expanded": minimapOpen, onClick: () => setMinimapOpen((open) => !open), children: _jsx(MinimapIcon, {}) }) }))] })), state.status === 'ready' && state.svg !== null && interactive && (_jsx("p", { id: hintId, className: styles.visuallyHidden, children: "Zoomable diagram. Use the zoom controls, or press plus and minus to zoom, the arrow keys to pan, and zero to reset." })), state.status !== 'ready' && state.status !== 'error' && (
228
294
  // `aria-busy` on the figure already conveys progress; announcing every phase change
229
295
  // would make a page with several diagrams unusable with a screen reader.
230
296
  _jsxs("div", { className: styles.placeholder, children: [_jsx("span", { className: styles.spinner, "aria-hidden": "true" }), _jsxs("span", { children: [state.status === 'loading' ? `Loading ${engineName} runtime…` : null, state.status === 'rendering' ? `Rendering ${label}…` : null, state.status === 'idle' ? `${label} (waiting to render)` : null] })] })), state.status === 'error' && (_jsxs("div", { className: styles.error, role: "alert", children: [_jsxs("p", { className: styles.errorHeading, children: [_jsx("span", { className: styles.errorIcon, "aria-hidden": "true", children: "\u26A0" }), _jsxs("span", { children: ["Error: ", engineName, " diagram could not be rendered"] })] }), _jsx("pre", { className: styles.errorMessage, children: state.error }), config?.options.showSourceOnError !== false && (_jsxs("details", { className: styles.errorDetails, children: [_jsx("summary", { children: "Show diagram source" }), _jsx("pre", { className: styles.source, children: source })] }))] })), title !== undefined && _jsx("figcaption", { className: styles.caption, children: title }), _jsx("noscript", { dangerouslySetInnerHTML: {
@@ -82,6 +82,20 @@
82
82
  }
83
83
 
84
84
  .layer {
85
+ /*
86
+ * Shrinks to the rendered picture instead of stretching across the viewport. The zoom
87
+ * hook measures content through `offsetWidth`/`offsetHeight` — layout sizes, immune to
88
+ * both the transform and its transition — so this is what makes those measurements mean
89
+ * "the diagram" rather than "the full-width frame". Fitting (which fills the screen when
90
+ * maximized) and the minimap both read that measurement.
91
+ *
92
+ * `max-width` is load-bearing, not decoration: in intrinsic (`fit-content`) sizing the
93
+ * SVG's own `max-width: 100%` resolves against an indefinite width and is ignored, so
94
+ * without this cap a diagram wider than the column would blow the layer out to the SVG's
95
+ * intrinsic width and overflow the frame.
96
+ */
97
+ width: fit-content;
98
+ max-width: 100%;
85
99
  transform-origin: 0 0;
86
100
  transition: transform var(--plantuml-zoom-transition, 150ms) ease-out;
87
101
  }
@@ -91,10 +105,31 @@
91
105
  transition: none;
92
106
  }
93
107
 
94
- .toolbar {
108
+ /*
109
+ * One flex row holds the search bar and the toolbar, so opening the search never needs to
110
+ * know how wide the toolbar happens to be — it simply takes the space to its left.
111
+ */
112
+ .controls {
95
113
  position: absolute;
96
114
  top: 0.5rem;
97
115
  right: 0.5rem;
116
+ display: flex;
117
+ gap: 0.5rem;
118
+ align-items: flex-start;
119
+ }
120
+
121
+ .toolbar {
122
+ display: flex;
123
+ gap: 0.25rem;
124
+ align-items: center;
125
+ padding: 0.25rem;
126
+ border: 1px solid var(--ifm-color-emphasis-300);
127
+ border-radius: var(--ifm-global-radius);
128
+ background: var(--ifm-background-surface-color);
129
+ opacity: 0.92;
130
+ }
131
+
132
+ .searchBar {
98
133
  display: flex;
99
134
  gap: 0.25rem;
100
135
  align-items: center;
@@ -105,6 +140,66 @@
105
140
  opacity: 0.92;
106
141
  }
107
142
 
143
+ .searchInput {
144
+ width: 10rem;
145
+ max-width: 40vw;
146
+ height: 1.75rem;
147
+ padding: 0 0.5rem;
148
+ border: 1px solid var(--ifm-color-emphasis-300);
149
+ border-radius: var(--ifm-global-radius);
150
+ /* Not `--ifm-background-color`: Infima defines it as `#0000` in light mode. */
151
+ background: var(--ifm-background-surface-color);
152
+ color: var(--ifm-font-color-base);
153
+ font-size: 0.8125rem;
154
+ }
155
+
156
+ .searchInput:focus-visible {
157
+ outline: 2px solid var(--ifm-color-primary);
158
+ outline-offset: 1px;
159
+ }
160
+
161
+ .searchCount {
162
+ min-width: 2.25rem;
163
+ color: var(--ifm-color-emphasis-700);
164
+ font-size: 0.75rem;
165
+ font-variant-numeric: tabular-nums;
166
+ text-align: center;
167
+ }
168
+
169
+ /*
170
+ * Search highlights, keyed on the attributes the search hook sets. Attribute + CSS rather
171
+ * than mutating the SVG string: the string is shared through the render cache, and a
172
+ * highlight must never leak into another figure showing the same diagram.
173
+ *
174
+ * These rules sit after the Graphviz `currentColor` retargeting above and tie on
175
+ * specificity, so source order is what makes the highlight win there too.
176
+ */
177
+ .layer .canvas svg text[data-plantuml-search-match] {
178
+ fill: var(--ifm-color-primary);
179
+ }
180
+
181
+ .layer .canvas svg text[data-plantuml-search-current] {
182
+ fill: var(--ifm-color-danger-dark);
183
+ text-decoration: underline;
184
+ }
185
+
186
+ /*
187
+ * The node a `#graph?highlight-node=…` deep link resolved to.
188
+ *
189
+ * The attribute may sit on a text line itself, or on the group/anchor wrapping a whole node
190
+ * (Graphviz `g.node`, a PlantUML self-anchor), so both spellings are styled. Scoped to
191
+ * `.figure` rather than `.layer`: a diagram that opted out of zoom still highlights, it
192
+ * just cannot pan.
193
+ *
194
+ * Placed after the Graphviz `currentColor` retargeting, whose specificity these tie —
195
+ * source order is what makes the highlight win there.
196
+ */
197
+ .figure .canvas svg text[data-plantuml-focused-node],
198
+ .figure .canvas svg [data-plantuml-focused-node] text {
199
+ fill: var(--ifm-color-danger-dark);
200
+ text-decoration: underline;
201
+ }
202
+
108
203
  .toolbarButton {
109
204
  display: flex;
110
205
  align-items: center;
@@ -169,6 +264,80 @@
169
264
  max-height: none;
170
265
  }
171
266
 
267
+ /*
268
+ * Minimap.
269
+ *
270
+ * A small copy of the diagram, bottom-left where the toolbar is top-right, with a rectangle
271
+ * marking the visible part. The copy is laid out at the content's own width and scaled down
272
+ * as one block, so it keeps exactly the geometry of the real canvas and the rectangle's
273
+ * coordinates can be multiplied straight through.
274
+ */
275
+ .minimapBar {
276
+ position: absolute;
277
+ bottom: 0.5rem;
278
+ left: 0.5rem;
279
+ display: flex;
280
+ padding: 0.25rem;
281
+ border: 1px solid var(--ifm-color-emphasis-300);
282
+ border-radius: var(--ifm-global-radius);
283
+ background: var(--ifm-background-surface-color);
284
+ opacity: 0.92;
285
+ }
286
+
287
+ .minimap {
288
+ position: absolute;
289
+ bottom: 3.25rem;
290
+ left: 0.5rem;
291
+ /*
292
+ * The close control sits *beside* the map, not floating over it: a wide, short diagram
293
+ * makes a map only a couple of buttons tall, and an overlaid control would cover — and
294
+ * steal presses from — the map's whole right end.
295
+ */
296
+ display: flex;
297
+ gap: 0.25rem;
298
+ align-items: flex-start;
299
+ max-width: calc(100% - 1rem);
300
+ padding: 0.25rem;
301
+ border: 1px solid var(--ifm-color-emphasis-300);
302
+ border-radius: var(--ifm-global-radius);
303
+ background: var(--ifm-background-surface-color);
304
+ opacity: 0.95;
305
+ }
306
+
307
+ .minimapCanvas {
308
+ position: relative;
309
+ max-width: 100%;
310
+ overflow: hidden;
311
+ cursor: crosshair;
312
+ /* The map pans the diagram; a drag on it must never scroll the page instead. */
313
+ touch-action: none;
314
+ }
315
+
316
+ .minimapLayer {
317
+ transform-origin: 0 0;
318
+ /* The map is one press-or-drag surface; its copy of the diagram must not eat the events. */
319
+ pointer-events: none;
320
+ }
321
+
322
+ .minimapLayer svg {
323
+ display: block;
324
+ max-width: 100%;
325
+ height: auto;
326
+ }
327
+
328
+ .minimapRect {
329
+ position: absolute;
330
+ border: 2px solid var(--ifm-color-primary);
331
+ background: color-mix(in srgb, var(--ifm-color-primary) 20%, transparent);
332
+ pointer-events: none;
333
+ }
334
+
335
+ .minimapClose {
336
+ flex: none;
337
+ width: 1.5rem;
338
+ height: 1.5rem;
339
+ }
340
+
172
341
  .visuallyHidden {
173
342
  position: absolute;
174
343
  width: 1px;
@@ -182,7 +351,10 @@
182
351
  }
183
352
 
184
353
  @media (forced-colors: active) {
185
- .toolbar {
354
+ .toolbar,
355
+ .searchBar,
356
+ .minimapBar,
357
+ .minimap {
186
358
  border-color: ButtonBorder;
187
359
  }
188
360
 
@@ -190,6 +362,12 @@
190
362
  border-color: ButtonBorder;
191
363
  color: ButtonText;
192
364
  }
365
+
366
+ /* `color-mix` resolves to nothing useful here; the outline alone must carry the meaning. */
367
+ .minimapRect {
368
+ border-color: Highlight;
369
+ background: transparent;
370
+ }
193
371
  }
194
372
 
195
373
  .placeholder {