@matfsw/docusaurus-plantuml-plugin 1.3.0 → 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 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.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
+
79
+ ## [1.3.1]
80
+
81
+ ### Fixed
82
+
83
+ - **The toolbar's controls are drawn rather than typed** ([#21]). `⛶` U+26F6 SQUARE FOUR
84
+ CORNERS, the maximize icon, has no glyph in any font that ships with a stock Linux desktop,
85
+ so that control rendered as an empty box for an entire platform's readers — on the demo site
86
+ included. The other four only worked because DejaVu Sans happens to be installed, which a
87
+ minimal container image or a locked-down desktop does not promise, so all of them are now
88
+ inline SVG and the plugin depends on no font at all.
89
+
90
+ The source toggle's `</>` went the same way. It is ASCII and never had the glyph problem, but
91
+ left as text it would have been the one control still drawn in the page font, at a different
92
+ weight and optical size from its neighbours.
93
+
94
+ Nothing about the accessible surface changes: every button keeps its `aria-label`,
95
+ `aria-pressed`, `aria-expanded` and `aria-controls`, and the icons are `aria-hidden`. The
96
+ icons inherit `currentColor` and `1em`, so both colour modes and reader font scaling behave
97
+ exactly as before, with no CSS change. The `Copy` text button and the `⚠` error glyph are
98
+ unchanged.
99
+
100
+ If you worked around this with a `font-family` override on `[class*='toolbarButton']`, it is
101
+ now a harmless no-op and can be removed.
102
+
103
+ ### Changed
104
+
105
+ - The example site records why its `onBrokenMarkdownLinks` stays at the top level rather than
106
+ moving to `markdown.hooks`, where Docusaurus 3.9 put it: 3.5's `markdown` schema rejects the
107
+ key outright, and CI builds that fixture against 3.5.2 — the oldest release this plugin's
108
+ peer range claims.
109
+
110
+ [#21]: https://github.com/matf/docusaurus-plantuml-plugin/issues/21
111
+
10
112
  ## [1.3.0]
11
113
 
12
114
  ### Added
@@ -347,7 +449,9 @@ Initial release.
347
449
  opt-out.
348
450
  - Plugin option validation that rejects unknown keys and out-of-range values at build time.
349
451
 
350
- [Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.3.0...HEAD
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
454
+ [1.3.1]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.3.0...v1.3.1
351
455
  [1.3.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.2.0...v1.3.0
352
456
  [1.2.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.1.2...v1.2.0
353
457
  [1.1.2]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.1.1...v1.1.2
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,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
@@ -898,6 +987,12 @@ The runtime requires:
898
987
  - `ResizeObserver` — optional; without it zoom still works, but the view is not re-clamped when
899
988
  the column is resized
900
989
 
990
+ It requires **no particular fonts**. The toolbar's controls are inline SVG rather than symbol
991
+ characters, which they were until 1.3.1: `⛶` U+26F6 (maximize) has no glyph in any font a stock
992
+ Linux desktop ships, so it rendered as an empty box for those readers, and the rest only worked
993
+ where DejaVu Sans happened to be installed. The icons inherit `currentColor` and `1em`, so they
994
+ follow the colour mode and the reader's font size the way the characters did.
995
+
901
996
  That is modern evergreen Chrome, Edge, Firefox and Safari. Internet Explorer is not
902
997
  supported and will not be.
903
998
 
@@ -1158,6 +1253,10 @@ package.
1158
1253
  - **Diagrams are not rendered during static-site generation.** The HTML Docusaurus emits
1159
1254
  contains the deferred placeholder and the `<noscript>` source. Search engines that do not
1160
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).
1161
1260
  - **One PlantUML diagram renders at a time.** The PlantUML engine has module-level shared
1162
1261
  state, so a page with many large diagrams renders them sequentially. This is a correctness
1163
1262
  requirement, not a tuning knob. Graphviz has no such constraint and is not queued.
@@ -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
  };
@@ -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;