@matfsw/docusaurus-plantuml-plugin 1.3.1 → 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.
@@ -24,10 +24,23 @@ export declare function ZoomOutIcon(): ReactElement;
24
24
  export declare function ZoomInIcon(): ReactElement;
25
25
  /** Replaces `⟲` U+27F2: a circular arrow, open at the top right where the arrowhead sits. */
26
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;
27
32
  /** Replaces `⛶` U+26F6, the character that had no glyph. */
28
33
  export declare function MaximizeIcon(): ReactElement;
29
34
  /** Replaces `✕` U+2715, shown in place of maximize while the diagram fills the viewport. */
30
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;
31
44
  /**
32
45
  * Replaces the literal `</>` of the source toggle.
33
46
  *
@@ -24,6 +24,13 @@ export function ZoomInIcon() {
24
24
  export function ResetZoomIcon() {
25
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
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
+ }
27
34
  /** Replaces `⛶` U+26F6, the character that had no glyph. */
28
35
  export function MaximizeIcon() {
29
36
  return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" }) }));
@@ -32,6 +39,22 @@ export function MaximizeIcon() {
32
39
  export function CloseIcon() {
33
40
  return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) }));
34
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
+ }
35
58
  /**
36
59
  * Replaces the literal `</>` of the source toggle.
37
60
  *
@@ -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,7 +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';
10
- import { CloseIcon, MaximizeIcon, ResetZoomIcon, SourceIcon, ZoomInIcon, ZoomOutIcon, } from './icons.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';
11
16
  import { useZoomPan } from './useZoomPan.js';
12
17
  import styles from './styles.module.css';
13
18
  /**
@@ -50,6 +55,9 @@ function messageFor(error) {
50
55
  export default function PlantUmlDiagram({ source, title, language, engine = 'plantuml', layout, zoom: zoomProp, showSource: showSourceProp, }) {
51
56
  const config = usePlantUmlConfig();
52
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();
53
61
  const containerRef = useRef(null);
54
62
  const hintId = useId();
55
63
  const sourcePanelId = useId();
@@ -68,8 +76,12 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
68
76
  const interactive = zoomProp ?? config?.options.zoom ?? true;
69
77
  const sourceAvailable = showSourceProp ?? config?.options.showSource ?? true;
70
78
  const [state, setState] = useState(INITIAL_STATE);
71
- 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);
72
83
  const [sourceOpen, setSourceOpen] = useState(false);
84
+ const [minimapOpen, setMinimapOpen] = useState(false);
73
85
  const [copyState, setCopyState] = useState('idle');
74
86
  const copyTimer = useRef(undefined);
75
87
  // A pending "Copied" message must not fire into an unmounted component.
@@ -107,6 +119,48 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
107
119
  // gratuitous jump.
108
120
  resetKey: `${source}|${engine}|${resolvedLayout}|${renderDark ? 'dark' : 'light'}`,
109
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]);
110
164
  useEffect(() => {
111
165
  if (!lazy || inView)
112
166
  return undefined;
@@ -201,11 +255,18 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
201
255
  ]);
202
256
  const label = title ?? `${engineName} diagram`;
203
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]);
204
265
  // The sanitized SVG always lives in this element, zoomable or not, so `role="img" > svg`
205
266
  // stays a stable contract for tests and for author CSS.
206
- 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,
207
268
  // Sanitized above unless `sanitizeSvg: false` was explicitly opted into.
208
- dangerouslySetInnerHTML: { __html: state.svg } }));
269
+ dangerouslySetInnerHTML: svgHtml }));
209
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" }));
210
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, {}) }));
211
272
  /**
@@ -225,7 +286,11 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
225
286
  [DATA_ATTR.theme]: dark ? 'dark' : 'light',
226
287
  ...(interactive ? { [DATA_ATTR.interactive]: 'true' } : {}),
227
288
  ...(zoom.maximized ? { [DATA_ATTR.maximized]: 'true' } : {}),
228
- ...(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(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, {}) })), _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%" })] })] })), 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' && (
229
294
  // `aria-busy` on the figure already conveys progress; announcing every phase change
230
295
  // would make a page with several diagrams unusable with a screen reader.
231
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 {
@@ -0,0 +1,32 @@
1
+ import { type MutableRefObject } from 'react';
2
+ import type { ZoomPanHandle } from './useZoomPan.js';
3
+ /**
4
+ * Makes one diagram react to `#graph?highlight-node=…` deep links.
5
+ *
6
+ * On a matching hash the target node is marked (`data-plantuml-focused-node`, styled from
7
+ * the stylesheet), the page scrolls to the first matching figure, and — when the diagram is
8
+ * zoomable — the view snaps to 100% centred on the node. A diagram without the node does
9
+ * nothing, which is what lets every diagram on the page react without any of them carrying
10
+ * an id.
11
+ *
12
+ * The hash comes from the router, not from a `hashchange` listener: Docusaurus `<Link>`
13
+ * navigations are `history.pushState` calls, which fire no `hashchange` — so a listener
14
+ * kept a stale hash whenever a link changed or dropped it while the page (and this
15
+ * component) stayed mounted, leaving the old node highlighted. `useLocation` re-renders on
16
+ * push, replace and pop alike, and native `#…` anchor clicks reach it too, via the
17
+ * `popstate` the browser fires for hash navigations.
18
+ *
19
+ * Highlighting is imperative for the same reason the search's is: the marked elements live
20
+ * in a `dangerouslySetInnerHTML` subtree React cannot render into.
21
+ */
22
+ export interface UseDiagramDeeplinkParams {
23
+ /** Whether the diagram is rendered and its SVG is in the DOM. */
24
+ ready: boolean;
25
+ /** The rendered SVG string — a dependency, so a re-rendered picture is re-matched. */
26
+ svg: string | null;
27
+ /** Whether the diagram is zoomable; centring and the 100% snap need the viewport. */
28
+ interactive: boolean;
29
+ zoom: ZoomPanHandle;
30
+ containerRef: MutableRefObject<HTMLElement | null>;
31
+ }
32
+ export declare function useDiagramDeeplink({ ready, svg, interactive, zoom, containerRef, }: UseDiagramDeeplinkParams): void;
@@ -0,0 +1,54 @@
1
+ import { useEffect } from 'react';
2
+ import { useLocation } from '@docusaurus/router';
3
+ import { DATA_ATTR } from '../../constants.js';
4
+ import { claimDeeplinkScroll, findDeeplinkTarget, parseDiagramHash } from './deeplink.js';
5
+ import { centerViewportOn } from './zoomMath.js';
6
+ export function useDiagramDeeplink({ ready, svg, interactive, zoom, containerRef, }) {
7
+ const location = useLocation();
8
+ const target = parseDiagramHash(location.hash);
9
+ // One value per history entry. Undefined on entries the router did not create itself
10
+ // (native hash clicks, the initial load); those still differ in `target`, which the
11
+ // scroll claim includes.
12
+ const navigationKey = location.key ?? 'pop';
13
+ const pathname = location.pathname;
14
+ /**
15
+ * Marks, scrolls and centres. The cleanup unmarks, so a changed hash, a navigation that
16
+ * drops the hash and an unmounting diagram all sweep up through the same code path; a
17
+ * replaced SVG re-runs the effect (via `svg`) against the new elements.
18
+ *
19
+ * `navigationKey` is a dependency on purpose: following the same deep link again is a new
20
+ * history entry with an unchanged `target`, and it should scroll and re-centre again.
21
+ */
22
+ useEffect(() => {
23
+ if (!ready || target === null)
24
+ return undefined;
25
+ const figure = containerRef.current;
26
+ const canvas = figure?.querySelector('[role="img"]');
27
+ if (!figure || !canvas)
28
+ return undefined;
29
+ const match = findDeeplinkTarget(canvas, target);
30
+ if (!match)
31
+ return undefined;
32
+ for (const element of match.elements) {
33
+ element.setAttribute(DATA_ATTR.focusedNode, 'true');
34
+ }
35
+ claimDeeplinkScroll(`${navigationKey}|${pathname}|${target}`, figure);
36
+ if (interactive) {
37
+ const layer = zoom.layerRef.current;
38
+ if (layer) {
39
+ // The node's offset inside the layer, divided back by the current scale, is its
40
+ // position in content coordinates whatever the zoom was; the view then snaps to
41
+ // 100% centred there — the zoom level a deep link promises.
42
+ const transform = zoom.getTransform();
43
+ const layerBox = layer.getBoundingClientRect();
44
+ const box = match.anchor.getBoundingClientRect();
45
+ zoom.applyTransform(centerViewportOn({ k: 1, x: 0, y: 0 }, (box.left + box.width / 2 - layerBox.left) / transform.k, (box.top + box.height / 2 - layerBox.top) / transform.k, zoom.measure()));
46
+ }
47
+ }
48
+ return () => {
49
+ for (const element of match.elements) {
50
+ element.removeAttribute(DATA_ATTR.focusedNode);
51
+ }
52
+ };
53
+ }, [containerRef, interactive, navigationKey, pathname, ready, svg, target, zoom]);
54
+ }
@@ -0,0 +1,37 @@
1
+ import { type MutableRefObject } from 'react';
2
+ import type { ZoomPanHandle } from './useZoomPan.js';
3
+ /**
4
+ * Text search over one rendered diagram.
5
+ *
6
+ * The search runs against the *rendered SVG's* `<text>` elements rather than the diagram
7
+ * source: what the reader sees is what they are searching, and a match can be highlighted and
8
+ * panned to because it is a real element with a position. Matches are marked with data
9
+ * attributes and coloured from the stylesheet, so the SVG string itself — which the cache
10
+ * shares between diagrams — is never mutated.
11
+ *
12
+ * Highlighting is imperative for the same reason the zoom transform is: the elements live in
13
+ * a `dangerouslySetInnerHTML` subtree that React cannot render into. React state carries only
14
+ * what drives markup — the query, the match count and the current position.
15
+ */
16
+ export interface DiagramSearchHandle {
17
+ /** Whether the search bar is shown. */
18
+ open: boolean;
19
+ query: string;
20
+ /** Number of `<text>` elements matching the query; 0 when the query is empty. */
21
+ matchCount: number;
22
+ /** Zero-based position of the current match, or -1 when there is none. */
23
+ currentIndex: number;
24
+ inputRef: MutableRefObject<HTMLInputElement | null>;
25
+ toggle: () => void;
26
+ close: () => void;
27
+ setQuery: (value: string) => void;
28
+ next: () => void;
29
+ previous: () => void;
30
+ }
31
+ export interface UseDiagramSearchParams {
32
+ enabled: boolean;
33
+ zoom: ZoomPanHandle;
34
+ /** The rendered SVG string — a dependency, so a re-rendered picture is re-searched. */
35
+ svg: string | null;
36
+ }
37
+ export declare function useDiagramSearch({ enabled, zoom, svg, }: UseDiagramSearchParams): DiagramSearchHandle;
@@ -0,0 +1,107 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { DATA_ATTR } from '../../constants.js';
3
+ import { centerViewportOn } from './zoomMath.js';
4
+ export function useDiagramSearch({ enabled, zoom, svg, }) {
5
+ const [open, setOpen] = useState(false);
6
+ const [query, setQuery] = useState('');
7
+ const [matchCount, setMatchCount] = useState(0);
8
+ const [currentIndex, setCurrentIndex] = useState(-1);
9
+ const matchesRef = useRef([]);
10
+ const inputRef = useRef(null);
11
+ const toggle = useCallback(() => setOpen((value) => !value), []);
12
+ const close = useCallback(() => setOpen(false), []);
13
+ const next = useCallback(() => {
14
+ setCurrentIndex((index) => {
15
+ const total = matchesRef.current.length;
16
+ return total === 0 ? -1 : (index + 1) % total;
17
+ });
18
+ }, []);
19
+ const previous = useCallback(() => {
20
+ setCurrentIndex((index) => {
21
+ const total = matchesRef.current.length;
22
+ return total === 0 ? -1 : (index - 1 + total) % total;
23
+ });
24
+ }, []);
25
+ // Typing should not require a second click after pressing the toggle.
26
+ useEffect(() => {
27
+ if (open)
28
+ inputRef.current?.focus();
29
+ }, [open]);
30
+ // A diagram that lost its zoom lost the viewport the search pans; the bar goes with it.
31
+ useEffect(() => {
32
+ if (!enabled)
33
+ setOpen(false);
34
+ }, [enabled]);
35
+ /**
36
+ * Finds and marks the matches. The effect's cleanup unmarks them, so closing the bar,
37
+ * changing the query and unmounting all sweep up through the same code path. A replaced
38
+ * SVG needs no sweeping — its marked elements left the document with it — but re-runs the
39
+ * effect (via `svg`) so the new elements are searched.
40
+ */
41
+ useEffect(() => {
42
+ if (!enabled || !open)
43
+ return undefined;
44
+ const layer = zoom.layerRef.current;
45
+ const needle = query.trim().toLowerCase();
46
+ if (!layer || needle === '') {
47
+ matchesRef.current = [];
48
+ setMatchCount(0);
49
+ setCurrentIndex(-1);
50
+ return undefined;
51
+ }
52
+ // `<text>` elements, not their tspans: a PlantUML label is one text element, and marking
53
+ // the whole label keeps the highlight readable instead of colouring a fragment.
54
+ const matches = Array.from(layer.querySelectorAll('svg text')).filter((element) => (element.textContent ?? '').toLowerCase().includes(needle));
55
+ matchesRef.current = matches;
56
+ for (const match of matches)
57
+ match.setAttribute(DATA_ATTR.searchMatch, 'true');
58
+ setMatchCount(matches.length);
59
+ // Every new query starts at its first match, exactly like the browser's own find bar.
60
+ setCurrentIndex(matches.length > 0 ? 0 : -1);
61
+ return () => {
62
+ matchesRef.current = [];
63
+ for (const match of matches)
64
+ match.removeAttribute(DATA_ATTR.searchMatch);
65
+ };
66
+ }, [enabled, open, query, svg, zoom]);
67
+ /**
68
+ * Marks the current match and centres the viewport on it.
69
+ *
70
+ * Declared after the match effect on purpose: effects run in order, so by the time this
71
+ * one reads `matchesRef` the list belongs to the current query. `query` and `svg` are
72
+ * dependencies because a new list can leave the index at 0 — unchanged — while element 0
73
+ * is a different match.
74
+ */
75
+ useEffect(() => {
76
+ if (!enabled || !open)
77
+ return undefined;
78
+ const target = currentIndex >= 0 ? matchesRef.current[currentIndex] : undefined;
79
+ if (!target)
80
+ return undefined;
81
+ target.setAttribute(DATA_ATTR.searchCurrent, 'true');
82
+ const layer = zoom.layerRef.current;
83
+ if (layer) {
84
+ // The match's offset inside the layer, both measured in the same transformed space,
85
+ // divided back by the scale — which yields content coordinates whatever the zoom is.
86
+ const transform = zoom.getTransform();
87
+ const layerBox = layer.getBoundingClientRect();
88
+ const box = target.getBoundingClientRect();
89
+ zoom.applyTransform(centerViewportOn(transform, (box.left + box.width / 2 - layerBox.left) / transform.k, (box.top + box.height / 2 - layerBox.top) / transform.k, zoom.measure()));
90
+ }
91
+ return () => target.removeAttribute(DATA_ATTR.searchCurrent);
92
+ }, [currentIndex, enabled, open, query, svg, zoom]);
93
+ // Stable per set of values, like the zoom handle: consumers may hang callbacks and
94
+ // effects off the handle, and those must not churn on unrelated re-renders.
95
+ return useMemo(() => ({
96
+ open,
97
+ query,
98
+ matchCount,
99
+ currentIndex,
100
+ inputRef,
101
+ toggle,
102
+ close,
103
+ setQuery,
104
+ next,
105
+ previous,
106
+ }), [open, query, matchCount, currentIndex, toggle, close, next, previous]);
107
+ }