@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 CHANGED
@@ -7,6 +7,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2026-08-14
11
+
12
+ ### Added
13
+
14
+ - **Deep links *from* diagram nodes — including to diagrams on other pages.** A node can now
15
+ carry a link to another diagram's deep link, and clicking it navigates there and focuses
16
+ the target node: `component "Order Service" as ORDER_SVC
17
+ [[/docs/orders#graph?highlight-node=ORDER_DETAIL_1]]` in PlantUML, or
18
+ `orders [URL="/docs/orders#graph?highlight-node=ORDER_DETAIL_1"]` in Graphviz.
19
+
20
+ For PlantUML this means the plugin now **synthesizes the anchors the bundled engine
21
+ drops**: `[[url]]` on components, participants, C4 macros and relations is read back out
22
+ of the fence source and wrapped around the rendered element — clickable,
23
+ keyboard-focusable, marked `data-plantuml-diagram-link`, coloured with the site's link
24
+ colour. Correlation runs on the alias the engine writes into `data-qualified-name`, with
25
+ `data-source-line` as the fallback, so it survives `!include` — standard-library diagrams
26
+ included, whose preprocessing shifts every line number. Synthesized hrefs pass a scheme
27
+ allowlist; a link that cannot be correlated attaches to nothing. Links inside note bodies
28
+ remain the engine's styled-but-inert text, and one link per source line is correlatable.
29
+
30
+ Navigation is SPA-aware in both engines: in-diagram links to same-site URLs go through
31
+ the Docusaurus router — no full page load — and site-absolute paths get the site's
32
+ `baseUrl` exactly as markdown links do. External links and pure `#…` anchors stay native,
33
+ and clicks that end a drag never navigate.
34
+
35
+ ### Changed
36
+
37
+ - **The deep-link highlight is green, not red.** The focused-node colour was the theme's
38
+ danger red, which reads as an error on a highlight that persists until the hash changes;
39
+ it now defaults to the theme's success green. Sites can pick any colour by setting
40
+ `--plantuml-focus-color` — the rule reads
41
+ `var(--plantuml-focus-color, var(--ifm-color-success))`.
42
+
43
+ ## [1.4.0] - 2026-08-13
44
+
45
+ ### Added
46
+
47
+ - **A Fit control in the maximized view.** While a diagram fills the screen, one press
48
+ returns it to the fitted scale — the whole diagram filling the screen, magnified or shrunk
49
+ as the picture demands — after zooming or panning away from it. Maximizing itself now
50
+ opens at that truly fitted scale too: the view's content measurement tracks the rendered
51
+ picture rather than the full-width frame, which is also what tightens the minimap around
52
+ the actual diagram. (The pan clamp deliberately keeps its old frame-wide bounds, so focal
53
+ wheel zoom holds the point under the pointer exactly as before.) The button is
54
+ deliberately absent from the inline toolbar, where the frame grows with the diagram and a
55
+ fit would merely duplicate Reset; it sits between Reset and Maximize, is drawn as inline
56
+ SVG like the rest of the toolbar, and carries `aria-label="Fit diagram to screen"`.
57
+
58
+ - **A minimap.** A new toggle in the bottom-left corner opens a small copy of the diagram
59
+ with a rectangle marking what the viewport currently shows; pressing or dragging anywhere
60
+ on the map centres the view there, so a single press doubles as "jump there". The map
61
+ follows every zoom, pan and resize without re-rendering the figure — it subscribes to the
62
+ same imperative transform writes the zoom hook itself uses — and works while maximized.
63
+ The figure carries `data-plantuml-minimap-open="true"` while the map is up.
64
+
65
+ The map is pointer-only and hidden from assistive technology on purpose: the real viewport
66
+ is already keyboard-operable, so a second, duplicated view of the same diagram would add
67
+ noise without adding a capability. Its close button stays outside the hidden subtree and
68
+ remains focusable.
69
+
70
+ - **Search within a diagram.** The new lens button opens a search bar beside the toolbar: a
71
+ case-insensitive substring search over the rendered SVG's text. Every match is marked with
72
+ `data-plantuml-search-match` and highlighted from the stylesheet — the cached SVG string is
73
+ never mutated — and the current match additionally carries `data-plantuml-search-current`.
74
+ Enter and the chevron buttons step through the matches (Shift+Enter backwards), each step
75
+ centres the view on its match at the current zoom level, and Escape or ✕ closes the bar
76
+ and sweeps every highlight. The figure carries `data-plantuml-search-open="true"` while
77
+ the bar is up.
78
+
79
+ - **Deep links into diagrams.** A URL hash of the form `#graph?highlight-node=REACTION_1234`
80
+ focuses a node: every diagram on the page reacts - none needs an id of its own, and a
81
+ diagram without the node does nothing - the page scrolls to the first matching figure, the
82
+ node is marked with `data-plantuml-focused-node` and highlighted from the stylesheet, and
83
+ a zoomable diagram snaps to 100% centred on it. The hash is watched through the router, so
84
+ every kind of navigation reacts — pushed, replaced, popped and native `#…` clicks alike,
85
+ including a `<Link>` navigation that *drops* the hash, which sweeps the highlight — and a
86
+ `#graph?…` hash defeats lazy loading so below-the-fold targets still react.
87
+
88
+ The identifier resolves through a ladder - explicit SVG `id` (Graphviz `node [id="…"]`),
89
+ the PlantUML alias (`component "X" as REACTION_1234`, aliased notes included, via the
90
+ `data-qualified-name` the engine writes into its SVG), a self-anchor link
91
+ (Graphviz `URL="#graph?…"`, which doubles as the node's own permalink when clicked),
92
+ Graphviz node names via their `<title>`, multiline labels with `%0A`-encoded newlines
93
+ matched against consecutive text lines within one node's group, and finally a
94
+ case-insensitive substring of a single line. The first level that matches wins, so a
95
+ deterministic id always beats loose text matching.
96
+
97
+ Alongside this, Graphviz author links are pinned by tests, and sanitization keeps
98
+ `target="_top"` on links instead of silently dropping it. One engine limitation surfaced
99
+ and is now documented: the bundled PlantUML engine renders `[[url]]` link text but emits
100
+ no `<a>` elements, so PlantUML links are not clickable - which is why PlantUML deep-link
101
+ ids ride on aliases rather than on links.
102
+
103
+ ### Fixed
104
+
105
+ - **The rendered SVG is no longer re-parsed on every re-render under React 19.** React 19
106
+ compares `dangerouslySetInnerHTML` by the wrapper object's identity rather than by its
107
+ `__html` string, so the inline `{{__html: svg}}` literal made every state change — opening
108
+ the source view, maximizing, a copy confirmation — throw away and re-parse the entire SVG
109
+ subtree. The wrapper is now memoized per SVG string. This is also what makes the search's
110
+ in-DOM highlights survive unrelated re-renders.
111
+
10
112
  ## [1.3.1]
11
113
 
12
114
  ### Fixed
@@ -380,7 +482,9 @@ Initial release.
380
482
  opt-out.
381
483
  - Plugin option validation that rejects unknown keys and out-of-range values at build time.
382
484
 
383
- [Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.3.1...HEAD
485
+ [Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.5.0...HEAD
486
+ [1.5.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.4.0...v1.5.0
487
+ [1.4.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.3.1...v1.4.0
384
488
  [1.3.1]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.3.0...v1.3.1
385
489
  [1.3.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.2.0...v1.3.0
386
490
  [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 fourth toolbar button expands the diagram to fill the browser viewport, fitted to the
572
- available space, over an opaque background. <kbd>Escape</kbd> or the same button restores it,
573
- along with whatever zoom level you had before.
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 roughly **four keyboard tab stops** (the viewport and three or
605
- four buttons). On a page with six diagrams that is a meaningful amount of tabbing.
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,133 @@ 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
+ ### Linking from a node
701
+
702
+ A node (or edge) can carry a link — to an external site, another docs page, or another
703
+ diagram's node via a [deep link](#deep-links-graphhighlight-node). What to write differs by
704
+ engine.
705
+
706
+ #### PlantUML
707
+
708
+ **Recommended form — give the node an alias** and put the link on the same line:
709
+
710
+ ````markdown
711
+ ```plantuml
712
+ @startuml
713
+ component "Order Service" as ORDER_SVC [[/docs/orders#graph?highlight-node=ORDER_DETAIL_1]]
714
+
715
+ ' The same through a C4 macro — the alias is the macro's first argument:
716
+ !include <C4/C4_Container>
717
+ Container(billing, "Billing", "Java") [[/docs/billing#graph?highlight-node=INVOICE_QUEUE]]
718
+ @enduml
719
+ ```
720
+ ````
721
+
722
+ The bundled engine parses `[[url]]` but emits no `<a>` elements, so the plugin
723
+ **synthesizes** the anchor: it reads the link back out of the fence source and wraps the
724
+ rendered element in a real anchor — clickable, keyboard-focusable, coloured with the site's
725
+ link colour, underlined on hover, and marked `data-plantuml-diagram-link` for site CSS. The
726
+ alias is the correlation key (the engine writes it into the SVG as `data-qualified-name`),
727
+ which is what makes this form work in **every** diagram — including ones whose
728
+ `!include <…>` shifts the engine's internal line numbering.
729
+
730
+ **Fallback form — no alias.** A plain quoted declaration or an edge label also works:
731
+
732
+ ````markdown
733
+ ```plantuml
734
+ @startuml
735
+ component "Order Service" [[/docs/orders#graph?highlight-node=ORDER_DETAIL_1]]
736
+ "Order Service" --> "Billing" : invoices [[/docs/billing]]
737
+ @enduml
738
+ ```
739
+ ````
740
+
741
+ With nothing alias-shaped on the line, the plugin correlates by the line number the engine
742
+ stamps on each element (`data-source-line`) — which is exact **only in diagrams without
743
+ `!include`**. In a standard-library diagram these links attach to nothing; use the aliased
744
+ form there.
745
+
746
+ Boundaries, by design: hrefs pass a scheme allowlist (`http(s)`, site-relative, `#…` —
747
+ anything else is dropped); links inside _note bodies_ stay the styled-but-inert text the
748
+ engine renders; one link per source line is correlatable; and a link that cannot be
749
+ correlated attaches to nothing rather than to the wrong node.
750
+
751
+ #### Graphviz
752
+
753
+ One form, no fallback involved — the engine emits the anchor itself, preserved through
754
+ sanitization (`javascript:` URLs are stripped):
755
+
756
+ ````markdown
757
+ ```dot
758
+ digraph {
759
+ orders [URL="/docs/orders#graph?highlight-node=ORDER_DETAIL_1", label="Order Service", fontcolor=blue];
760
+ }
761
+ ```
762
+ ````
763
+
764
+ #### How clicks navigate
765
+
766
+ Both engines share the same navigation behaviour: a click on an in-diagram link to a
767
+ same-site URL goes through the Docusaurus router — no full page load — and site-absolute
768
+ paths get the site's `baseUrl` exactly as markdown links do, so `/docs/orders` means the
769
+ same thing in a diagram as in prose. External links and pure `#…` anchors stay native,
770
+ modified clicks (Ctrl, Cmd, Shift) keep the browser's own behaviour, and a click that ends
771
+ a drag never navigates.
772
+
773
+ Clicking either example node above therefore navigates — without a reload — to
774
+ `/docs/orders`, where the diagram containing `ORDER_DETAIL_1` highlights and centres it.
775
+
776
+ **Rule of thumb: in PlantUML, give a node an alias if you link it or link to it.** The
777
+ aliased spelling is immune to includes on both ends — as the outgoing link's correlation
778
+ key here, and as the incoming deep link's `data-qualified-name` target below. The
779
+ unaliased form exists so quick, plain diagrams work without ceremony.
780
+
781
+ ### Deep links: `#graph?highlight-node=…`
782
+
783
+ A URL hash of the form `#graph?highlight-node=REACTION_1234` focuses a node:
784
+
785
+ - **Every diagram on the page reacts** - no diagram needs an id of its own. A diagram that
786
+ does not contain the node simply does nothing.
787
+ - The page scrolls to the first matching figure, the node is visibly highlighted
788
+ (`data-plantuml-focused-node`), and a zoomable diagram snaps to **100% centred on the
789
+ node**. The highlight stays until the hash changes. It uses the theme's success green by
790
+ default; set `--plantuml-focus-color` in site CSS to pick any other colour:
791
+
792
+ ```css
793
+ :root {
794
+ --plantuml-focus-color: #3578e5; /* a light blue instead */
795
+ }
796
+ ```
797
+
798
+ - The hash is watched live, so following a `#graph?…` link anywhere on the page - including
799
+ one inside a Graphviz diagram - focuses the node without a reload.
800
+ - A `#graph?…` hash defeats lazy loading for the page's diagrams, so a target below the
801
+ fold still reacts. That costs the render of every diagram on the page, exactly as
802
+ `lazy: false` would.
803
+
804
+ The identifier is resolved per diagram through a ladder; the first level that matches wins:
805
+
806
+ | # | Level | Works in | Authoring |
807
+ | --- | ----------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
808
+ | 1 | Explicit SVG `id` | Graphviz | `node [id="REACTION-1234"]` |
809
+ | 2 | PlantUML alias | PlantUML | `component "X" as REACTION_1234`, `note "…" as REACTION_1234` - the alias lands in the SVG as `data-qualified-name` |
810
+ | 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 |
811
+ | 4 | Node name | Graphviz | plain DOT node names, via the `<title>` Graphviz emits |
812
+ | 5 | Multiline label | both | `%0A` encodes the newline: `#graph?highlight-node=Archive%0A12345` matches _consecutive_ label lines, kept within one node's group |
813
+ | 6 | Substring | both | any text line containing the identifier, case-insensitively |
814
+
815
+ Level 2 is the PlantUML spelling of a hidden, deterministic id: the alias never appears in
816
+ the picture, but the engine writes it onto the entity's group - and aliased **notes** get
817
+ one too, so `note "…" as MYNOTE_123` makes the note itself navigable. PlantUML aliases are
818
+ identifiers: use underscores (`REACTION_1234`), not hyphens, which do not parse.
819
+
820
+ Level 6 is what makes an unannotated node reachable by the unique part of its label - for a
821
+ node labelled `caminus-process-archive\n12345`, the hash `#graph?highlight-node=12345`
822
+ suffices. Levels 1-4 exist so that a deterministic, author-chosen identifier always beats
823
+ loose text matching.
824
+
666
825
  ## Lazy loading
667
826
 
668
827
  With `lazy: true` (the default), a diagram is not rendered until it scrolls within 300 px of
@@ -1164,6 +1323,11 @@ package.
1164
1323
  - **Diagrams are not rendered during static-site generation.** The HTML Docusaurus emits
1165
1324
  contains the deferred placeholder and the `<noscript>` source. Search engines that do not
1166
1325
  execute JavaScript will not see the diagram image.
1326
+ - **PlantUML links inside note bodies are not clickable.** The plugin synthesizes the
1327
+ `[[url]]` anchors the bundled engine drops on components, participants, macros and
1328
+ relations — but a link in a note's _text_ renders as the styled-but-inert text the engine
1329
+ emits, with nothing to correlate it to. One link per source line is correlatable, for the
1330
+ same reason. See [Links and deep links](#links-and-deep-links).
1167
1331
  - **One PlantUML diagram renders at a time.** The PlantUML engine has module-level shared
1168
1332
  state, so a page with many large diagrams renders them sequentially. This is a correctness
1169
1333
  requirement, not a tuning knob. Graphviz has no such constraint and is not queued.
@@ -41,4 +41,16 @@ 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";
54
+ /** On anchors the plugin synthesized from `[[url]]` links the PlantUML engine dropped. */
55
+ readonly diagramLink: "data-plantuml-diagram-link";
44
56
  };
package/dist/constants.js CHANGED
@@ -43,4 +43,16 @@ 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',
56
+ /** On anchors the plugin synthesized from `[[url]]` links the PlantUML engine dropped. */
57
+ diagramLink: 'data-plantuml-diagram-link',
46
58
  };
@@ -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
- ADD_ATTR: ['role', 'aria-label', 'aria-labelledby', 'aria-describedby'],
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;