@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.
- package/CHANGELOG.md +105 -1
- package/README.md +177 -13
- package/dist/constants.d.ts +12 -0
- package/dist/constants.js +12 -0
- package/dist/runtime/sanitize.js +5 -1
- package/dist/theme/PlantUmlDiagram/Minimap.d.ts +9 -0
- package/dist/theme/PlantUmlDiagram/Minimap.js +137 -0
- package/dist/theme/PlantUmlDiagram/deeplink.d.ts +55 -0
- package/dist/theme/PlantUmlDiagram/deeplink.js +149 -0
- package/dist/theme/PlantUmlDiagram/diagramLinks.d.ts +51 -0
- package/dist/theme/PlantUmlDiagram/diagramLinks.js +185 -0
- package/dist/theme/PlantUmlDiagram/icons.d.ts +13 -0
- package/dist/theme/PlantUmlDiagram/icons.js +23 -0
- package/dist/theme/PlantUmlDiagram/index.js +79 -6
- package/dist/theme/PlantUmlDiagram/styles.module.css +210 -2
- package/dist/theme/PlantUmlDiagram/useDiagramDeeplink.d.ts +32 -0
- package/dist/theme/PlantUmlDiagram/useDiagramDeeplink.js +54 -0
- package/dist/theme/PlantUmlDiagram/useDiagramLinks.d.ts +29 -0
- package/dist/theme/PlantUmlDiagram/useDiagramLinks.js +62 -0
- package/dist/theme/PlantUmlDiagram/useDiagramSearch.d.ts +37 -0
- package/dist/theme/PlantUmlDiagram/useDiagramSearch.js +107 -0
- package/dist/theme/PlantUmlDiagram/useZoomPan.d.ts +17 -1
- package/dist/theme/PlantUmlDiagram/useZoomPan.js +66 -6
- package/dist/theme/PlantUmlDiagram/zoomMath.d.ts +28 -0
- package/dist/theme/PlantUmlDiagram/zoomMath.js +42 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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;
|