@matfsw/docusaurus-plantuml-plugin 1.3.1 → 1.5.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.
@@ -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,96 @@
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
+ /*
200
+ * The theme's success green, not danger red: this highlight persists until the hash
201
+ * changes, and a standing red reads as an error. Text stays *filled* with an accent
202
+ * rather than boxed in a light tint because SVG text has no background to tint — and
203
+ * the variable lets a site pick its own colour without fighting specificity.
204
+ */
205
+ fill: var(--plantuml-focus-color, var(--ifm-color-success));
206
+ text-decoration: underline;
207
+ }
208
+
209
+ /*
210
+ * Links inside the picture — Graphviz-native and the anchors synthesized from PlantUML
211
+ * `[[url]]` links the engine drops. The synthesized ones get the site's link colour so a
212
+ * linked node is discoverable; Graphviz authors colour their own (`fontcolor=`), so only
213
+ * the cursor and focus treatment apply there.
214
+ */
215
+ .canvas svg a {
216
+ cursor: pointer;
217
+ }
218
+
219
+ .canvas svg a:focus-visible {
220
+ outline: 2px solid var(--ifm-color-primary);
221
+ outline-offset: 1px;
222
+ }
223
+
224
+ .canvas svg a[data-plantuml-diagram-link] text {
225
+ fill: var(--ifm-link-color);
226
+ }
227
+
228
+ .canvas svg a[data-plantuml-diagram-link]:hover text,
229
+ .canvas svg a[data-plantuml-diagram-link]:focus-visible text {
230
+ text-decoration: underline;
231
+ }
232
+
108
233
  .toolbarButton {
109
234
  display: flex;
110
235
  align-items: center;
@@ -169,6 +294,80 @@
169
294
  max-height: none;
170
295
  }
171
296
 
297
+ /*
298
+ * Minimap.
299
+ *
300
+ * A small copy of the diagram, bottom-left where the toolbar is top-right, with a rectangle
301
+ * marking the visible part. The copy is laid out at the content's own width and scaled down
302
+ * as one block, so it keeps exactly the geometry of the real canvas and the rectangle's
303
+ * coordinates can be multiplied straight through.
304
+ */
305
+ .minimapBar {
306
+ position: absolute;
307
+ bottom: 0.5rem;
308
+ left: 0.5rem;
309
+ display: flex;
310
+ padding: 0.25rem;
311
+ border: 1px solid var(--ifm-color-emphasis-300);
312
+ border-radius: var(--ifm-global-radius);
313
+ background: var(--ifm-background-surface-color);
314
+ opacity: 0.92;
315
+ }
316
+
317
+ .minimap {
318
+ position: absolute;
319
+ bottom: 3.25rem;
320
+ left: 0.5rem;
321
+ /*
322
+ * The close control sits *beside* the map, not floating over it: a wide, short diagram
323
+ * makes a map only a couple of buttons tall, and an overlaid control would cover — and
324
+ * steal presses from — the map's whole right end.
325
+ */
326
+ display: flex;
327
+ gap: 0.25rem;
328
+ align-items: flex-start;
329
+ max-width: calc(100% - 1rem);
330
+ padding: 0.25rem;
331
+ border: 1px solid var(--ifm-color-emphasis-300);
332
+ border-radius: var(--ifm-global-radius);
333
+ background: var(--ifm-background-surface-color);
334
+ opacity: 0.95;
335
+ }
336
+
337
+ .minimapCanvas {
338
+ position: relative;
339
+ max-width: 100%;
340
+ overflow: hidden;
341
+ cursor: crosshair;
342
+ /* The map pans the diagram; a drag on it must never scroll the page instead. */
343
+ touch-action: none;
344
+ }
345
+
346
+ .minimapLayer {
347
+ transform-origin: 0 0;
348
+ /* The map is one press-or-drag surface; its copy of the diagram must not eat the events. */
349
+ pointer-events: none;
350
+ }
351
+
352
+ .minimapLayer svg {
353
+ display: block;
354
+ max-width: 100%;
355
+ height: auto;
356
+ }
357
+
358
+ .minimapRect {
359
+ position: absolute;
360
+ border: 2px solid var(--ifm-color-primary);
361
+ background: color-mix(in srgb, var(--ifm-color-primary) 20%, transparent);
362
+ pointer-events: none;
363
+ }
364
+
365
+ .minimapClose {
366
+ flex: none;
367
+ width: 1.5rem;
368
+ height: 1.5rem;
369
+ }
370
+
172
371
  .visuallyHidden {
173
372
  position: absolute;
174
373
  width: 1px;
@@ -182,7 +381,10 @@
182
381
  }
