@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 +105 -1
- package/README.md +112 -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 +51 -0
- package/dist/theme/PlantUmlDiagram/icons.js +67 -0
- package/dist/theme/PlantUmlDiagram/index.js +72 -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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type MutableRefObject } from 'react';
|
|
2
|
+
import type { ZoomPanHandle } from './useZoomPan.js';
|
|
3
|
+
/**
|
|
4
|
+
* Makes one diagram react to `#graph?highlight-node=…` deep links.
|
|
5
|
+
*
|
|
6
|
+
* On a matching hash the target node is marked (`data-plantuml-focused-node`, styled from
|
|
7
|
+
* the stylesheet), the page scrolls to the first matching figure, and — when the diagram is
|
|
8
|
+
* zoomable — the view snaps to 100% centred on the node. A diagram without the node does
|
|
9
|
+
* nothing, which is what lets every diagram on the page react without any of them carrying
|
|
10
|
+
* an id.
|
|
11
|
+
*
|
|
12
|
+
* The hash comes from the router, not from a `hashchange` listener: Docusaurus `<Link>`
|
|
13
|
+
* navigations are `history.pushState` calls, which fire no `hashchange` — so a listener
|
|
14
|
+
* kept a stale hash whenever a link changed or dropped it while the page (and this
|
|
15
|
+
* component) stayed mounted, leaving the old node highlighted. `useLocation` re-renders on
|
|
16
|
+
* push, replace and pop alike, and native `#…` anchor clicks reach it too, via the
|
|
17
|
+
* `popstate` the browser fires for hash navigations.
|
|
18
|
+
*
|
|
19
|
+
* Highlighting is imperative for the same reason the search's is: the marked elements live
|
|
20
|
+
* in a `dangerouslySetInnerHTML` subtree React cannot render into.
|
|
21
|
+
*/
|
|
22
|
+
export interface UseDiagramDeeplinkParams {
|
|
23
|
+
/** Whether the diagram is rendered and its SVG is in the DOM. */
|
|
24
|
+
ready: boolean;
|
|
25
|
+
/** The rendered SVG string — a dependency, so a re-rendered picture is re-matched. */
|
|
26
|
+
svg: string | null;
|
|
27
|
+
/** Whether the diagram is zoomable; centring and the 100% snap need the viewport. */
|
|
28
|
+
interactive: boolean;
|
|
29
|
+
zoom: ZoomPanHandle;
|
|
30
|
+
containerRef: MutableRefObject<HTMLElement | null>;
|
|
31
|
+
}
|
|
32
|
+
export declare function useDiagramDeeplink({ ready, svg, interactive, zoom, containerRef, }: UseDiagramDeeplinkParams): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useLocation } from '@docusaurus/router';
|
|
3
|
+
import { DATA_ATTR } from '../../constants.js';
|
|
4
|
+
import { claimDeeplinkScroll, findDeeplinkTarget, parseDiagramHash } from './deeplink.js';
|
|
5
|
+
import { centerViewportOn } from './zoomMath.js';
|
|
6
|
+
export function useDiagramDeeplink({ ready, svg, interactive, zoom, containerRef, }) {
|
|
7
|
+
const location = useLocation();
|
|
8
|
+
const target = parseDiagramHash(location.hash);
|
|
9
|
+
// One value per history entry. Undefined on entries the router did not create itself
|
|
10
|
+
// (native hash clicks, the initial load); those still differ in `target`, which the
|
|
11
|
+
// scroll claim includes.
|
|
12
|
+
const navigationKey = location.key ?? 'pop';
|
|
13
|
+
const pathname = location.pathname;
|
|
14
|
+
/**
|
|
15
|
+
* Marks, scrolls and centres. The cleanup unmarks, so a changed hash, a navigation that
|
|
16
|
+
* drops the hash and an unmounting diagram all sweep up through the same code path; a
|
|
17
|
+
* replaced SVG re-runs the effect (via `svg`) against the new elements.
|
|
18
|
+
*
|
|
19
|
+
* `navigationKey` is a dependency on purpose: following the same deep link again is a new
|
|
20
|
+
* history entry with an unchanged `target`, and it should scroll and re-centre again.
|
|
21
|
+
*/
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!ready || target === null)
|
|
24
|
+
return undefined;
|
|
25
|
+
const figure = containerRef.current;
|
|
26
|
+
const canvas = figure?.querySelector('[role="img"]');
|
|
27
|
+
if (!figure || !canvas)
|
|
28
|
+
return undefined;
|
|
29
|
+
const match = findDeeplinkTarget(canvas, target);
|
|
30
|
+
if (!match)
|
|
31
|
+
return undefined;
|
|
32
|
+
for (const element of match.elements) {
|
|
33
|
+
element.setAttribute(DATA_ATTR.focusedNode, 'true');
|
|
34
|
+
}
|
|
35
|
+
claimDeeplinkScroll(`${navigationKey}|${pathname}|${target}`, figure);
|
|
36
|
+
if (interactive) {
|
|
37
|
+
const layer = zoom.layerRef.current;
|
|
38
|
+
if (layer) {
|
|
39
|
+
// The node's offset inside the layer, divided back by the current scale, is its
|
|
40
|
+
// position in content coordinates whatever the zoom was; the view then snaps to
|
|
41
|
+
// 100% centred there — the zoom level a deep link promises.
|
|
42
|
+
const transform = zoom.getTransform();
|
|
43
|
+
const layerBox = layer.getBoundingClientRect();
|
|
44
|
+
const box = match.anchor.getBoundingClientRect();
|
|
45
|
+
zoom.applyTransform(centerViewportOn({ k: 1, x: 0, y: 0 }, (box.left + box.width / 2 - layerBox.left) / transform.k, (box.top + box.height / 2 - layerBox.top) / transform.k, zoom.measure()));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return () => {
|
|
49
|
+
for (const element of match.elements) {
|
|
50
|
+
element.removeAttribute(DATA_ATTR.focusedNode);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}, [containerRef, interactive, navigationKey, pathname, ready, svg, target, zoom]);
|
|
54
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type MutableRefObject } from 'react';
|
|
2
|
+
import type { ZoomPanHandle } from './useZoomPan.js';
|
|
3
|
+
/**
|
|
4
|
+
* Text search over one rendered diagram.
|
|
5
|
+
*
|
|
6
|
+
* The search runs against the *rendered SVG's* `<text>` elements rather than the diagram
|
|
7
|
+
* source: what the reader sees is what they are searching, and a match can be highlighted and
|
|
8
|
+
* panned to because it is a real element with a position. Matches are marked with data
|
|
9
|
+
* attributes and coloured from the stylesheet, so the SVG string itself — which the cache
|
|
10
|
+
* shares between diagrams — is never mutated.
|
|
11
|
+
*
|
|
12
|
+
* Highlighting is imperative for the same reason the zoom transform is: the elements live in
|
|
13
|
+
* a `dangerouslySetInnerHTML` subtree that React cannot render into. React state carries only
|
|
14
|
+
* what drives markup — the query, the match count and the current position.
|
|
15
|
+
*/
|
|
16
|
+
export interface DiagramSearchHandle {
|
|
17
|
+
/** Whether the search bar is shown. */
|
|
18
|
+
open: boolean;
|
|
19
|
+
query: string;
|
|
20
|
+
/** Number of `<text>` elements matching the query; 0 when the query is empty. */
|
|
21
|
+
matchCount: number;
|
|
22
|
+
/** Zero-based position of the current match, or -1 when there is none. */
|
|
23
|
+
currentIndex: number;
|
|
24
|
+
inputRef: MutableRefObject<HTMLInputElement | null>;
|
|
25
|
+
toggle: () => void;
|
|
26
|
+
close: () => void;
|
|
27
|
+
setQuery: (value: string) => void;
|
|
28
|
+
next: () => void;
|
|
29
|
+
previous: () => void;
|
|
30
|
+
}
|
|
31
|
+
export interface UseDiagramSearchParams {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
zoom: ZoomPanHandle;
|
|
34
|
+
/** The rendered SVG string — a dependency, so a re-rendered picture is re-searched. */
|
|
35
|
+
svg: string | null;
|
|
36
|
+
}
|
|
37
|
+
export declare function useDiagramSearch({ enabled, zoom, svg, }: UseDiagramSearchParams): DiagramSearchHandle;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { DATA_ATTR } from '../../constants.js';
|
|
3
|
+
import { centerViewportOn } from './zoomMath.js';
|
|
4
|
+
export function useDiagramSearch({ enabled, zoom, svg, }) {
|
|
5
|
+
const [open, setOpen] = useState(false);
|
|
6
|
+
const [query, setQuery] = useState('');
|
|
7
|
+
const [matchCount, setMatchCount] = useState(0);
|
|
8
|
+
const [currentIndex, setCurrentIndex] = useState(-1);
|
|
9
|
+
const matchesRef = useRef([]);
|
|
10
|
+
const inputRef = useRef(null);
|
|
11
|
+
const toggle = useCallback(() => setOpen((value) => !value), []);
|
|
12
|
+
const close = useCallback(() => setOpen(false), []);
|
|
13
|
+
const next = useCallback(() => {
|
|
14
|
+
setCurrentIndex((index) => {
|
|
15
|
+
const total = matchesRef.current.length;
|
|
16
|
+
return total === 0 ? -1 : (index + 1) % total;
|
|
17
|
+
});
|
|
18
|
+
}, []);
|
|
19
|
+
const previous = useCallback(() => {
|
|
20
|
+
setCurrentIndex((index) => {
|
|
21
|
+
const total = matchesRef.current.length;
|
|
22
|
+
return total === 0 ? -1 : (index - 1 + total) % total;
|
|
23
|
+
});
|
|
24
|
+
}, []);
|
|
25
|
+
// Typing should not require a second click after pressing the toggle.
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (open)
|
|
28
|
+
inputRef.current?.focus();
|
|
29
|
+
}, [open]);
|
|
30
|
+
// A diagram that lost its zoom lost the viewport the search pans; the bar goes with it.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!enabled)
|
|
33
|
+
setOpen(false);
|
|
34
|
+
}, [enabled]);
|
|
35
|
+
/**
|
|
36
|
+
* Finds and marks the matches. The effect's cleanup unmarks them, so closing the bar,
|
|
37
|
+
* changing the query and unmounting all sweep up through the same code path. A replaced
|
|
38
|
+
* SVG needs no sweeping — its marked elements left the document with it — but re-runs the
|
|
39
|
+
* effect (via `svg`) so the new elements are searched.
|
|
40
|
+
*/
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!enabled || !open)
|
|
43
|
+
return undefined;
|
|
44
|
+
const layer = zoom.layerRef.current;
|
|
45
|
+
const needle = query.trim().toLowerCase();
|
|
46
|
+
if (!layer || needle === '') {
|
|
47
|
+
matchesRef.current = [];
|
|
48
|
+
setMatchCount(0);
|
|
49
|
+
setCurrentIndex(-1);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
// `<text>` elements, not their tspans: a PlantUML label is one text element, and marking
|
|
53
|
+
// the whole label keeps the highlight readable instead of colouring a fragment.
|
|
54
|
+
const matches = Array.from(layer.querySelectorAll('svg text')).filter((element) => (element.textContent ?? '').toLowerCase().includes(needle));
|
|
55
|
+
matchesRef.current = matches;
|
|
56
|
+
for (const match of matches)
|
|
57
|
+
match.setAttribute(DATA_ATTR.searchMatch, 'true');
|
|
58
|
+
setMatchCount(matches.length);
|
|
59
|
+
// Every new query starts at its first match, exactly like the browser's own find bar.
|
|
60
|
+
setCurrentIndex(matches.length > 0 ? 0 : -1);
|
|
61
|
+
return () => {
|
|
62
|
+
matchesRef.current = [];
|
|
63
|
+
for (const match of matches)
|
|
64
|
+
match.removeAttribute(DATA_ATTR.searchMatch);
|
|
65
|
+
};
|
|
66
|
+
}, [enabled, open, query, svg, zoom]);
|
|
67
|
+
/**
|
|
68
|
+
* Marks the current match and centres the viewport on it.
|
|
69
|
+
*
|
|
70
|
+
* Declared after the match effect on purpose: effects run in order, so by the time this
|
|
71
|
+
* one reads `matchesRef` the list belongs to the current query. `query` and `svg` are
|
|
72
|
+
* dependencies because a new list can leave the index at 0 — unchanged — while element 0
|
|
73
|
+
* is a different match.
|
|
74
|
+
*/
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!enabled || !open)
|
|
77
|
+
return undefined;
|
|
78
|
+
const target = currentIndex >= 0 ? matchesRef.current[currentIndex] : undefined;
|
|
79
|
+
if (!target)
|
|
80
|
+
return undefined;
|
|
81
|
+
target.setAttribute(DATA_ATTR.searchCurrent, 'true');
|
|
82
|
+
const layer = zoom.layerRef.current;
|
|
83
|
+
if (layer) {
|
|
84
|
+
// The match's offset inside the layer, both measured in the same transformed space,
|
|
85
|
+
// divided back by the scale — which yields content coordinates whatever the zoom is.
|
|
86
|
+
const transform = zoom.getTransform();
|
|
87
|
+
const layerBox = layer.getBoundingClientRect();
|
|
88
|
+
const box = target.getBoundingClientRect();
|
|
89
|
+
zoom.applyTransform(centerViewportOn(transform, (box.left + box.width / 2 - layerBox.left) / transform.k, (box.top + box.height / 2 - layerBox.top) / transform.k, zoom.measure()));
|
|
90
|
+
}
|
|
91
|
+
return () => target.removeAttribute(DATA_ATTR.searchCurrent);
|
|
92
|
+
}, [currentIndex, enabled, open, query, svg, zoom]);
|
|
93
|
+
// Stable per set of values, like the zoom handle: consumers may hang callbacks and
|
|
94
|
+
// effects off the handle, and those must not churn on unrelated re-renders.
|
|
95
|
+
return useMemo(() => ({
|
|
96
|
+
open,
|
|
97
|
+
query,
|
|
98
|
+
matchCount,
|
|
99
|
+
currentIndex,
|
|
100
|
+
inputRef,
|
|
101
|
+
toggle,
|
|
102
|
+
close,
|
|
103
|
+
setQuery,
|
|
104
|
+
next,
|
|
105
|
+
previous,
|
|
106
|
+
}), [open, query, matchCount, currentIndex, toggle, close, next, previous]);
|
|
107
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject } from 'react';
|
|
2
|
-
import { MAX_SCALE, MIN_SCALE } from './zoomMath.js';
|
|
2
|
+
import { MAX_SCALE, MIN_SCALE, type Bounds, type Transform } from './zoomMath.js';
|
|
3
3
|
export interface ZoomPanHandle {
|
|
4
4
|
viewportRef: MutableRefObject<HTMLDivElement | null>;
|
|
5
5
|
layerRef: MutableRefObject<HTMLDivElement | null>;
|
|
@@ -8,10 +8,26 @@ export interface ZoomPanHandle {
|
|
|
8
8
|
zoomIn: () => void;
|
|
9
9
|
zoomOut: () => void;
|
|
10
10
|
reset: () => void;
|
|
11
|
+
/** Fits the whole diagram inside the viewport, at whatever scale that takes to fill it. */
|
|
12
|
+
fit: () => void;
|
|
11
13
|
toggleMaximize: () => void;
|
|
12
14
|
onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void;
|
|
13
15
|
/** Whether the diagram currently fills the browser viewport. */
|
|
14
16
|
maximized: boolean;
|
|
17
|
+
/** Reads the live transform without subscribing to it. */
|
|
18
|
+
getTransform: () => Transform;
|
|
19
|
+
/** Applies a transform through the same clamp as every gesture. */
|
|
20
|
+
applyTransform: (next: Transform) => void;
|
|
21
|
+
/** Layout sizes of the viewport and content — the bounds the clamp runs against. */
|
|
22
|
+
measure: () => Bounds;
|
|
23
|
+
/**
|
|
24
|
+
* Calls the listener after every committed transform write; returns the unsubscribe.
|
|
25
|
+
*
|
|
26
|
+
* This is how the minimap tracks the view: the transform is written straight to the DOM on
|
|
27
|
+
* every pan frame, so anything that mirrored it through React state would re-render the
|
|
28
|
+
* component once per frame.
|
|
29
|
+
*/
|
|
30
|
+
subscribe: (listener: (transform: Transform) => void) => () => void;
|
|
15
31
|
}
|
|
16
32
|
export interface UseZoomPanParams {
|
|
17
33
|
enabled: boolean;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState, } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
|
|
2
2
|
import { clampTransform, fitScale, formatPercent, formatZoom, IDENTITY, MAX_SCALE, MIN_SCALE, normalizeWheelDelta, panBy, PAN_STEP, SCALE_STEP, toCssTransform, wheelZoomFactor, zoomAbout, } from './zoomMath.js';
|
|
3
3
|
/**
|
|
4
4
|
* Zoom, pan and maximize for one diagram.
|
|
@@ -19,12 +19,17 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
19
19
|
const readoutRef = useRef(null);
|
|
20
20
|
const [maximized, setMaximized] = useState(false);
|
|
21
21
|
const transformRef = useRef(IDENTITY);
|
|
22
|
+
const listenersRef = useRef(new Set());
|
|
22
23
|
/** The view to restore when the diagram is un-maximized. */
|
|
23
24
|
const beforeMaximizeRef = useRef(null);
|
|
24
25
|
const frameRef = useRef(null);
|
|
25
26
|
/**
|
|
26
27
|
* Layout sizes, never `getBoundingClientRect()`: the layer's rect includes the transform we
|
|
27
|
-
* are about to change, which would feed back into the
|
|
28
|
+
* are about to change — and, mid-easing, the transition — which would feed back into the
|
|
29
|
+
* next measurement. The layer is `width: fit-content`, so its layout size *is* the rendered
|
|
30
|
+
* picture's, not the full-width frame's; that is what lets fitting fill a maximized screen
|
|
31
|
+
* and keeps the minimap honest about where the diagram ends. The clamp deliberately runs
|
|
32
|
+
* against wider bounds — see {@link clampBounds}.
|
|
28
33
|
*/
|
|
29
34
|
const measure = useCallback(() => {
|
|
30
35
|
const viewport = viewportRef.current;
|
|
@@ -36,6 +41,20 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
36
41
|
contentHeight: layer?.offsetHeight ?? 0,
|
|
37
42
|
};
|
|
38
43
|
}, []);
|
|
44
|
+
/**
|
|
45
|
+
* The bounds the clamp runs against: the picture or the viewport, whichever is *wider*.
|
|
46
|
+
*
|
|
47
|
+
* Fitting wants the true picture size; the clamp deliberately does not. Focal zoom must
|
|
48
|
+
* hold the point under the pointer even when that slides the picture's right edge inside
|
|
49
|
+
* the viewport — clamping to the picture itself yanks the diagram sideways mid-gesture the
|
|
50
|
+
* moment that edge is reached. Flooring the width at the viewport's restores exactly the
|
|
51
|
+
* geometry the full-width layer always gave. Height is not floored: the layer never
|
|
52
|
+
* stretched vertically, so the height clamp has always run against the real picture.
|
|
53
|
+
*/
|
|
54
|
+
const clampBounds = useCallback(() => {
|
|
55
|
+
const bounds = measure();
|
|
56
|
+
return { ...bounds, contentWidth: Math.max(bounds.contentWidth, bounds.viewportWidth) };
|
|
57
|
+
}, [measure]);
|
|
39
58
|
const write = useCallback((next) => {
|
|
40
59
|
transformRef.current = next;
|
|
41
60
|
const layer = layerRef.current;
|
|
@@ -47,10 +66,19 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
47
66
|
const readout = readoutRef.current;
|
|
48
67
|
if (readout)
|
|
49
68
|
readout.textContent = formatPercent(next.k);
|
|
69
|
+
for (const listener of listenersRef.current)
|
|
70
|
+
listener(next);
|
|
71
|
+
}, []);
|
|
72
|
+
const subscribe = useCallback((listener) => {
|
|
73
|
+
listenersRef.current.add(listener);
|
|
74
|
+
return () => {
|
|
75
|
+
listenersRef.current.delete(listener);
|
|
76
|
+
};
|
|
50
77
|
}, []);
|
|
78
|
+
const getTransform = useCallback(() => transformRef.current, []);
|
|
51
79
|
const apply = useCallback((next) => {
|
|
52
|
-
write(clampTransform(next,
|
|
53
|
-
}, [
|
|
80
|
+
write(clampTransform(next, clampBounds()));
|
|
81
|
+
}, [clampBounds, write]);
|
|
54
82
|
const reset = useCallback(() => {
|
|
55
83
|
apply(IDENTITY);
|
|
56
84
|
}, [apply]);
|
|
@@ -70,6 +98,18 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
70
98
|
}, [apply]);
|
|
71
99
|
const zoomIn = useCallback(() => zoomByStep(SCALE_STEP), [zoomByStep]);
|
|
72
100
|
const zoomOut = useCallback(() => zoomByStep(1 / SCALE_STEP), [zoomByStep]);
|
|
101
|
+
/**
|
|
102
|
+
* The fitted view maximizing opens with: the diagram scaled to fill the screen, magnified
|
|
103
|
+
* or shrunk as the picture demands, capped at {@link MAX_SCALE}.
|
|
104
|
+
*
|
|
105
|
+
* Only offered while maximized. Inline it would duplicate Reset, because the inline frame
|
|
106
|
+
* grows with the diagram — at 100% everything is already visible, so there is nothing for
|
|
107
|
+
* a fit to add. Translation returns to the origin because a diagram that fits is
|
|
108
|
+
* left-aligned, exactly as `clampTransform` would force anyway.
|
|
109
|
+
*/
|
|
110
|
+
const fit = useCallback(() => {
|
|
111
|
+
apply({ ...IDENTITY, k: fitScale(measure()) });
|
|
112
|
+
}, [apply, measure]);
|
|
73
113
|
const toggleMaximize = useCallback(() => {
|
|
74
114
|
setMaximized((previous) => !previous);
|
|
75
115
|
}, []);
|
|
@@ -309,7 +349,10 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
309
349
|
if (!enabled)
|
|
310
350
|
setMaximized(false);
|
|
311
351
|
}, [enabled]);
|
|
312
|
-
|
|
352
|
+
// One stable object per set of values: consumers hang effects off the handle (the search
|
|
353
|
+
// hook, the minimap), and an object recreated every render would re-run all of them — with
|
|
354
|
+
// visible consequences, such as a search that resets to its first match on any re-render.
|
|
355
|
+
return useMemo(() => ({
|
|
313
356
|
stageRef,
|
|
314
357
|
viewportRef,
|
|
315
358
|
layerRef,
|
|
@@ -317,9 +360,26 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
317
360
|
zoomIn,
|
|
318
361
|
zoomOut,
|
|
319
362
|
reset,
|
|
363
|
+
fit,
|
|
364
|
+
toggleMaximize,
|
|
365
|
+
onKeyDown,
|
|
366
|
+
maximized,
|
|
367
|
+
getTransform,
|
|
368
|
+
applyTransform: apply,
|
|
369
|
+
measure,
|
|
370
|
+
subscribe,
|
|
371
|
+
}), [
|
|
372
|
+
zoomIn,
|
|
373
|
+
zoomOut,
|
|
374
|
+
reset,
|
|
375
|
+
fit,
|
|
320
376
|
toggleMaximize,
|
|
321
377
|
onKeyDown,
|
|
322
378
|
maximized,
|
|
323
|
-
|
|
379
|
+
getTransform,
|
|
380
|
+
apply,
|
|
381
|
+
measure,
|
|
382
|
+
subscribe,
|
|
383
|
+
]);
|
|
324
384
|
}
|
|
325
385
|
export { MAX_SCALE, MIN_SCALE };
|
|
@@ -67,6 +67,34 @@ export declare function wheelZoomFactor(normalizedDelta: number): number;
|
|
|
67
67
|
* `max`, and never below {@link MIN_SCALE}.
|
|
68
68
|
*/
|
|
69
69
|
export declare function fitScale(bounds: Bounds, max?: number): number;
|
|
70
|
+
export interface Rect {
|
|
71
|
+
x: number;
|
|
72
|
+
y: number;
|
|
73
|
+
width: number;
|
|
74
|
+
height: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Scale of the minimap image: the largest scale that fits the content into the given box,
|
|
78
|
+
* but never past 1 — a small diagram is shown at its own size, not blown up into a blurrier
|
|
79
|
+
* copy of itself sitting next to the original.
|
|
80
|
+
*
|
|
81
|
+
* Returns 0 when there is nothing to draw, which callers treat as "no minimap".
|
|
82
|
+
*/
|
|
83
|
+
export declare function minimapScale(bounds: Bounds, maxWidth: number, maxHeight: number): number;
|
|
84
|
+
/**
|
|
85
|
+
* The part of the content the viewport currently shows, in content coordinates.
|
|
86
|
+
*
|
|
87
|
+
* Clamped to the content box, so the minimap's viewport rectangle can never poke outside the
|
|
88
|
+
* picture — not even mid-gesture, when the transform itself may be momentarily out of range.
|
|
89
|
+
*/
|
|
90
|
+
export declare function visibleContentRect(transform: Transform, bounds: Bounds): Rect;
|
|
91
|
+
/**
|
|
92
|
+
* The transform that centres the viewport on a content point, at the current scale.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately unclamped: callers apply it through the same clamp as every gesture, which
|
|
95
|
+
* pins edge cases exactly the way the end of a drag is pinned.
|
|
96
|
+
*/
|
|
97
|
+
export declare function centerViewportOn(transform: Transform, contentX: number, contentY: number, bounds: Bounds): Transform;
|
|
70
98
|
export declare function toCssTransform(transform: Transform): string;
|
|
71
99
|
/** Machine-readable scale for `data-plantuml-zoom`, used by tests and author CSS. */
|
|
72
100
|
export declare function formatZoom(scale: number): string;
|
|
@@ -94,6 +94,48 @@ export function fitScale(bounds, max = MAX_SCALE) {
|
|
|
94
94
|
return 1;
|
|
95
95
|
return clamp(scale, MIN_SCALE, max);
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Scale of the minimap image: the largest scale that fits the content into the given box,
|
|
99
|
+
* but never past 1 — a small diagram is shown at its own size, not blown up into a blurrier
|
|
100
|
+
* copy of itself sitting next to the original.
|
|
101
|
+
*
|
|
102
|
+
* Returns 0 when there is nothing to draw, which callers treat as "no minimap".
|
|
103
|
+
*/
|
|
104
|
+
export function minimapScale(bounds, maxWidth, maxHeight) {
|
|
105
|
+
if (bounds.contentWidth <= 0 || bounds.contentHeight <= 0)
|
|
106
|
+
return 0;
|
|
107
|
+
const scale = Math.min(maxWidth / bounds.contentWidth, maxHeight / bounds.contentHeight, 1);
|
|
108
|
+
return Number.isFinite(scale) && scale > 0 ? scale : 0;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The part of the content the viewport currently shows, in content coordinates.
|
|
112
|
+
*
|
|
113
|
+
* Clamped to the content box, so the minimap's viewport rectangle can never poke outside the
|
|
114
|
+
* picture — not even mid-gesture, when the transform itself may be momentarily out of range.
|
|
115
|
+
*/
|
|
116
|
+
export function visibleContentRect(transform, bounds) {
|
|
117
|
+
const width = Math.min(bounds.viewportWidth / transform.k, bounds.contentWidth);
|
|
118
|
+
const height = Math.min(bounds.viewportHeight / transform.k, bounds.contentHeight);
|
|
119
|
+
return {
|
|
120
|
+
x: clamp(-transform.x / transform.k, 0, Math.max(0, bounds.contentWidth - width)),
|
|
121
|
+
y: clamp(-transform.y / transform.k, 0, Math.max(0, bounds.contentHeight - height)),
|
|
122
|
+
width,
|
|
123
|
+
height,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The transform that centres the viewport on a content point, at the current scale.
|
|
128
|
+
*
|
|
129
|
+
* Deliberately unclamped: callers apply it through the same clamp as every gesture, which
|
|
130
|
+
* pins edge cases exactly the way the end of a drag is pinned.
|
|
131
|
+
*/
|
|
132
|
+
export function centerViewportOn(transform, contentX, contentY, bounds) {
|
|
133
|
+
return {
|
|
134
|
+
k: transform.k,
|
|
135
|
+
x: bounds.viewportWidth / 2 - contentX * transform.k,
|
|
136
|
+
y: bounds.viewportHeight / 2 - contentY * transform.k,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
97
139
|
/** Rounds away floating-point noise so repeated zoom in/out returns to exactly 1. */
|
|
98
140
|
function tidy(value) {
|
|
99
141
|
return Math.abs(value) < 1e-6 ? 0 : Math.round(value * 1e6) / 1e6;
|
package/package.json
CHANGED