@matfsw/docusaurus-plantuml-plugin 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 next measurement.
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, measure()));
53
- }, [measure, write]);
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
- return {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matfsw/docusaurus-plantuml-plugin",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Render PlantUML diagrams in Docusaurus 3 entirely in the browser - no Java, no PlantUML server, no CDN.",
5
5
  "keywords": [
6
6
  "docusaurus",