183
382
 
184
383
  @media (forced-colors: active) {
185
- .toolbar {
384
+ .toolbar,
385
+ .searchBar,
386
+ .minimapBar,
387
+ .minimap {
186
388
  border-color: ButtonBorder;
187
389
  }
188
390
 
@@ -190,6 +392,12 @@
190
392
  border-color: ButtonBorder;
191
393
  color: ButtonText;
192
394
  }
395
+
396
+ /* `color-mix` resolves to nothing useful here; the outline alone must carry the meaning. */
397
+ .minimapRect {
398
+ border-color: Highlight;
399
+ background: transparent;
400
+ }
193
401
  }
194
402
 
195
403
  .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,29 @@
1
+ import { type MutableRefObject } from 'react';
2
+ import type { DiagramEngine } from '../../runtime/types.js';
3
+ /**
4
+ * Author-written links inside one diagram: synthesis and navigation.
5
+ *
6
+ * **Synthesis** (PlantUML only): the bundled engine drops `[[url]]` links, so they are
7
+ * rebuilt from the fence source and attached to the rendered elements — see
8
+ * `diagramLinks.ts` for the correlation. Graphviz needs none of this; `URL=` already emits
9
+ * real anchors.
10
+ *
11
+ * **Navigation** (both engines): a click on any in-diagram link to a same-site URL goes
12
+ * through the router instead of a full page load, so a node can link to a diagram on
13
+ * another page and arrive instantly — with the target page's diagrams reacting to the
14
+ * hash through the same router-driven deep-link tracking. Site-absolute paths get the
15
+ * site's `baseUrl`, exactly as markdown links do, so authors write the same `/docs/…`
16
+ * everywhere. External links and pure `#…` anchors stay native: the browser handles both
17
+ * correctly on its own, and the router observes hash navigations anyway.
18
+ */
19
+ export interface UseDiagramLinksParams {
20
+ /** Whether the diagram is rendered and its SVG is in the DOM. */
21
+ ready: boolean;
22
+ /** The rendered SVG string — a dependency, so a re-rendered picture is re-linked. */
23
+ svg: string | null;
24
+ engine: DiagramEngine;
25
+ /** The fence source, exactly as authored — where the links are read from. */
26
+ source: string;
27
+ containerRef: MutableRefObject<HTMLElement | null>;
28
+ }
29
+ export declare function useDiagramLinks({ ready, svg, engine, source, containerRef, }: UseDiagramLinksParams): void;
@@ -0,0 +1,62 @@
1
+ import { useEffect } from 'react';
2
+ import { useHistory } from '@docusaurus/router';
3
+ import { useBaseUrlUtils } from '@docusaurus/useBaseUrl';
4
+ import { attachDiagramLinks, detachDiagramLinks, extractSourceLinks } from './diagramLinks.js';
5
+ export function useDiagramLinks({ ready, svg, engine, source, containerRef, }) {
6
+ const history = useHistory();
7
+ const { withBaseUrl } = useBaseUrlUtils();
8
+ // Synthesis. The cleanup unwraps, so a re-rendered or unmounting diagram never leaks
9
+ // anchors; a replaced SVG re-runs the effect (via `svg`) against the new elements.
10
+ useEffect(() => {
11
+ if (!ready || engine !== 'plantuml')
12
+ return undefined;
13
+ const canvas = containerRef.current?.querySelector('[role="img"]');
14
+ if (!canvas)
15
+ return undefined;
16
+ const links = extractSourceLinks(source);
17
+ if (links.length === 0)
18
+ return undefined;
19
+ attachDiagramLinks(canvas, links);
20
+ return () => detachDiagramLinks(canvas);
21
+ }, [containerRef, engine, ready, source, svg]);
22
+ // Navigation. One listener on the figure covers synthesized and engine-native anchors
23
+ // alike. Bubble phase on purpose: the zoom hook suppresses post-drag clicks in the
24
+ // capture phase, and a suppressed click must not navigate.
25
+ useEffect(() => {
26
+ if (!ready)
27
+ return undefined;
28
+ const figure = containerRef.current;
29
+ if (!figure)
30
+ return undefined;
31
+ const onClick = (event) => {
32
+ // Modified clicks are the reader asking the browser for its own behaviour
33
+ // (new tab, download); never take those over.
34
+ if (event.defaultPrevented || event.button !== 0)
35
+ return;
36
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
37
+ return;
38
+ const target = event.target instanceof Element ? event.target : null;
39
+ const anchor = target?.closest('a');
40
+ if (!anchor || !figure.querySelector('[role="img"]')?.contains(anchor))
41
+ return;
42
+ const href = anchor.getAttribute('href') ?? anchor.getAttribute('xlink:href');
43
+ // Hash-only anchors navigate natively; the router sees hash navigations by itself.
44
+ if (!href || href.startsWith('#'))
45
+ return;
46
+ const resolved = href.startsWith('/') ? withBaseUrl(href) : href;
47
+ let url;
48
+ try {
49
+ url = new URL(resolved, window.location.href);
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ if (url.origin !== window.location.origin)
55
+ return;
56
+ event.preventDefault();
57
+ history.push(url.pathname + url.search + url.hash);
58
+ };
59
+ figure.addEventListener('click', onClick);
60
+ return () => figure.removeEventListener('click', onClick);
61
+ }, [containerRef, history, ready, withBaseUrl]);
62
+ }
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject } from 'react';
2
- import { MAX_SCALE, MIN_SCALE } from './zoomMath.js';
2
+ import { MAX_SCALE, MIN_SCALE, type Bounds, type Transform } from './zoomMath.js';
3
3
  export interface ZoomPanHandle {
4
4
  viewportRef: MutableRefObject<HTMLDivElement | null>;
5
5
  layerRef: MutableRefObject<HTMLDivElement | null>;
@@ -8,10 +8,26 @@ export interface ZoomPanHandle {
8
8
  zoomIn: () => void;
9
9
  zoomOut: () => void;
10
10
  reset: () => void;
11
+ /** Fits the whole diagram inside the viewport, at whatever scale that takes to fill it. */
12
+ fit: () => void;
11
13
  toggleMaximize: () => void;
12
14
  onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void;
13
15
  /** Whether the diagram currently fills the browser viewport. */
14
16
  maximized: boolean;
17
+ /** Reads the live transform without subscribing to it. */
18
+ getTransform: () => Transform;
19
+ /** Applies a transform through the same clamp as every gesture. */
20
+ applyTransform: (next: Transform) => void;
21
+ /** Layout sizes of the viewport and content — the bounds the clamp runs against. */
22
+ measure: () => Bounds;
23
+ /**
24
+ * Calls the listener after every committed transform write; returns the unsubscribe.
25
+ *
26
+ * This is how the minimap tracks the view: the transform is written straight to the DOM on
27
+ * every pan frame, so anything that mirrored it through React state would re-render the
28
+ * component once per frame.
29
+ */
30
+ subscribe: (listener: (transform: Transform) => void) => () => void;
15
31
  }
16
32
  export interface UseZoomPanParams {
17
33
  enabled: boolean;