@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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep links into diagrams: `#graph?highlight-node=<TARGET>`.
|
|
3
|
+
*
|
|
4
|
+
* Every diagram on the page reacts to the hash — none of them needs an id of its own, and a
|
|
5
|
+
* diagram that does not contain the target simply does nothing. The matcher below is a
|
|
6
|
+
* ladder: the first level that yields a match wins, so a deterministic, author-chosen ID
|
|
7
|
+
* always beats the loose text matching that exists for diagrams nobody annotated.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is DOM-in, elements-out and free of React, which is what makes the ladder
|
|
10
|
+
* testable against synthetic SVG.
|
|
11
|
+
*/
|
|
12
|
+
/** The hash prefix that separates diagram deep links from ordinary heading anchors. */
|
|
13
|
+
const HASH_PREFIX = '#graph?';
|
|
14
|
+
/** The query parameter carrying the node identifier. */
|
|
15
|
+
const NODE_PARAM = 'highlight-node';
|
|
16
|
+
/**
|
|
17
|
+
* Extracts the deeplink target from a location hash.
|
|
18
|
+
*
|
|
19
|
+
* Returns the decoded identifier — `%0A` becomes a real newline, so multiline labels can be
|
|
20
|
+
* addressed — or `null` when the hash is absent, not diagram-shaped, or names no node.
|
|
21
|
+
*/
|
|
22
|
+
export function parseDiagramHash(hash) {
|
|
23
|
+
if (!hash.startsWith(HASH_PREFIX))
|
|
24
|
+
return null;
|
|
25
|
+
const target = new URLSearchParams(hash.slice(HASH_PREFIX.length)).get(NODE_PARAM);
|
|
26
|
+
return target === null || target.trim() === '' ? null : target;
|
|
27
|
+
}
|
|
28
|
+
/** Escapes a value for use inside a double-quoted CSS attribute selector. */
|
|
29
|
+
function escapeAttributeValue(value) {
|
|
30
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
31
|
+
}
|
|
32
|
+
/** The target of an `<a>` element, whichever spelling the engine used. */
|
|
33
|
+
function hrefOf(anchor) {
|
|
34
|
+
return anchor.getAttribute('href') ?? anchor.getAttribute('xlink:href');
|
|
35
|
+
}
|
|
36
|
+
/** Whether every line of the window contains its target line, case-insensitively. */
|
|
37
|
+
function windowMatches(texts, start, lines) {
|
|
38
|
+
return lines.every((line, index) => (texts[start + index]?.textContent ?? '').toLowerCase().includes(line));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Finds the node a deeplink target refers to inside one rendered diagram.
|
|
42
|
+
*
|
|
43
|
+
* The ladder, from most to least deterministic:
|
|
44
|
+
*
|
|
45
|
+
* 1. **Explicit id** — an element whose `id` equals the target. Graphviz emits these for
|
|
46
|
+
* `node [id="…"]`, giving a hidden, deterministic handle. (PlantUML's own `id`s are
|
|
47
|
+
* generated — `ent0001` — and deliberately not part of the contract.)
|
|
48
|
+
* 2. **PlantUML alias** — the engine writes every entity's alias into
|
|
49
|
+
* `data-qualified-name` on its `g.entity` group, so `component "X" as REACTION_1234`
|
|
50
|
+
* and `note "…" as REACTION_1234` are addressable by an id that never appears in the
|
|
51
|
+
* picture. This is PlantUML's spelling of a hidden deterministic id.
|
|
52
|
+
* 3. **Self-anchor** — an `<a>` whose href's hash parses to the same target, which doubles
|
|
53
|
+
* as the node's own permalink. Graphviz emits these for `node [URL="#graph?…"]`; the
|
|
54
|
+
* bundled PlantUML engine currently emits no anchors at all, so for PlantUML this level
|
|
55
|
+
* is future-proofing rather than an authoring channel.
|
|
56
|
+
* 4. **Graphviz node name** — the `<title>` Graphviz writes into every `g.node`, so plain
|
|
57
|
+
* DOT node names work with no annotation at all.
|
|
58
|
+
* 5. **Multiline label** — a target containing newlines (`%0A` in the URL) matches that many
|
|
59
|
+
* *consecutive* text lines, each containing its line of the target. The window stays
|
|
60
|
+
* inside one node's group where groups exist (Graphviz `g.node`, PlantUML `g.entity`)
|
|
61
|
+
* and runs over the document order otherwise — which is how a two-line label is told
|
|
62
|
+
* apart from two nodes that each contain one of the lines.
|
|
63
|
+
* 6. **Single-line substring** — the first text line containing the target,
|
|
64
|
+
* case-insensitively. This is what lets the unique half of a label (`12345` of
|
|
65
|
+
* `caminus-process-archive\n12345`) address the node without spelling out the rest.
|
|
66
|
+
*/
|
|
67
|
+
export function findDeeplinkTarget(root, target) {
|
|
68
|
+
// A target with a newline is a multiline label, never an id or alias — and selector
|
|
69
|
+
// engines reject the literal newline outright.
|
|
70
|
+
if (!target.includes('\n')) {
|
|
71
|
+
// 1. Explicit id. An attribute selector rather than #…: ids here are author-chosen and
|
|
72
|
+
// may start with a digit or contain characters a bare id selector would reject.
|
|
73
|
+
const byId = root.querySelector(`[id="${escapeAttributeValue(target)}"]`);
|
|
74
|
+
if (byId)
|
|
75
|
+
return { elements: [byId], anchor: byId };
|
|
76
|
+
// 2. PlantUML alias.
|
|
77
|
+
const byAlias = root.querySelector(`[data-qualified-name="${escapeAttributeValue(target)}"]`);
|
|
78
|
+
if (byAlias)
|
|
79
|
+
return { elements: [byAlias], anchor: byAlias };
|
|
80
|
+
}
|
|
81
|
+
// 3. Self-anchor.
|
|
82
|
+
for (const anchor of Array.from(root.querySelectorAll('a'))) {
|
|
83
|
+
const href = hrefOf(anchor);
|
|
84
|
+
const hashIndex = href?.indexOf('#') ?? -1;
|
|
85
|
+
if (href !== null && hashIndex >= 0 && parseDiagramHash(href.slice(hashIndex)) === target) {
|
|
86
|
+
return { elements: [anchor], anchor };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// 4. Graphviz node name.
|
|
90
|
+
for (const title of Array.from(root.querySelectorAll('g.node > title'))) {
|
|
91
|
+
const node = title.parentElement;
|
|
92
|
+
if (node && (title.textContent ?? '').trim() === target) {
|
|
93
|
+
return { elements: [node], anchor: node };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const lines = target
|
|
97
|
+
.split('\n')
|
|
98
|
+
.map((line) => line.trim().toLowerCase())
|
|
99
|
+
.filter((line) => line !== '');
|
|
100
|
+
if (lines.length === 0)
|
|
101
|
+
return null;
|
|
102
|
+
// 5. Multiline label.
|
|
103
|
+
if (lines.length > 1) {
|
|
104
|
+
const groups = Array.from(root.querySelectorAll('g.node, g.entity'));
|
|
105
|
+
const scopes = groups.length > 0 ? groups : [root];
|
|
106
|
+
for (const scope of scopes) {
|
|
107
|
+
const texts = Array.from(scope.querySelectorAll('text'));
|
|
108
|
+
for (let start = 0; start + lines.length <= texts.length; start += 1) {
|
|
109
|
+
if (windowMatches(texts, start, lines)) {
|
|
110
|
+
const window = texts.slice(start, start + lines.length);
|
|
111
|
+
return { elements: window, anchor: window[0] };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
// 6. Single-line substring.
|
|
118
|
+
const needle = lines[0];
|
|
119
|
+
for (const text of Array.from(root.querySelectorAll('text'))) {
|
|
120
|
+
if ((text.textContent ?? '').toLowerCase().includes(needle)) {
|
|
121
|
+
return { elements: [text], anchor: text };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Scrolls the page to the first figure that claims a given deeplink navigation — and only
|
|
128
|
+
* that one.
|
|
129
|
+
*
|
|
130
|
+
* Diagrams evaluate the hash independently, so without an arbiter every matching figure
|
|
131
|
+
* would fight over the scroll position. The caller keys the claim on one *navigation* to
|
|
132
|
+
* one target (history entry key + pathname + target): the many diagrams reacting to a
|
|
133
|
+
* single navigation yield a single scroll, while following the same deep link again — a new
|
|
134
|
+
* history entry, unchanged hash — scrolls again. Keying on the target alone got that second
|
|
135
|
+
* follow wrong: the stale claim swallowed the scroll. On a page whose diagrams were
|
|
136
|
+
* force-rendered by the hash, renders complete in mount order, so the first claimant is in
|
|
137
|
+
* practice the first matching figure in the document.
|
|
138
|
+
*/
|
|
139
|
+
let scrolledKey = null;
|
|
140
|
+
export function claimDeeplinkScroll(claimKey, figure) {
|
|
141
|
+
if (scrolledKey === claimKey)
|
|
142
|
+
return;
|
|
143
|
+
scrolledKey = claimKey;
|
|
144
|
+
figure.scrollIntoView({ block: 'center' });
|
|
145
|
+
}
|
|
146
|
+
/** Test-only: lets each test start with the scroll unclaimed. */
|
|
147
|
+
export function resetDeeplinkScroll() {
|
|
148
|
+
scrolledKey = null;
|
|
149
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthesizes the hyperlinks PlantUML's bundled engine drops.
|
|
3
|
+
*
|
|
4
|
+
* The engine parses `[[url]]` on components, participants and edges but emits no `<a>`
|
|
5
|
+
* elements, so authored links are silently lost. What it *does* emit is enough to put them
|
|
6
|
+
* back: every entity carries its alias in `data-qualified-name` and its source position in
|
|
7
|
+
* `data-source-line`, and every edge carries `data-source-line` plus its endpoints in
|
|
8
|
+
* `data-entity-1`/`data-entity-2`. This module extracts the links from the fence source the
|
|
9
|
+
* plugin already has, correlates each with the rendered element, and wraps that element in a
|
|
10
|
+
* real `<a>` — clickable, keyboard-focusable, and marked for styling.
|
|
11
|
+
*
|
|
12
|
+
* Correlation is a two-layer affair because of `!include`: the engine numbers lines in the
|
|
13
|
+
* *preprocessed* text, so a stdlib include shifts every `data-source-line` after it and
|
|
14
|
+
* line-equality silently stops holding. Aliases survive preprocessing untouched, which makes
|
|
15
|
+
* them the include-proof primary; exact line matching is the fallback that covers unnamed
|
|
16
|
+
* things (edges, mostly) in include-free sources. Everything here fails safe: a link that
|
|
17
|
+
* cannot be correlated attaches to nothing.
|
|
18
|
+
*/
|
|
19
|
+
export interface ExtractedLink {
|
|
20
|
+
/** Zero-based index of the line in the fence source, matching `data-source-line`. */
|
|
21
|
+
line: number;
|
|
22
|
+
/** The line's text, used for alias-candidate extraction. */
|
|
23
|
+
text: string;
|
|
24
|
+
href: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Schemes a synthesized link may carry. The engine's own output goes through DOMPurify, but
|
|
28
|
+
* these anchors are built by this module *after* sanitization, so the check lives here.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isSafeLinkHref(href: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Extracts at most one link per source line.
|
|
33
|
+
*
|
|
34
|
+
* One per line is a deliberate ceiling, not laziness: `data-source-line` identifies a line,
|
|
35
|
+
* not a token, so two links on one line cannot be told apart on the SVG side.
|
|
36
|
+
*/
|
|
37
|
+
export declare function extractSourceLinks(source: string): ExtractedLink[];
|
|
38
|
+
/**
|
|
39
|
+
* Attaches extracted links to the rendered SVG. Returns how many elements were wrapped.
|
|
40
|
+
*
|
|
41
|
+
* Resolution per link, in order:
|
|
42
|
+
*
|
|
43
|
+
* 1. **Alias** (include-proof): a declaration line's single candidate that names emitted
|
|
44
|
+
* `data-qualified-name` elements wraps all of them. An arrow line whose identifiers name
|
|
45
|
+
* exactly two entities wraps the `g.link` connecting them.
|
|
46
|
+
* 2. **Exact line**: elements whose `data-source-line` equals the author's line index —
|
|
47
|
+
* which holds whenever the source was not shifted by preprocessing.
|
|
48
|
+
*/
|
|
49
|
+
export declare function attachDiagramLinks(root: Element, links: ExtractedLink[]): number;
|
|
50
|
+
/** Undoes {@link attachDiagramLinks}: unwraps every synthesized anchor under `root`. */
|
|
51
|
+
export declare function detachDiagramLinks(root: Element): void;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthesizes the hyperlinks PlantUML's bundled engine drops.
|
|
3
|
+
*
|
|
4
|
+
* The engine parses `[[url]]` on components, participants and edges but emits no `<a>`
|
|
5
|
+
* elements, so authored links are silently lost. What it *does* emit is enough to put them
|
|
6
|
+
* back: every entity carries its alias in `data-qualified-name` and its source position in
|
|
7
|
+
* `data-source-line`, and every edge carries `data-source-line` plus its endpoints in
|
|
8
|
+
* `data-entity-1`/`data-entity-2`. This module extracts the links from the fence source the
|
|
9
|
+
* plugin already has, correlates each with the rendered element, and wraps that element in a
|
|
10
|
+
* real `<a>` — clickable, keyboard-focusable, and marked for styling.
|
|
11
|
+
*
|
|
12
|
+
* Correlation is a two-layer affair because of `!include`: the engine numbers lines in the
|
|
13
|
+
* *preprocessed* text, so a stdlib include shifts every `data-source-line` after it and
|
|
14
|
+
* line-equality silently stops holding. Aliases survive preprocessing untouched, which makes
|
|
15
|
+
* them the include-proof primary; exact line matching is the fallback that covers unnamed
|
|
16
|
+
* things (edges, mostly) in include-free sources. Everything here fails safe: a link that
|
|
17
|
+
* cannot be correlated attaches to nothing.
|
|
18
|
+
*/
|
|
19
|
+
import { DATA_ATTR } from '../../constants.js';
|
|
20
|
+
/**
|
|
21
|
+
* Schemes a synthesized link may carry. The engine's own output goes through DOMPurify, but
|
|
22
|
+
* these anchors are built by this module *after* sanitization, so the check lives here.
|
|
23
|
+
*/
|
|
24
|
+
export function isSafeLinkHref(href) {
|
|
25
|
+
if (/^(https?:)/i.test(href))
|
|
26
|
+
return true;
|
|
27
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href))
|
|
28
|
+
return false; // any other explicit scheme
|
|
29
|
+
return (href.startsWith('/') || href.startsWith('./') || href.startsWith('../') || href.startsWith('#'));
|
|
30
|
+
}
|
|
31
|
+
/** `[[href]]`, `[[href label]]`, `[[href{tooltip}]]` — the href ends at `{`, space, or `]]`. */
|
|
32
|
+
const LINK_PATTERN = /\[\[([^\s{\]]+)[^\]]*\]\]/;
|
|
33
|
+
/** Note lines carry body links the engine renders as styled text; nothing to attach. */
|
|
34
|
+
const NOTE_LINE = /^\s*[rh]?note\b/i;
|
|
35
|
+
/**
|
|
36
|
+
* Extracts at most one link per source line.
|
|
37
|
+
*
|
|
38
|
+
* One per line is a deliberate ceiling, not laziness: `data-source-line` identifies a line,
|
|
39
|
+
* not a token, so two links on one line cannot be told apart on the SVG side.
|
|
40
|
+
*/
|
|
41
|
+
export function extractSourceLinks(source) {
|
|
42
|
+
const links = [];
|
|
43
|
+
const lines = source.split(/\r\n|\r|\n/);
|
|
44
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
45
|
+
const text = lines[index];
|
|
46
|
+
if (NOTE_LINE.test(text))
|
|
47
|
+
continue;
|
|
48
|
+
const match = LINK_PATTERN.exec(text);
|
|
49
|
+
if (!match)
|
|
50
|
+
continue;
|
|
51
|
+
const href = match[1];
|
|
52
|
+
if (!isSafeLinkHref(href))
|
|
53
|
+
continue;
|
|
54
|
+
links.push({ line: index, text, href });
|
|
55
|
+
}
|
|
56
|
+
return links;
|
|
57
|
+
}
|
|
58
|
+
/** `as ALIAS` — the strongest signal a declaration line gives. */
|
|
59
|
+
const AS_ALIAS = /\bas\s+([A-Za-z_][\w]*)/;
|
|
60
|
+
/** C4-style macro: `Container(alias, …)` — the alias is the first argument. */
|
|
61
|
+
const MACRO_ALIAS = /^\s*[A-Za-z_][\w]*\s*\(\s*([A-Za-z_][\w]*)\s*[,)]/;
|
|
62
|
+
/** `component ALIAS [[…]]` — a declaration whose bare name is the alias. */
|
|
63
|
+
const DECL_ALIAS = /^\s*(?:component|actor|participant|database|queue|node|usecase|rectangle|interface|boundary|control|entity|collections|agent|artifact|card|cloud|file|folder|frame|hexagon|package|person|stack|storage|state|object|class|circle|label)\s+([A-Za-z_][\w]*)/i;
|
|
64
|
+
/** Lines with an arrow are relations; their identifiers name the two endpoints. */
|
|
65
|
+
const ARROW = /(-+[->]|\.+[.>]|<[-.]|[-.]{2,})/;
|
|
66
|
+
const IDENTIFIER = /[A-Za-z_][\w]*/g;
|
|
67
|
+
/**
|
|
68
|
+
* The alias candidates a link line offers, most specific first. `null` means the line's
|
|
69
|
+
* shape gives no safe candidates and only exact line matching may be used.
|
|
70
|
+
*/
|
|
71
|
+
function aliasCandidates(text) {
|
|
72
|
+
const as = AS_ALIAS.exec(text);
|
|
73
|
+
if (as)
|
|
74
|
+
return [as[1]];
|
|
75
|
+
const macro = MACRO_ALIAS.exec(text);
|
|
76
|
+
if (macro)
|
|
77
|
+
return [macro[1]];
|
|
78
|
+
const decl = DECL_ALIAS.exec(text);
|
|
79
|
+
if (decl)
|
|
80
|
+
return [decl[1]];
|
|
81
|
+
if (ARROW.test(text)) {
|
|
82
|
+
// Identifiers before the link only: the label after `:` may contain arbitrary words.
|
|
83
|
+
const beforeLink = text.slice(0, text.indexOf('[['));
|
|
84
|
+
return beforeLink.match(IDENTIFIER) ?? [];
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Participant lifelines run the full height of a sequence diagram; wrapping them would turn
|
|
90
|
+
* most of the background into a link.
|
|
91
|
+
*/
|
|
92
|
+
function isWrappable(element) {
|
|
93
|
+
return !(element.getAttribute('class') ?? '').includes('lifeline');
|
|
94
|
+
}
|
|
95
|
+
function wrap(element, href) {
|
|
96
|
+
if (!isWrappable(element))
|
|
97
|
+
return false;
|
|
98
|
+
if (element.closest('a'))
|
|
99
|
+
return false; // already linked — never nest anchors
|
|
100
|
+
const parent = element.parentNode;
|
|
101
|
+
if (!parent)
|
|
102
|
+
return false;
|
|
103
|
+
const anchor = element.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'a');
|
|
104
|
+
anchor.setAttribute('href', href);
|
|
105
|
+
anchor.setAttribute(DATA_ATTR.diagramLink, 'true');
|
|
106
|
+
parent.insertBefore(anchor, element);
|
|
107
|
+
anchor.appendChild(element);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Attaches extracted links to the rendered SVG. Returns how many elements were wrapped.
|
|
112
|
+
*
|
|
113
|
+
* Resolution per link, in order:
|
|
114
|
+
*
|
|
115
|
+
* 1. **Alias** (include-proof): a declaration line's single candidate that names emitted
|
|
116
|
+
* `data-qualified-name` elements wraps all of them. An arrow line whose identifiers name
|
|
117
|
+
* exactly two entities wraps the `g.link` connecting them.
|
|
118
|
+
* 2. **Exact line**: elements whose `data-source-line` equals the author's line index —
|
|
119
|
+
* which holds whenever the source was not shifted by preprocessing.
|
|
120
|
+
*/
|
|
121
|
+
export function attachDiagramLinks(root, links) {
|
|
122
|
+
const named = new Map();
|
|
123
|
+
for (const element of Array.from(root.querySelectorAll('[data-qualified-name]'))) {
|
|
124
|
+
const name = element.getAttribute('data-qualified-name');
|
|
125
|
+
const list = named.get(name);
|
|
126
|
+
if (list)
|
|
127
|
+
list.push(element);
|
|
128
|
+
else
|
|
129
|
+
named.set(name, [element]);
|
|
130
|
+
}
|
|
131
|
+
// Entity ids (`ent0001`) are what edges reference in `data-entity-1`/`data-entity-2`.
|
|
132
|
+
const idToName = new Map();
|
|
133
|
+
for (const [name, elements] of named) {
|
|
134
|
+
for (const element of elements) {
|
|
135
|
+
const id = element.getAttribute('id');
|
|
136
|
+
if (id)
|
|
137
|
+
idToName.set(id, name);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let wrapped = 0;
|
|
141
|
+
for (const link of links) {
|
|
142
|
+
const candidates = aliasCandidates(link.text);
|
|
143
|
+
// 1a. A single alias candidate that exists → wrap every element carrying it.
|
|
144
|
+
if (candidates !== null) {
|
|
145
|
+
const matching = candidates.filter((candidate) => named.has(candidate));
|
|
146
|
+
if (matching.length === 1) {
|
|
147
|
+
for (const element of named.get(matching[0]) ?? []) {
|
|
148
|
+
if (wrap(element, link.href))
|
|
149
|
+
wrapped += 1;
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// 1b. Exactly two entities on an arrow line → the edge between them.
|
|
154
|
+
if (matching.length === 2) {
|
|
155
|
+
const [a, b] = matching;
|
|
156
|
+
const edge = Array.from(root.querySelectorAll('g.link')).find((element) => {
|
|
157
|
+
const e1 = idToName.get(element.getAttribute('data-entity-1') ?? '');
|
|
158
|
+
const e2 = idToName.get(element.getAttribute('data-entity-2') ?? '');
|
|
159
|
+
return (e1 === a && e2 === b) || (e1 === b && e2 === a);
|
|
160
|
+
});
|
|
161
|
+
if (edge && wrap(edge, link.href)) {
|
|
162
|
+
wrapped += 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// 2. Exact line match — the fallback for unnamed elements in unshifted sources.
|
|
168
|
+
for (const element of Array.from(root.querySelectorAll(`[data-source-line="${link.line}"]`))) {
|
|
169
|
+
if (wrap(element, link.href))
|
|
170
|
+
wrapped += 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return wrapped;
|
|
174
|
+
}
|
|
175
|
+
/** Undoes {@link attachDiagramLinks}: unwraps every synthesized anchor under `root`. */
|
|
176
|
+
export function detachDiagramLinks(root) {
|
|
177
|
+
for (const anchor of Array.from(root.querySelectorAll(`a[${DATA_ATTR.diagramLink}]`))) {
|
|
178
|
+
const parent = anchor.parentNode;
|
|
179
|
+
if (!parent)
|
|
180
|
+
continue;
|
|
181
|
+
while (anchor.firstChild)
|
|
182
|
+
parent.insertBefore(anchor.firstChild, anchor);
|
|
183
|
+
anchor.remove();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -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,12 @@ 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 { useDiagramLinks } from './useDiagramLinks.js';
|
|
16
|
+
import { useDiagramSearch } from './useDiagramSearch.js';
|
|
11
17
|
import { useZoomPan } from './useZoomPan.js';
|
|
12
18
|
import styles from './styles.module.css';
|
|
13
19
|
/**
|
|
@@ -50,6 +56,9 @@ function messageFor(error) {
|
|
|
50
56
|
export default function PlantUmlDiagram({ source, title, language, engine = 'plantuml', layout, zoom: zoomProp, showSource: showSourceProp, }) {
|
|
51
57
|
const config = usePlantUmlConfig();
|
|
52
58
|
const { colorMode } = useColorMode();
|
|
59
|
+
// The router, not `window.location`: `<Link>` navigations are `history.pushState` calls
|
|
60
|
+
// that fire no DOM event, so only the router sees every way the hash can change.
|
|
61
|
+
const routerLocation = useLocation();
|
|
53
62
|
const containerRef = useRef(null);
|
|
54
63
|
const hintId = useId();
|
|
55
64
|
const sourcePanelId = useId();
|
|
@@ -68,8 +77,12 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
68
77
|
const interactive = zoomProp ?? config?.options.zoom ?? true;
|
|
69
78
|
const sourceAvailable = showSourceProp ?? config?.options.showSource ?? true;
|
|
70
79
|
const [state, setState] = useState(INITIAL_STATE);
|
|
71
|
-
|
|
80
|
+
// A diagram deep link must be able to reach a diagram below the fold, so a `#graph?…`
|
|
81
|
+
// hash defeats lazy rendering. The router's server-side location has no hash, and the
|
|
82
|
+
// first paint is the placeholder either way, so hydration cannot mismatch.
|
|
83
|
+
const [inView, setInView] = useState(() => !lazy || parseDiagramHash(routerLocation.hash) !== null);
|
|
72
84
|
const [sourceOpen, setSourceOpen] = useState(false);
|
|
85
|
+
const [minimapOpen, setMinimapOpen] = useState(false);
|
|
73
86
|
const [copyState, setCopyState] = useState('idle');
|
|
74
87
|
const copyTimer = useRef(undefined);
|
|
75
88
|
// A pending "Copied" message must not fire into an unmounted component.
|
|
@@ -107,6 +120,55 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
107
120
|
// gratuitous jump.
|
|
108
121
|
resetKey: `${source}|${engine}|${resolvedLayout}|${renderDark ? 'dark' : 'light'}`,
|
|
109
122
|
});
|
|
123
|
+
const search = useDiagramSearch({
|
|
124
|
+
enabled: interactive && state.status === 'ready',
|
|
125
|
+
zoom,
|
|
126
|
+
svg: state.svg,
|
|
127
|
+
});
|
|
128
|
+
useDiagramDeeplink({
|
|
129
|
+
ready: state.status === 'ready' && state.svg !== null,
|
|
130
|
+
svg: state.svg,
|
|
131
|
+
interactive,
|
|
132
|
+
zoom,
|
|
133
|
+
containerRef,
|
|
134
|
+
});
|
|
135
|
+
useDiagramLinks({
|
|
136
|
+
ready: state.status === 'ready' && state.svg !== null,
|
|
137
|
+
svg: state.svg,
|
|
138
|
+
engine,
|
|
139
|
+
source,
|
|
140
|
+
containerRef,
|
|
141
|
+
});
|
|
142
|
+
// A hash arriving *after* mount must defeat lazy rendering too — a deep link followed
|
|
143
|
+
// from another diagram on the same page may point below the fold. Driven by the router
|
|
144
|
+
// location rather than `hashchange`, which `<Link>` navigations never fire.
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!inView && parseDiagramHash(routerLocation.hash) !== null)
|
|
147
|
+
setInView(true);
|
|
148
|
+
}, [inView, routerLocation.hash]);
|
|
149
|
+
const searchToggleRef = useRef(null);
|
|
150
|
+
// Escape and the ✕ hand focus back to the toggle, so the keyboard is not left stranded on
|
|
151
|
+
// a control that just left the page.
|
|
152
|
+
const closeSearch = useCallback(() => {
|
|
153
|
+
search.close();
|
|
154
|
+
searchToggleRef.current?.focus();
|
|
155
|
+
}, [search]);
|
|
156
|
+
const onSearchKeyDown = useCallback((event) => {
|
|
157
|
+
if (event.key === 'Enter') {
|
|
158
|
+
event.preventDefault();
|
|
159
|
+
if (event.shiftKey)
|
|
160
|
+
search.previous();
|
|
161
|
+
else
|
|
162
|
+
search.next();
|
|
163
|
+
}
|
|
164
|
+
else if (event.key === 'Escape') {
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
// Escape in the search closes the search. Without this, the document-level listener
|
|
167
|
+
// would also un-maximize the diagram in the same keystroke.
|
|
168
|
+
event.stopPropagation();
|
|
169
|
+
closeSearch();
|
|
170
|
+
}
|
|
171
|
+
}, [closeSearch, search]);
|
|
110
172
|
useEffect(() => {
|
|
111
173
|
if (!lazy || inView)
|
|
112
174
|
return undefined;
|
|
@@ -201,11 +263,18 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
201
263
|
]);
|
|
202
264
|
const label = title ?? `${engineName} diagram`;
|
|
203
265
|
const busy = state.status === 'loading' || state.status === 'rendering';
|
|
266
|
+
/**
|
|
267
|
+
* One wrapper object per SVG string, not a fresh literal in the JSX: React 19 compares
|
|
268
|
+
* `dangerouslySetInnerHTML` by the wrapper's identity, not by `__html`, so an inline
|
|
269
|
+
* literal makes every re-render of the figure re-parse the whole SVG subtree — visibly
|
|
270
|
+
* so once anything marks the rendered elements, as the search's highlights do.
|
|
271
|
+
*/
|
|
272
|
+
const svgHtml = useMemo(() => (state.svg === null ? null : { __html: state.svg }), [state.svg]);
|
|
204
273
|
// The sanitized SVG always lives in this element, zoomable or not, so `role="img" > svg`
|
|
205
274
|
// 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,
|
|
275
|
+
const canvas = state.svg !== null && svgHtml !== null && (_jsx("div", { className: !interactive && sourceOpen ? `${styles.canvas} ${styles.hiddenView}` : styles.canvas, role: "img", "aria-label": label,
|
|
207
276
|
// Sanitized above unless `sanitizeSvg: false` was explicitly opted into.
|
|
208
|
-
dangerouslySetInnerHTML:
|
|
277
|
+
dangerouslySetInnerHTML: svgHtml }));
|
|
209
278
|
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
279
|
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
280
|
/**
|
|
@@ -225,7 +294,11 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
225
294
|
[DATA_ATTR.theme]: dark ? 'dark' : 'light',
|
|
226
295
|
...(interactive ? { [DATA_ATTR.interactive]: 'true' } : {}),
|
|
227
296
|
...(zoom.maximized ? { [DATA_ATTR.maximized]: 'true' } : {}),
|
|
228
|
-
...(sourceOpen ? { [DATA_ATTR.sourceOpen]: 'true' } : {}),
|
|
297
|
+
...(sourceOpen ? { [DATA_ATTR.sourceOpen]: 'true' } : {}),
|
|
298
|
+
...(minimapOpen ? { [DATA_ATTR.minimapOpen]: 'true' } : {}),
|
|
299
|
+
...(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
|
|
300
|
+
? '0/0'
|
|
301
|
+
: `${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
302
|
// `aria-busy` on the figure already conveys progress; announcing every phase change
|
|
230
303
|
// would make a page with several diagrams unusable with a screen reader.
|
|
231
304
|
_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: {
|