@matfsw/docusaurus-plantuml-plugin 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -1
- package/README.md +106 -13
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +10 -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/icons.d.ts +13 -0
- package/dist/theme/PlantUmlDiagram/icons.js +23 -0
- package/dist/theme/PlantUmlDiagram/index.js +71 -6
- package/dist/theme/PlantUmlDiagram/styles.module.css +180 -2
- package/dist/theme/PlantUmlDiagram/useDiagramDeeplink.d.ts +32 -0
- package/dist/theme/PlantUmlDiagram/useDiagramDeeplink.js +54 -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
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.4.0] - 2026-08-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **A Fit control in the maximized view.** While a diagram fills the screen, one press
|
|
15
|
+
returns it to the fitted scale — the whole diagram filling the screen, magnified or shrunk
|
|
16
|
+
as the picture demands — after zooming or panning away from it. Maximizing itself now
|
|
17
|
+
opens at that truly fitted scale too: the view's content measurement tracks the rendered
|
|
18
|
+
picture rather than the full-width frame, which is also what tightens the minimap around
|
|
19
|
+
the actual diagram. (The pan clamp deliberately keeps its old frame-wide bounds, so focal
|
|
20
|
+
wheel zoom holds the point under the pointer exactly as before.) The button is
|
|
21
|
+
deliberately absent from the inline toolbar, where the frame grows with the diagram and a
|
|
22
|
+
fit would merely duplicate Reset; it sits between Reset and Maximize, is drawn as inline
|
|
23
|
+
SVG like the rest of the toolbar, and carries `aria-label="Fit diagram to screen"`.
|
|
24
|
+
|
|
25
|
+
- **A minimap.** A new toggle in the bottom-left corner opens a small copy of the diagram
|
|
26
|
+
with a rectangle marking what the viewport currently shows; pressing or dragging anywhere
|
|
27
|
+
on the map centres the view there, so a single press doubles as "jump there". The map
|
|
28
|
+
follows every zoom, pan and resize without re-rendering the figure — it subscribes to the
|
|
29
|
+
same imperative transform writes the zoom hook itself uses — and works while maximized.
|
|
30
|
+
The figure carries `data-plantuml-minimap-open="true"` while the map is up.
|
|
31
|
+
|
|
32
|
+
The map is pointer-only and hidden from assistive technology on purpose: the real viewport
|
|
33
|
+
is already keyboard-operable, so a second, duplicated view of the same diagram would add
|
|
34
|
+
noise without adding a capability. Its close button stays outside the hidden subtree and
|
|
35
|
+
remains focusable.
|
|
36
|
+
|
|
37
|
+
- **Search within a diagram.** The new lens button opens a search bar beside the toolbar: a
|
|
38
|
+
case-insensitive substring search over the rendered SVG's text. Every match is marked with
|
|
39
|
+
`data-plantuml-search-match` and highlighted from the stylesheet — the cached SVG string is
|
|
40
|
+
never mutated — and the current match additionally carries `data-plantuml-search-current`.
|
|
41
|
+
Enter and the chevron buttons step through the matches (Shift+Enter backwards), each step
|
|
42
|
+
centres the view on its match at the current zoom level, and Escape or ✕ closes the bar
|
|
43
|
+
and sweeps every highlight. The figure carries `data-plantuml-search-open="true"` while
|
|
44
|
+
the bar is up.
|
|
45
|
+
|
|
46
|
+
- **Deep links into diagrams.** A URL hash of the form `#graph?highlight-node=REACTION_1234`
|
|
47
|
+
focuses a node: every diagram on the page reacts - none needs an id of its own, and a
|
|
48
|
+
diagram without the node does nothing - the page scrolls to the first matching figure, the
|
|
49
|
+
node is marked with `data-plantuml-focused-node` and highlighted from the stylesheet, and
|
|
50
|
+
a zoomable diagram snaps to 100% centred on it. The hash is watched through the router, so
|
|
51
|
+
every kind of navigation reacts — pushed, replaced, popped and native `#…` clicks alike,
|
|
52
|
+
including a `<Link>` navigation that *drops* the hash, which sweeps the highlight — and a
|
|
53
|
+
`#graph?…` hash defeats lazy loading so below-the-fold targets still react.
|
|
54
|
+
|
|
55
|
+
The identifier resolves through a ladder - explicit SVG `id` (Graphviz `node [id="…"]`),
|
|
56
|
+
the PlantUML alias (`component "X" as REACTION_1234`, aliased notes included, via the
|
|
57
|
+
`data-qualified-name` the engine writes into its SVG), a self-anchor link
|
|
58
|
+
(Graphviz `URL="#graph?…"`, which doubles as the node's own permalink when clicked),
|
|
59
|
+
Graphviz node names via their `<title>`, multiline labels with `%0A`-encoded newlines
|
|
60
|
+
matched against consecutive text lines within one node's group, and finally a
|
|
61
|
+
case-insensitive substring of a single line. The first level that matches wins, so a
|
|
62
|
+
deterministic id always beats loose text matching.
|
|
63
|
+
|
|
64
|
+
Alongside this, Graphviz author links are pinned by tests, and sanitization keeps
|
|
65
|
+
`target="_top"` on links instead of silently dropping it. One engine limitation surfaced
|
|
66
|
+
and is now documented: the bundled PlantUML engine renders `[[url]]` link text but emits
|
|
67
|
+
no `<a>` elements, so PlantUML links are not clickable - which is why PlantUML deep-link
|
|
68
|
+
ids ride on aliases rather than on links.
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- **The rendered SVG is no longer re-parsed on every re-render under React 19.** React 19
|
|
73
|
+
compares `dangerouslySetInnerHTML` by the wrapper object's identity rather than by its
|
|
74
|
+
`__html` string, so the inline `{{__html: svg}}` literal made every state change — opening
|
|
75
|
+
the source view, maximizing, a copy confirmation — throw away and re-parse the entire SVG
|
|
76
|
+
subtree. The wrapper is now memoized per SVG string. This is also what makes the search's
|
|
77
|
+
in-DOM highlights survive unrelated re-renders.
|
|
78
|
+
|
|
10
79
|
## [1.3.1]
|
|
11
80
|
|
|
12
81
|
### Fixed
|
|
@@ -380,7 +449,8 @@ Initial release.
|
|
|
380
449
|
opt-out.
|
|
381
450
|
- Plugin option validation that rejects unknown keys and out-of-range values at build time.
|
|
382
451
|
|
|
383
|
-
[Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.
|
|
452
|
+
[Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.4.0...HEAD
|
|
453
|
+
[1.4.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.3.1...v1.4.0
|
|
384
454
|
[1.3.1]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.3.0...v1.3.1
|
|
385
455
|
[1.3.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.2.0...v1.3.0
|
|
386
456
|
[1.2.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.1.2...v1.2.0
|
package/README.md
CHANGED
|
@@ -529,18 +529,24 @@ pannable by default, with a small control toolbar in the top-right corner.
|
|
|
529
529
|
|
|
530
530
|
### Interaction
|
|
531
531
|
|
|
532
|
-
| Input | What happens
|
|
533
|
-
| ----------------------------------- |
|
|
534
|
-
| Plain scroll wheel | Scrolls the page. Never intercepted.
|
|
535
|
-
| **Ctrl** + wheel, or trackpad pinch | Zooms about the pointer
|
|
536
|
-
| Drag | Pans, once zoomed in
|
|
537
|
-
| One finger on a touchscreen | Scrolls the page
|
|
538
|
-
| Two-finger pinch on a touchscreen | The browser's own page zoom
|
|
539
|
-
| Toolbar buttons | Zoom out, zoom in, reset, maximize
|
|
532
|
+
| Input | What happens |
|
|
533
|
+
| ----------------------------------- | -------------------------------------------------------------- |
|
|
534
|
+
| Plain scroll wheel | Scrolls the page. Never intercepted. |
|
|
535
|
+
| **Ctrl** + wheel, or trackpad pinch | Zooms about the pointer |
|
|
536
|
+
| Drag | Pans, once zoomed in |
|
|
537
|
+
| One finger on a touchscreen | Scrolls the page |
|
|
538
|
+
| Two-finger pinch on a touchscreen | The browser's own page zoom |
|
|
539
|
+
| Toolbar buttons | Zoom out, zoom in, reset, maximize — plus fit, while maximized |
|
|
540
540
|
|
|
541
541
|
`Cmd` + wheel is deliberately **not** intercepted: on macOS that is the browser's own page
|
|
542
542
|
zoom, and taking it over would fight the platform.
|
|
543
543
|
|
|
544
|
+
**Fit** appears only while the diagram is maximized: it scales the diagram to fill the
|
|
545
|
+
screen — the fitted view maximizing opens with — after you have zoomed or panned away from
|
|
546
|
+
it. It is deliberately absent from the inline toolbar: the inline frame grows with the
|
|
547
|
+
diagram, so at 100% everything is already visible and a fit would merely duplicate
|
|
548
|
+
**Reset**.
|
|
549
|
+
|
|
544
550
|
### Keyboard
|
|
545
551
|
|
|
546
552
|
The diagram viewport is focusable. Once focused:
|
|
@@ -566,11 +572,37 @@ space instead and keeps it visible as long as possible.
|
|
|
566
572
|
Scale is limited to 0.25×–8×, and the view resets to 100% whenever the diagram source or the
|
|
567
573
|
site colour mode changes.
|
|
568
574
|
|
|
575
|
+
### Search
|
|
576
|
+
|
|
577
|
+
The lens button opens a search bar beside the toolbar. Matching is a case-insensitive
|
|
578
|
+
substring search over the rendered SVG's text, so what you see is what you are searching.
|
|
579
|
+
Every match is highlighted, the current one distinctly; the counter shows where you are;
|
|
580
|
+
<kbd>Enter</kbd> / the chevrons step through the matches, <kbd>Shift</kbd>+<kbd>Enter</kbd>
|
|
581
|
+
steps backwards, and each step pans the view so the match is centred — at whatever zoom
|
|
582
|
+
level you are on. <kbd>Escape</kbd> or ✕ closes the bar and removes every highlight.
|
|
583
|
+
|
|
584
|
+
Matches are marked with `data-plantuml-search-match` (and the current one additionally with
|
|
585
|
+
`data-plantuml-search-current`) and coloured from the stylesheet, so site CSS can restyle the
|
|
586
|
+
highlight without touching the plugin.
|
|
587
|
+
|
|
588
|
+
### Minimap
|
|
589
|
+
|
|
590
|
+
The button in the bottom-left corner opens a minimap: a small copy of the diagram with a
|
|
591
|
+
rectangle marking what the viewport currently shows. Press or drag anywhere on the map to
|
|
592
|
+
centre the view there — a single press works as "jump there". The map follows every zoom,
|
|
593
|
+
pan and resize, works while maximized, and closes from its own ✕ or the toggle.
|
|
594
|
+
|
|
595
|
+
The map is pointer-only and hidden from assistive technology on purpose: the real viewport is
|
|
596
|
+
already keyboard-operable with the arrow keys, so announcing a second, duplicated view of the
|
|
597
|
+
same diagram would add noise without adding a capability.
|
|
598
|
+
|
|
569
599
|
### Maximizing
|
|
570
600
|
|
|
571
|
-
The
|
|
572
|
-
|
|
573
|
-
|
|
601
|
+
The maximize button expands the diagram to fill the browser viewport over an opaque
|
|
602
|
+
background, opening at the fitted scale — magnified or shrunk so the whole diagram fills the
|
|
603
|
+
screen. The **fit** button (present only in this view) returns to that scale after zooming
|
|
604
|
+
or panning. <kbd>Escape</kbd> or the maximize button restores the inline view, along with
|
|
605
|
+
whatever zoom level you had before.
|
|
574
606
|
|
|
575
607
|
This is an in-page overlay rather than the Fullscreen API. `requestFullscreen()` takes the
|
|
576
608
|
entire browser window fullscreen in Firefox instead of presenting the element, and its
|
|
@@ -601,8 +633,8 @@ Alice -> Bob : Hello
|
|
|
601
633
|
A reader who needs to zoom is rarely the author who would have enabled it, so leaving it off
|
|
602
634
|
would mean most readers never discover it. The costs are real and worth knowing:
|
|
603
635
|
|
|
604
|
-
- Each zoomable diagram adds
|
|
605
|
-
|
|
636
|
+
- Each zoomable diagram adds a **keyboard tab stop for the viewport and one per toolbar
|
|
637
|
+
button**. On a page with six diagrams that is a meaningful amount of tabbing.
|
|
606
638
|
- The rendered markup gains a viewport and transform wrapper around the `role="img"` container.
|
|
607
639
|
Site CSS that targets `[data-plantuml-diagram] > div[role="img"]` as a **direct child** needs
|
|
608
640
|
updating — see [Accessibility](#accessibility) for both shapes.
|
|
@@ -663,6 +695,63 @@ before either feature existed.
|
|
|
663
695
|
This is separate from `showSourceOnError`, which offers the source in the error panel when a
|
|
664
696
|
diagram _fails_ to render.
|
|
665
697
|
|
|
698
|
+
## Links and deep links
|
|
699
|
+
|
|
700
|
+
### Links written in the diagram source
|
|
701
|
+
|
|
702
|
+
**Graphviz** emits real hyperlinks: `URL=`/`href=` on a node or edge becomes an `<a>` in the
|
|
703
|
+
rendered SVG, preserved through sanitization (`javascript:` URLs are stripped). Links stay
|
|
704
|
+
clickable inside the zoomable viewport: a plain click follows the link, while a click that
|
|
705
|
+
ends a drag does not.
|
|
706
|
+
|
|
707
|
+
````markdown
|
|
708
|
+
```dot
|
|
709
|
+
digraph { docs [URL="https://graphviz.org/doc/info/attrs.html"]; }
|
|
710
|
+
```
|
|
711
|
+
````
|
|
712
|
+
|
|
713
|
+
**PlantUML** links are an engine limitation today: the bundled browser engine renders
|
|
714
|
+
`[[url]]` link _text_ (styled blue and underlined) but emits no `<a>` elements, so PlantUML
|
|
715
|
+
links are not clickable. Deep-link addressing does not depend on them - PlantUML nodes are
|
|
716
|
+
addressed by alias, below. Should the engine gain anchor output, links will pass through
|
|
717
|
+
sanitization unchanged (that path is pinned by tests, `target="_top"` included).
|
|
718
|
+
|
|
719
|
+
### Deep links: `#graph?highlight-node=…`
|
|
720
|
+
|
|
721
|
+
A URL hash of the form `#graph?highlight-node=REACTION_1234` focuses a node:
|
|
722
|
+
|
|
723
|
+
- **Every diagram on the page reacts** - no diagram needs an id of its own. A diagram that
|
|
724
|
+
does not contain the node simply does nothing.
|
|
725
|
+
- The page scrolls to the first matching figure, the node is visibly highlighted
|
|
726
|
+
(`data-plantuml-focused-node`, restylable from site CSS), and a zoomable diagram snaps to
|
|
727
|
+
**100% centred on the node**. The highlight stays until the hash changes.
|
|
728
|
+
- The hash is watched live, so following a `#graph?…` link anywhere on the page - including
|
|
729
|
+
one inside a Graphviz diagram - focuses the node without a reload.
|
|
730
|
+
- A `#graph?…` hash defeats lazy loading for the page's diagrams, so a target below the
|
|
731
|
+
fold still reacts. That costs the render of every diagram on the page, exactly as
|
|
732
|
+
`lazy: false` would.
|
|
733
|
+
|
|
734
|
+
The identifier is resolved per diagram through a ladder; the first level that matches wins:
|
|
735
|
+
|
|
736
|
+
| # | Level | Works in | Authoring |
|
|
737
|
+
| --- | ----------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
738
|
+
| 1 | Explicit SVG `id` | Graphviz | `node [id="REACTION-1234"]` |
|
|
739
|
+
| 2 | PlantUML alias | PlantUML | `component "X" as REACTION_1234`, `note "…" as REACTION_1234` - the alias lands in the SVG as `data-qualified-name` |
|
|
740
|
+
| 3 | Self-anchor link | Graphviz | `node [URL="#graph?highlight-node=REACTION-1234"]` - carries the id _and_, being a real link, clicking the node mints its own permalink |
|
|
741
|
+
| 4 | Node name | Graphviz | plain DOT node names, via the `<title>` Graphviz emits |
|
|
742
|
+
| 5 | Multiline label | both | `%0A` encodes the newline: `#graph?highlight-node=Archive%0A12345` matches _consecutive_ label lines, kept within one node's group |
|
|
743
|
+
| 6 | Substring | both | any text line containing the identifier, case-insensitively |
|
|
744
|
+
|
|
745
|
+
Level 2 is the PlantUML spelling of a hidden, deterministic id: the alias never appears in
|
|
746
|
+
the picture, but the engine writes it onto the entity's group - and aliased **notes** get
|
|
747
|
+
one too, so `note "…" as MYNOTE_123` makes the note itself navigable. PlantUML aliases are
|
|
748
|
+
identifiers: use underscores (`REACTION_1234`), not hyphens, which do not parse.
|
|
749
|
+
|
|
750
|
+
Level 6 is what makes an unannotated node reachable by the unique part of its label - for a
|
|
751
|
+
node labelled `caminus-process-archive\n12345`, the hash `#graph?highlight-node=12345`
|
|
752
|
+
suffices. Levels 1-4 exist so that a deterministic, author-chosen identifier always beats
|
|
753
|
+
loose text matching.
|
|
754
|
+
|
|
666
755
|
## Lazy loading
|
|
667
756
|
|
|
668
757
|
With `lazy: true` (the default), a diagram is not rendered until it scrolls within 300 px of
|
|
@@ -1164,6 +1253,10 @@ package.
|
|
|
1164
1253
|
- **Diagrams are not rendered during static-site generation.** The HTML Docusaurus emits
|
|
1165
1254
|
contains the deferred placeholder and the `<noscript>` source. Search engines that do not
|
|
1166
1255
|
execute JavaScript will not see the diagram image.
|
|
1256
|
+
- **PlantUML `[[url]]` hyperlinks are not clickable.** The bundled browser engine renders
|
|
1257
|
+
the link text (styled blue and underlined) but emits no `<a>` elements into its SVG.
|
|
1258
|
+
Graphviz links work; PlantUML deep-link addressing works through aliases and does not
|
|
1259
|
+
need links. See [Links and deep links](#links-and-deep-links).
|
|
1167
1260
|
- **One PlantUML diagram renders at a time.** The PlantUML engine has module-level shared
|
|
1168
1261
|
state, so a page with many large diagrams renders them sequentially. This is a correctness
|
|
1169
1262
|
requirement, not a tuning knob. Graphviz has no such constraint and is not queued.
|
package/dist/constants.d.ts
CHANGED
|
@@ -41,4 +41,14 @@ export declare const DATA_ATTR: {
|
|
|
41
41
|
readonly maximized: "data-plantuml-maximized";
|
|
42
42
|
/** `"true"` on the figure while the source panel is open. */
|
|
43
43
|
readonly sourceOpen: "data-plantuml-source-open";
|
|
44
|
+
/** `"true"` on the figure while the minimap is open. */
|
|
45
|
+
readonly minimapOpen: "data-plantuml-minimap-open";
|
|
46
|
+
/** `"true"` on the figure while the search bar is open. */
|
|
47
|
+
readonly searchOpen: "data-plantuml-search-open";
|
|
48
|
+
/** On every `<text>` element in the rendered SVG that matches the search query. */
|
|
49
|
+
readonly searchMatch: "data-plantuml-search-match";
|
|
50
|
+
/** On the one match the search is currently focused on; always also a `searchMatch`. */
|
|
51
|
+
readonly searchCurrent: "data-plantuml-search-current";
|
|
52
|
+
/** On the node (or its text lines) a `#graph?highlight-node=…` deep link resolved to. */
|
|
53
|
+
readonly focusedNode: "data-plantuml-focused-node";
|
|
44
54
|
};
|
package/dist/constants.js
CHANGED
|
@@ -43,4 +43,14 @@ export const DATA_ATTR = {
|
|
|
43
43
|
maximized: 'data-plantuml-maximized',
|
|
44
44
|
/** `"true"` on the figure while the source panel is open. */
|
|
45
45
|
sourceOpen: 'data-plantuml-source-open',
|
|
46
|
+
/** `"true"` on the figure while the minimap is open. */
|
|
47
|
+
minimapOpen: 'data-plantuml-minimap-open',
|
|
48
|
+
/** `"true"` on the figure while the search bar is open. */
|
|
49
|
+
searchOpen: 'data-plantuml-search-open',
|
|
50
|
+
/** On every `<text>` element in the rendered SVG that matches the search query. */
|
|
51
|
+
searchMatch: 'data-plantuml-search-match',
|
|
52
|
+
/** On the one match the search is currently focused on; always also a `searchMatch`. */
|
|
53
|
+
searchCurrent: 'data-plantuml-search-current',
|
|
54
|
+
/** On the node (or its text lines) a `#graph?highlight-node=…` deep link resolved to. */
|
|
55
|
+
focusedNode: 'data-plantuml-focused-node',
|
|
46
56
|
};
|
package/dist/runtime/sanitize.js
CHANGED
|
@@ -30,7 +30,11 @@ export function sanitizeSvgMarkup(svg) {
|
|
|
30
30
|
USE_PROFILES: { svg: true, svgFilters: true },
|
|
31
31
|
FORBID_TAGS: FORBIDDEN_TAGS,
|
|
32
32
|
FORBID_ATTR: ['formaction', 'xlink:show', 'ping'],
|
|
33
|
-
|
|
33
|
+
// `target` is in DOMPurify's HTML profile but not its SVG profile, and PlantUML puts
|
|
34
|
+
// `target="_top"` on every `[[link]]` it renders. The attribute carries no script
|
|
35
|
+
// surface — modern browsers imply `noopener` for `target="_blank"` — so it is allowed
|
|
36
|
+
// rather than silently rewriting where the author's links open.
|
|
37
|
+
ADD_ATTR: ['role', 'aria-label', 'aria-labelledby', 'aria-describedby', 'target'],
|
|
34
38
|
ALLOW_DATA_ATTR: true,
|
|
35
39
|
KEEP_CONTENT: true,
|
|
36
40
|
RETURN_DOM: false,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { ZoomPanHandle } from './useZoomPan.js';
|
|
3
|
+
export interface MinimapProps {
|
|
4
|
+
/** The rendered diagram, already sanitized — the same string the canvas shows. */
|
|
5
|
+
svg: string;
|
|
6
|
+
zoom: ZoomPanHandle;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
}
|
|
9
|
+
export default function Minimap({ svg, zoom, onClose }: MinimapProps): ReactNode;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import { CloseIcon } from './icons.js';
|
|
4
|
+
import { centerViewportOn, minimapScale, visibleContentRect } from './zoomMath.js';
|
|
5
|
+
import styles from './styles.module.css';
|
|
6
|
+
/**
|
|
7
|
+
* The minimap: a small copy of the diagram with a rectangle marking what the viewport
|
|
8
|
+
* currently shows. Pressing or dragging anywhere on the map centres the view there.
|
|
9
|
+
*
|
|
10
|
+
* Everything that changes per pan frame — the map's sizes and the rectangle — is written
|
|
11
|
+
* straight to the DOM through `zoom.subscribe`, for the same reason `useZoomPan` writes its
|
|
12
|
+
* transform imperatively: mirroring a per-frame value through React state would re-render the
|
|
13
|
+
* whole figure once per frame of every drag.
|
|
14
|
+
*
|
|
15
|
+
* The map handles the pointer itself rather than making the rectangle the draggable thing:
|
|
16
|
+
* a rectangle a few pixels wide would be a hopeless drag target, and centring on the pressed
|
|
17
|
+
* point makes a single click work as "jump there" with no extra code path.
|
|
18
|
+
*/
|
|
19
|
+
/** The box the diagram is scaled into. The map is smaller when the aspect ratio demands it. */
|
|
20
|
+
const MAP_MAX_WIDTH = 200;
|
|
21
|
+
const MAP_MAX_HEIGHT = 150;
|
|
22
|
+
export default function Minimap({ svg, zoom, onClose }) {
|
|
23
|
+
// React 19 compares `dangerouslySetInnerHTML` by wrapper identity, not by `__html`; an
|
|
24
|
+
// inline literal would re-parse the copy on every re-render of the figure.
|
|
25
|
+
const svgHtml = useMemo(() => ({ __html: svg }), [svg]);
|
|
26
|
+
const canvasRef = useRef(null);
|
|
27
|
+
const layerRef = useRef(null);
|
|
28
|
+
const rectRef = useRef(null);
|
|
29
|
+
const scaleRef = useRef(0);
|
|
30
|
+
const update = useCallback(() => {
|
|
31
|
+
const canvas = canvasRef.current;
|
|
32
|
+
const layer = layerRef.current;
|
|
33
|
+
const rect = rectRef.current;
|
|
34
|
+
if (!canvas || !layer || !rect)
|
|
35
|
+
return;
|
|
36
|
+
const bounds = zoom.measure();
|
|
37
|
+
const scale = minimapScale(bounds, MAP_MAX_WIDTH, MAP_MAX_HEIGHT);
|
|
38
|
+
scaleRef.current = scale;
|
|
39
|
+
// Nothing measurable yet (jsdom, or a picture that has not laid out): keep the box at
|
|
40
|
+
// its maximum size rather than collapsing to a zero-sized, unclickable sliver.
|
|
41
|
+
if (scale <= 0) {
|
|
42
|
+
canvas.style.width = `${MAP_MAX_WIDTH}px`;
|
|
43
|
+
canvas.style.height = `${MAP_MAX_HEIGHT}px`;
|
|
44
|
+
rect.style.width = '0px';
|
|
45
|
+
rect.style.height = '0px';
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
canvas.style.width = `${bounds.contentWidth * scale}px`;
|
|
49
|
+
canvas.style.height = `${bounds.contentHeight * scale}px`;
|
|
50
|
+
// The copy is laid out at the content's own width and scaled down as one block, so it
|
|
51
|
+
// keeps exactly the layout — and therefore the geometry — of the real canvas.
|
|
52
|
+
layer.style.width = `${bounds.contentWidth}px`;
|
|
53
|
+
layer.style.transform = `scale(${scale})`;
|
|
54
|
+
const visible = visibleContentRect(zoom.getTransform(), bounds);
|
|
55
|
+
rect.style.left = `${visible.x * scale}px`;
|
|
56
|
+
rect.style.top = `${visible.y * scale}px`;
|
|
57
|
+
rect.style.width = `${visible.width * scale}px`;
|
|
58
|
+
rect.style.height = `${visible.height * scale}px`;
|
|
59
|
+
}, [zoom]);
|
|
60
|
+
// `svg` is a dependency so a re-rendered picture re-measures once its new layout is
|
|
61
|
+
// committed; the subscription covers every transform change in between.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
update();
|
|
64
|
+
return zoom.subscribe(update);
|
|
65
|
+
}, [svg, update, zoom]);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const canvas = canvasRef.current;
|
|
68
|
+
if (!canvas)
|
|
69
|
+
return undefined;
|
|
70
|
+
let pointerId = null;
|
|
71
|
+
const panTo = (event) => {
|
|
72
|
+
const scale = scaleRef.current;
|
|
73
|
+
if (scale <= 0)
|
|
74
|
+
return;
|
|
75
|
+
const box = canvas.getBoundingClientRect();
|
|
76
|
+
zoom.applyTransform(centerViewportOn(zoom.getTransform(), (event.clientX - box.left) / scale, (event.clientY - box.top) / scale, zoom.measure()));
|
|
77
|
+
};
|
|
78
|
+
const setContinuous = (on) => {
|
|
79
|
+
// The main layer eases discrete zoom steps; a drag tracked through the minimap is
|
|
80
|
+
// direct manipulation and must not lag behind the pointer, exactly like a canvas drag.
|
|
81
|
+
const layer = zoom.layerRef.current;
|
|
82
|
+
if (layer)
|
|
83
|
+
layer.dataset['plantumlContinuous'] = on ? 'true' : 'false';
|
|
84
|
+
};
|
|
85
|
+
const onPointerDown = (event) => {
|
|
86
|
+
if (!event.isPrimary || event.button !== 0)
|
|
87
|
+
return;
|
|
88
|
+
pointerId = event.pointerId;
|
|
89
|
+
// Unlike the canvas there is nothing to select or focus in here, so the press can be
|
|
90
|
+
// consumed outright — this is also what stops a drag from selecting page text.
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
setContinuous(true);
|
|
93
|
+
try {
|
|
94
|
+
canvas.setPointerCapture(pointerId);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Capture is unavailable in some environments; dragging still works without it.
|
|
98
|
+
}
|
|
99
|
+
panTo(event);
|
|
100
|
+
};
|
|
101
|
+
const onPointerMove = (event) => {
|
|
102
|
+
if (pointerId === null || event.pointerId !== pointerId)
|
|
103
|
+
return;
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
panTo(event);
|
|
106
|
+
};
|
|
107
|
+
const endDrag = (event) => {
|
|
108
|
+
if (pointerId === null || event.pointerId !== pointerId)
|
|
109
|
+
return;
|
|
110
|
+
try {
|
|
111
|
+
if (canvas.hasPointerCapture(pointerId))
|
|
112
|
+
canvas.releasePointerCapture(pointerId);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Nothing to release.
|
|
116
|
+
}
|
|
117
|
+
pointerId = null;
|
|
118
|
+
setContinuous(false);
|
|
119
|
+
};
|
|
120
|
+
canvas.addEventListener('pointerdown', onPointerDown);
|
|
121
|
+
canvas.addEventListener('pointermove', onPointerMove);
|
|
122
|
+
canvas.addEventListener('pointerup', endDrag);
|
|
123
|
+
canvas.addEventListener('pointercancel', endDrag);
|
|
124
|
+
canvas.addEventListener('lostpointercapture', endDrag);
|
|
125
|
+
return () => {
|
|
126
|
+
canvas.removeEventListener('pointerdown', onPointerDown);
|
|
127
|
+
canvas.removeEventListener('pointermove', onPointerMove);
|
|
128
|
+
canvas.removeEventListener('pointerup', endDrag);
|
|
129
|
+
canvas.removeEventListener('pointercancel', endDrag);
|
|
130
|
+
canvas.removeEventListener('lostpointercapture', endDrag);
|
|
131
|
+
setContinuous(false);
|
|
132
|
+
};
|
|
133
|
+
}, [zoom]);
|
|
134
|
+
return (_jsxs("div", { className: styles.minimap, "data-plantuml-minimap": "true", children: [_jsxs("div", { ref: canvasRef, className: styles.minimapCanvas, "aria-hidden": "true", children: [_jsx("div", { ref: layerRef, className: styles.minimapLayer,
|
|
135
|
+
// The same sanitized string the visible canvas renders — nothing new to sanitize.
|
|
136
|
+
dangerouslySetInnerHTML: svgHtml }), _jsx("div", { ref: rectRef, className: styles.minimapRect })] }), _jsx("button", { type: "button", className: `${styles.toolbarButton} ${styles.minimapClose}`, "aria-label": "Close minimap", onClick: onClose, children: _jsx(CloseIcon, {}) })] }));
|
|
137
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
/**
|
|
13
|
+
* Extracts the deeplink target from a location hash.
|
|
14
|
+
*
|
|
15
|
+
* Returns the decoded identifier — `%0A` becomes a real newline, so multiline labels can be
|
|
16
|
+
* addressed — or `null` when the hash is absent, not diagram-shaped, or names no node.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseDiagramHash(hash: string): string | null;
|
|
19
|
+
export interface DeeplinkMatch {
|
|
20
|
+
/** The elements to mark as the focused node — one group or anchor, or several text lines. */
|
|
21
|
+
elements: Element[];
|
|
22
|
+
/** The element whose position the viewport centres on. */
|
|
23
|
+
anchor: Element;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Finds the node a deeplink target refers to inside one rendered diagram.
|
|
27
|
+
*
|
|
28
|
+
* The ladder, from most to least deterministic:
|
|
29
|
+
*
|
|
30
|
+
* 1. **Explicit id** — an element whose `id` equals the target. Graphviz emits these for
|
|
31
|
+
* `node [id="…"]`, giving a hidden, deterministic handle. (PlantUML's own `id`s are
|
|
32
|
+
* generated — `ent0001` — and deliberately not part of the contract.)
|
|
33
|
+
* 2. **PlantUML alias** — the engine writes every entity's alias into
|
|
34
|
+
* `data-qualified-name` on its `g.entity` group, so `component "X" as REACTION_1234`
|
|
35
|
+
* and `note "…" as REACTION_1234` are addressable by an id that never appears in the
|
|
36
|
+
* picture. This is PlantUML's spelling of a hidden deterministic id.
|
|
37
|
+
* 3. **Self-anchor** — an `<a>` whose href's hash parses to the same target, which doubles
|
|
38
|
+
* as the node's own permalink. Graphviz emits these for `node [URL="#graph?…"]`; the
|
|
39
|
+
* bundled PlantUML engine currently emits no anchors at all, so for PlantUML this level
|
|
40
|
+
* is future-proofing rather than an authoring channel.
|
|
41
|
+
* 4. **Graphviz node name** — the `<title>` Graphviz writes into every `g.node`, so plain
|
|
42
|
+
* DOT node names work with no annotation at all.
|
|
43
|
+
* 5. **Multiline label** — a target containing newlines (`%0A` in the URL) matches that many
|
|
44
|
+
* *consecutive* text lines, each containing its line of the target. The window stays
|
|
45
|
+
* inside one node's group where groups exist (Graphviz `g.node`, PlantUML `g.entity`)
|
|
46
|
+
* and runs over the document order otherwise — which is how a two-line label is told
|
|
47
|
+
* apart from two nodes that each contain one of the lines.
|
|
48
|
+
* 6. **Single-line substring** — the first text line containing the target,
|
|
49
|
+
* case-insensitively. This is what lets the unique half of a label (`12345` of
|
|
50
|
+
* `caminus-process-archive\n12345`) address the node without spelling out the rest.
|
|
51
|
+
*/
|
|
52
|
+
export declare function findDeeplinkTarget(root: Element, target: string): DeeplinkMatch | null;
|
|
53
|
+
export declare function claimDeeplinkScroll(claimKey: string, figure: Element): void;
|
|
54
|
+
/** Test-only: lets each test start with the scroll unclaimed. */
|
|
55
|
+
export declare function resetDeeplinkScroll(): void;
|
|
@@ -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
|
+
}
|