@matfsw/docusaurus-plantuml-plugin 0.1.0 → 1.0.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,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.0]
11
+
12
+ First stable release. The public surface — plugin options, fence metadata flags, the `data-*`
13
+ attributes and the shape of the rendered markup — is now covered by semantic versioning.
14
+
15
+ ### Added
16
+
17
+ - Zoom and pan for rendered diagrams: Ctrl + wheel (and trackpad pinch) zooms about the
18
+ pointer, dragging pans, and a `role="group"` control toolbar offers zoom out, zoom in, reset
19
+ and — where the browser supports it — fullscreen.
20
+ - Keyboard operation on a focusable viewport: `+`/`=`, `-`/`_`, `0`, arrow keys, and
21
+ Shift + arrows to pan by most of the viewport. Modified keys are left to the browser, and
22
+ `Tab` always escapes.
23
+ - `zoom` plugin option (default `true`) and a per-fence `zoom` / `zoom=false` metastring flag
24
+ that overrides it for a single diagram.
25
+ - `data-plantuml-interactive` on the figure and `data-plantuml-zoom` on the viewport.
26
+ - `parseBooleanMeta()` for reading boolean flags from a fence metastring.
27
+
28
+ ### Changed
29
+
30
+ - **Breaking: the rendered markup changed for zoomable diagrams.** With `zoom` enabled — the
31
+ default — the `role="img"` container is wrapped by a viewport and a transform layer, and a
32
+ control group is added beside it. Site CSS that targeted
33
+ `[data-plantuml-diagram] > div[role="img"]` as a **direct child** must be updated;
34
+ `div[role="img"] > svg` still holds. Setting `zoom: false` restores the `0.1.0` markup
35
+ exactly.
36
+ - **Breaking: zoomable diagrams add keyboard tab stops.** A zoomable diagram contributes about
37
+ four (the viewport and its buttons).
38
+ - Plain scrolling over a diagram is unaffected: only Ctrl + wheel zooms, and on touchscreens
39
+ one finger still scrolls the page and two-finger pinch is still the browser's own page zoom.
40
+
41
+ ### Fixed
42
+
43
+ - `configureWebpack` no longer throws on Docusaurus `3.5.x`, where `currentBundler` does not
44
+ exist. That release line is now covered by the CI compatibility matrix.
45
+
10
46
  ## [0.1.0]
11
47
 
12
48
  Initial release.
@@ -29,5 +65,6 @@ Initial release.
29
65
  opt-out.
30
66
  - Plugin option validation that rejects unknown keys and out-of-range values at build time.
31
67
 
32
- [Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v0.1.0...HEAD
68
+ [Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.0.0...HEAD
69
+ [1.0.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v0.1.0...v1.0.0
33
70
  [0.1.0]: https://github.com/matf/docusaurus-plantuml-plugin/releases/tag/v0.1.0
package/README.md CHANGED
@@ -3,6 +3,9 @@
3
3
  Render PlantUML diagrams in Docusaurus 3 entirely in the browser — no Java, no PlantUML
4
4
  server, no Kroki, no CDN.
5
5
 
6
+ **→ [Live demo](https://matf.github.io/docusaurus-plantuml-demo)** — every diagram on that site
7
+ is rendered by your own browser.
8
+
6
9
  Write a `plantuml` fence in any `.md` or `.mdx` file and the code block is replaced, in the
7
10
  reader's browser, with an SVG rendered by the official [`@plantuml/core`][plantuml-core]
8
11
  package:
@@ -28,9 +31,13 @@ the engine itself is served from your own site's `baseUrl`.
28
31
 
29
32
  ## Status and compatibility
30
33
 
31
- Status: **beta**. The public surface (plugin options, `data-*` attributes, fence syntax) is
32
- stable enough to build on, but this is a `0.x` release and breaking changes will happen in
33
- minor versions until `1.0.0`.
34
+ Status: **stable**. `1.0.0` fixes the public surface — plugin options, fence metadata flags,
35
+ the `data-*` attributes, and the shape of the rendered markup. Breaking changes to any of them
36
+ require a major version.
37
+
38
+ Explicitly **not** covered by that promise: the generated CSS-module class names, the exact SVG
39
+ `@plantuml/core` produces, and the renderer, queue and cache internals — which is why none of
40
+ them are exported.
34
41
 
35
42
  | Requirement | Supported |
36
43
  | --------------- | -------------------------------------------------------------------------------------- |
@@ -204,6 +211,7 @@ props untouched. Existing code-block behaviour (highlighting, line numbers, titl
204
211
  | `showSourceOnError` | `boolean` | `true` | Include the diagram source in a `<details>` block on the error panel. |
205
212
  | `renderTimeoutMs` | `number` | `20000` | Abort a single render (and the runtime load) after this many milliseconds. Integer, `100`–`600000`. |
206
213
  | `cacheMaxEntries` | `number` | `50` | Upper bound on cached SVG entries. Positive integer. |
214
+ | `zoom` | `boolean` | `true` | Let readers zoom and pan diagrams. Adds a control toolbar and a focusable viewport. Override per fence with `zoom=false`. |
207
215
  | `id` | `string` | `'default'` | Docusaurus plugin instance id; only relevant if you register the plugin more than once. |
208
216
 
209
217
  Options are validated during the Docusaurus configuration phase, before anything is built.
@@ -213,7 +221,7 @@ would otherwise silently disable the option you meant to set:
213
221
  ```text
214
222
  [docusaurus-plugin-plantuml-client] Unknown option 'sanitiseSvg'. Supported options:
215
223
  'languages', 'theme', 'lazy', 'cache', 'sanitizeSvg', 'showSourceOnError',
216
- 'renderTimeoutMs', 'cacheMaxEntries'.
224
+ 'renderTimeoutMs', 'cacheMaxEntries', 'zoom'.
217
225
  ```
218
226
 
219
227
  `PlantUmlPluginOptions`, `ResolvedPlantUmlOptions`, `CacheMode`, `DiagramTheme`,
@@ -235,6 +243,76 @@ theme.
235
243
 
236
244
  The current mode is exposed on the wrapper as `data-plantuml-theme="light|dark"`.
237
245
 
246
+ ## Zoom and pan
247
+
248
+ Large diagrams are unreadable at column width. Rendered diagrams are therefore zoomable and
249
+ pannable by default, with a small control toolbar in the top-right corner.
250
+
251
+ ### Interaction
252
+
253
+ | Input | What happens |
254
+ | ----------------------------------- | ------------------------------------ |
255
+ | Plain scroll wheel | Scrolls the page. Never intercepted. |
256
+ | **Ctrl** + wheel, or trackpad pinch | Zooms about the pointer |
257
+ | Drag | Pans, once zoomed in |
258
+ | One finger on a touchscreen | Scrolls the page |
259
+ | Two-finger pinch on a touchscreen | The browser's own page zoom |
260
+ | Toolbar buttons | Zoom out, zoom in, reset, fullscreen |
261
+
262
+ `Cmd` + wheel is deliberately **not** intercepted: on macOS that is the browser's own page
263
+ zoom, and taking it over would fight the platform.
264
+
265
+ ### Keyboard
266
+
267
+ The diagram viewport is focusable. Once focused:
268
+
269
+ | Key | Action |
270
+ | ------------------ | --------------------------- |
271
+ | `+` or `=` | Zoom in |
272
+ | `-` or `_` | Zoom out |
273
+ | `0` | Reset to 100% |
274
+ | Arrow keys | Pan |
275
+ | Shift + arrow keys | Pan by most of the viewport |
276
+
277
+ Keys with Ctrl, Cmd or Alt held are left to the browser, so shortcuts such as Ctrl + 0 keep
278
+ working, and `Tab` always moves on — the diagram is never a keyboard trap.
279
+
280
+ Scale is limited to 0.25×–8×, and the view resets to 100% whenever the diagram source or the
281
+ site colour mode changes.
282
+
283
+ ### Turning it off
284
+
285
+ Globally:
286
+
287
+ ```ts
288
+ ['@matfsw/docusaurus-plantuml-plugin', {zoom: false}];
289
+ ```
290
+
291
+ Or for a single fence, which overrides the option either way:
292
+
293
+ ````markdown
294
+ ```plantuml title="Small diagram" zoom=false
295
+ @startuml
296
+ Alice -> Bob : Hello
297
+ @enduml
298
+ ```
299
+ ````
300
+
301
+ ### Why it is on by default, and what that costs
302
+
303
+ A reader who needs to zoom is rarely the author who would have enabled it, so leaving it off
304
+ would mean most readers never discover it. The costs are real and worth knowing:
305
+
306
+ - Each zoomable diagram adds roughly **four keyboard tab stops** (the viewport and three or
307
+ four buttons). On a page with six diagrams that is a meaningful amount of tabbing.
308
+ - The rendered markup gains a viewport and transform wrapper around the `role="img"` container.
309
+ Site CSS that targets `[data-plantuml-diagram] > div[role="img"]` as a **direct child** needs
310
+ updating — see [Accessibility](#accessibility) for both shapes.
311
+ - Each diagram costs one non-passive `wheel` listener and one `ResizeObserver`, and only in the
312
+ `ready` state.
313
+
314
+ Setting `zoom: false` restores the previous markup exactly.
315
+
238
316
  ## Lazy loading
239
317
 
240
318
  With `lazy: true` (the default), a diagram is not rendered until it scrolls within 300 px of
@@ -278,7 +356,7 @@ in-memory cache for the rest of the session. A storage failure never breaks rend
278
356
 
279
357
  ## Accessibility
280
358
 
281
- Each diagram renders as:
359
+ With `zoom: false`, a diagram renders as:
282
360
 
283
361
  ```html
284
362
  <figure
@@ -293,6 +371,32 @@ Each diagram renders as:
293
371
  </figure>
294
372
  ```
295
373
 
374
+ With zoom enabled (the default), the same `role="img"` element is wrapped by a viewport and a
375
+ transform layer, and a control group is added beside it:
376
+
377
+ ```html
378
+ <figure
379
+ data-plantuml-diagram="plantuml"
380
+ data-plantuml-status="ready"
381
+ data-plantuml-theme="light"
382
+ data-plantuml-interactive="true"
383
+ >
384
+ <div class="stage">
385
+ <div class="viewport" tabindex="0" aria-describedby="…" data-plantuml-zoom="1">
386
+ <div class="layer">
387
+ <div role="img" aria-label="Authentication sequence"><svg>…</svg></div>
388
+ </div>
389
+ </div>
390
+ <div role="group" aria-label="Authentication sequence zoom controls">…buttons…</div>
391
+ </div>
392
+ <p class="visuallyHidden" id="…">Zoomable diagram. Use the zoom controls, …</p>
393
+ <figcaption>Authentication sequence</figcaption>
394
+ <noscript><pre>@startuml …</pre></noscript>
395
+ </figure>
396
+ ```
397
+
398
+ `div[role="img"] > svg` holds in both shapes, so a selector written against it keeps working.
399
+
296
400
  - The fence `title="..."` becomes the `<figcaption>` and the `aria-label`. Without a title,
297
401
  the accessible label falls back to **`PlantUML diagram`**.
298
402
  - Progress is conveyed with `aria-busy` on the `<figure>`, deliberately **not** with an
@@ -303,18 +407,31 @@ Each diagram renders as:
303
407
  a ⚠ glyph in addition to the danger colouring, so it survives colour-blindness and
304
408
  forced-colours mode.
305
409
  - Readers without JavaScript get the diagram source in a `<noscript>` block.
306
- - The spinner animation is disabled under `prefers-reduced-motion: reduce`.
410
+ - The spinner animation is disabled under `prefers-reduced-motion: reduce`, as is the easing of
411
+ discrete zoom steps. Dragging and wheel zooming stay instantaneous either way, because direct
412
+ manipulation is not decoration.
413
+ - Zoom controls live **outside** the `role="img"` element. That role makes its whole subtree
414
+ opaque to assistive technology, so a button placed inside would be invisible to screen-reader
415
+ users.
416
+ - The control group is `role="group"`, not `role="toolbar"`: the toolbar role obliges arrow-key
417
+ navigation between its buttons, and the arrow keys already pan the diagram.
418
+ - Button labels are static and the zoom percentage is `aria-hidden`. A live percentage would
419
+ announce on every wheel tick.
420
+ - Keyboard instructions are attached to the viewport with `aria-describedby` rather than being
421
+ announced on focus repeatedly.
307
422
 
308
423
  ### `data-*` contract
309
424
 
310
425
  These attributes are part of the public surface; write your own end-to-end tests against
311
426
  them.
312
427
 
313
- | Attribute | Values |
314
- | ----------------------- | ---------------------------------------------------------- |
315
- | `data-plantuml-diagram` | the fence language that matched, e.g. `plantuml` or `puml` |
316
- | `data-plantuml-status` | `idle` \| `loading` \| `rendering` \| `ready` \| `error` |
317
- | `data-plantuml-theme` | `light` \| `dark` |
428
+ | Attribute | Values |
429
+ | --------------------------- | ---------------------------------------------------------- |
430
+ | `data-plantuml-diagram` | the fence language that matched, e.g. `plantuml` or `puml` |
431
+ | `data-plantuml-status` | `idle` \| `loading` \| `rendering` \| `ready` \| `error` |
432
+ | `data-plantuml-theme` | `light` \| `dark` |
433
+ | `data-plantuml-interactive` | `true`, on the `<figure>`, only when zoom is enabled |
434
+ | `data-plantuml-zoom` | current scale on the viewport, e.g. `1`, `2.5` |
318
435
 
319
436
  ## SVG sanitization and security model
320
437
 
@@ -408,6 +525,11 @@ The runtime requires:
408
525
  - `MutationObserver`
409
526
  - `IntersectionObserver` — optional; a graceful fallback renders immediately when it is absent
410
527
  - `sessionStorage` — optional; only for `cache: 'session'`, which degrades to memory without it
528
+ - Pointer Events — required for drag-to-pan when `zoom` is enabled
529
+ - `ResizeObserver` — optional; without it zoom still works, but the view is not re-clamped when
530
+ the column is resized
531
+ - `Element.requestFullscreen()` — optional; the fullscreen button is simply not rendered where
532
+ it is unavailable, which includes iOS Safari
411
533
 
412
534
  That is modern evergreen Chrome, Edge, Firefox and Safari. Internet Explorer is not
413
535
  supported and will not be.
@@ -525,7 +647,10 @@ Four layers, all deterministic and none of them calling an external PlantUML ser
525
647
  retry, FIFO queue ordering, queue continuation after failure, timeouts, unmount during
526
648
  render, light/dark cache separation, memory cache eviction, `sessionStorage` failure and
527
649
  corruption handling, SVG sanitization against synthetic malicious input, the loading/ready/
528
- error UI, SSR without browser globals, and the `IntersectionObserver` fallback.
650
+ error UI, SSR without browser globals, the `IntersectionObserver` fallback, and the zoom
651
+ layer: focal-point and clamping geometry as pure functions, the wheel policy (plain wheel
652
+ ignored, Ctrl honoured, Cmd ignored), keyboard handling, both reset triggers, and listener
653
+ teardown.
529
654
  Docusaurus `@theme/*` and `@docusaurus/*` aliases only exist inside a real Docusaurus
530
655
  webpack build, so the Vitest config points them at local stubs.
531
656
  2. **Package verification** (`npm run pack:check`). Runs `npm pack --dry-run --json`, prints
@@ -537,7 +662,11 @@ Four layers, all deterministic and none of them calling an external PlantUML ser
537
662
  fixture in a temporary directory, runs a full `docusaurus build`, and serves it. This is
538
663
  what proves the package works as consumers actually install it.
539
664
  4. **Browser end-to-end** (`npm run test:e2e`, Playwright against the served production
540
- build). Asserts that real `<svg>` elements appear inside the expected container with the
665
+ build). Asserts that zooming magnifies the diagram without changing the figure's height,
666
+ that a plain wheel scrolls the page while Ctrl + wheel zooms without scrolling, that the
667
+ focal point stays under the cursor, that panning is clamped so an edge never comes inside
668
+ the viewport, and that `touch-action` stays `pan-y pinch-zoom`. It also asserts that real
669
+ `<svg>` elements appear inside the expected container with the
541
670
  expected diagram labels, that multiple diagrams render on one page, that `.md` and `.mdx`
542
671
  both work, that ordinary code blocks are untouched, that an invalid diagram produces the
543
672
  documented error state, that toggling dark mode re-renders, that client-side navigation
@@ -639,6 +768,14 @@ package.
639
768
  8 MB download is subject to the same budget as a render.
640
769
  - **Consuming the plugin through a `file:` link needs a webpack tweak.** See the entry in
641
770
  Troubleshooting below.
771
+ - **No pinch-to-zoom of the diagram itself on touchscreens.** Two-finger pinch is deliberately
772
+ left to the browser's own page zoom, so a full-width diagram can never become a scroll trap
773
+ and page zoom is never taken away. Trackpad pinch on a laptop does zoom the diagram, because
774
+ the browser reports it as Ctrl + wheel.
775
+ - **Zoom state is not persisted.** Navigating away and back, or switching colour mode, returns
776
+ the diagram to 100%.
777
+ - **No fullscreen on iOS Safari.** It does not implement `Element.requestFullscreen()`, so the
778
+ button is not shown there.
642
779
 
643
780
  ## Troubleshooting
644
781
 
@@ -19,4 +19,11 @@ export declare const DATA_ATTR: {
19
19
  readonly diagram: "data-plantuml-diagram";
20
20
  readonly status: "data-plantuml-status";
21
21
  readonly theme: "data-plantuml-theme";
22
+ /** `"true"` on the figure when the diagram is zoomable. */
23
+ readonly interactive: "data-plantuml-interactive";
24
+ /**
25
+ * Current scale, on the viewport rather than the figure. Keeping it off the figure means
26
+ * the imperative zoom writes can never race React's attribute diffing.
27
+ */
28
+ readonly zoom: "data-plantuml-zoom";
22
29
  };
package/dist/constants.js CHANGED
@@ -21,4 +21,11 @@ export const DATA_ATTR = {
21
21
  diagram: 'data-plantuml-diagram',
22
22
  status: 'data-plantuml-status',
23
23
  theme: 'data-plantuml-theme',
24
+ /** `"true"` on the figure when the diagram is zoomable. */
25
+ interactive: 'data-plantuml-interactive',
26
+ /**
27
+ * Current scale, on the viewport rather than the figure. Keeping it off the figure means
28
+ * the imperative zoom writes can never race React's attribute diffing.
29
+ */
30
+ zoom: 'data-plantuml-zoom',
24
31
  };
package/dist/index.cjs CHANGED
@@ -99,7 +99,8 @@ var DEFAULT_OPTIONS = {
99
99
  sanitizeSvg: true,
100
100
  showSourceOnError: true,
101
101
  renderTimeoutMs: 2e4,
102
- cacheMaxEntries: 50
102
+ cacheMaxEntries: 50,
103
+ zoom: true
103
104
  };
104
105
  var CACHE_MODES = ["none", "memory", "session"];
105
106
  var THEMES = ["auto", "light", "dark"];
@@ -187,6 +188,7 @@ function resolveOptions(rawOptions) {
187
188
  "showSourceOnError",
188
189
  "renderTimeoutMs",
189
190
  "cacheMaxEntries",
191
+ "zoom",
190
192
  // Docusaurus injects `id` for multi-instance plugins.
191
193
  "id"
192
194
  ]);
@@ -221,7 +223,8 @@ function resolveOptions(rawOptions) {
221
223
  DEFAULT_OPTIONS.showSourceOnError
222
224
  ),
223
225
  renderTimeoutMs: validateRenderTimeout(rawOptions.renderTimeoutMs),
224
- cacheMaxEntries: validateCacheMaxEntries(rawOptions.cacheMaxEntries)
226
+ cacheMaxEntries: validateCacheMaxEntries(rawOptions.cacheMaxEntries),
227
+ zoom: validateBoolean(rawOptions.zoom, "zoom", DEFAULT_OPTIONS.zoom)
225
228
  };
226
229
  }
227
230
 
package/dist/index.d.cts CHANGED
@@ -27,6 +27,11 @@ interface PlantUmlPluginOptions {
27
27
  renderTimeoutMs?: number;
28
28
  /** Maximum number of cached SVG entries kept per browsing session. */
29
29
  cacheMaxEntries?: number;
30
+ /**
31
+ * Let readers zoom and pan rendered diagrams, with a small control toolbar.
32
+ * Override for a single fence with `zoom` or `zoom=false` in its metastring.
33
+ */
34
+ zoom?: boolean;
30
35
  /** Docusaurus plugin instance id, used when the plugin is registered more than once. */
31
36
  id?: string;
32
37
  }
package/dist/options.d.ts CHANGED
@@ -24,6 +24,11 @@ export interface PlantUmlPluginOptions {
24
24
  renderTimeoutMs?: number;
25
25
  /** Maximum number of cached SVG entries kept per browsing session. */
26
26
  cacheMaxEntries?: number;
27
+ /**
28
+ * Let readers zoom and pan rendered diagrams, with a small control toolbar.
29
+ * Override for a single fence with `zoom` or `zoom=false` in its metastring.
30
+ */
31
+ zoom?: boolean;
27
32
  /** Docusaurus plugin instance id, used when the plugin is registered more than once. */
28
33
  id?: string;
29
34
  }
package/dist/options.js CHANGED
@@ -8,6 +8,7 @@ export const DEFAULT_OPTIONS = {
8
8
  showSourceOnError: true,
9
9
  renderTimeoutMs: 20_000,
10
10
  cacheMaxEntries: 50,
11
+ zoom: true,
11
12
  };
12
13
  const CACHE_MODES = ['none', 'memory', 'session'];
13
14
  const THEMES = ['auto', 'light', 'dark'];
@@ -103,6 +104,7 @@ export function resolveOptions(rawOptions) {
103
104
  'showSourceOnError',
104
105
  'renderTimeoutMs',
105
106
  'cacheMaxEntries',
107
+ 'zoom',
106
108
  // Docusaurus injects `id` for multi-instance plugins.
107
109
  'id',
108
110
  ]);
@@ -127,5 +129,6 @@ export function resolveOptions(rawOptions) {
127
129
  showSourceOnError: validateBoolean(rawOptions.showSourceOnError, 'showSourceOnError', DEFAULT_OPTIONS.showSourceOnError),
128
130
  renderTimeoutMs: validateRenderTimeout(rawOptions.renderTimeoutMs),
129
131
  cacheMaxEntries: validateCacheMaxEntries(rawOptions.cacheMaxEntries),
132
+ zoom: validateBoolean(rawOptions.zoom, 'zoom', DEFAULT_OPTIONS.zoom),
130
133
  };
131
134
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import OriginalCode from '@theme-init/MDXComponents/Code';
3
3
  import PlantUmlDiagram from '@theme/PlantUmlDiagram';
4
- import { extractLanguage, extractSource, parseTitle, } from '../../codeBlockMeta.js';
4
+ import { extractLanguage, extractSource, parseBooleanMeta, parseTitle, } from '../../codeBlockMeta.js';
5
5
  import { usePlantUmlConfig } from '../../usePlantUmlConfig.js';
6
6
  /**
7
7
  * Wraps Docusaurus' `MDXComponents/Code` extension point.
@@ -24,5 +24,5 @@ export default function Code(props) {
24
24
  if (!isPlantUml || source === null) {
25
25
  return _jsx(OriginalCode, { ...props });
26
26
  }
27
- return _jsx(PlantUmlDiagram, { source: source, title: parseTitle(props), language: language });
27
+ return (_jsx(PlantUmlDiagram, { source: source, title: parseTitle(props), language: language, zoom: parseBooleanMeta(props, 'zoom') }));
28
28
  }
@@ -6,6 +6,11 @@ export interface PlantUmlDiagramProps {
6
6
  title?: string;
7
7
  /** The fence language that matched, e.g. `plantuml` or `puml`. */
8
8
  language?: string;
9
+ /**
10
+ * Overrides the `zoom` plugin option for this diagram. `undefined` follows the option.
11
+ * Set from the fence metastring: `zoom` or `zoom=false`.
12
+ */
13
+ zoom?: boolean;
9
14
  }
10
15
  /**
11
16
  * Renders one PlantUML diagram in the browser.
@@ -13,4 +18,4 @@ export interface PlantUmlDiagramProps {
13
18
  * Nothing happens during server-side rendering: the markup emitted on the server is the
14
19
  * same deferred placeholder the client renders first, so hydration never mismatches.
15
20
  */
16
- export default function PlantUmlDiagram({ source, title, language, }: PlantUmlDiagramProps): ReactNode;
21
+ export default function PlantUmlDiagram({ source, title, language, zoom: zoomProp, }: PlantUmlDiagramProps): ReactNode;
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
2
+ import { useEffect, useId, useRef, useState } from 'react';
3
3
  import { useColorMode } from '@docusaurus/theme-common';
4
4
  import { DATA_ATTR } from '../../constants.js';
5
5
  import { getSharedCache } from '../../runtime/cache.js';
6
6
  import { PlantUmlError } from '../../runtime/errors.js';
7
7
  import { renderDiagram } from '../../runtime/renderer.js';
8
8
  import { usePlantUmlConfig } from '../usePlantUmlConfig.js';
9
+ import { useZoomPan } from './useZoomPan.js';
9
10
  import styles from './styles.module.css';
10
11
  const DEFAULT_LABEL = 'PlantUML diagram';
11
12
  /** Margin large enough that a diagram is usually ready by the time it is scrolled into view. */
@@ -31,15 +32,23 @@ function messageFor(error) {
31
32
  * Nothing happens during server-side rendering: the markup emitted on the server is the
32
33
  * same deferred placeholder the client renders first, so hydration never mismatches.
33
34
  */
34
- export default function PlantUmlDiagram({ source, title, language, }) {
35
+ export default function PlantUmlDiagram({ source, title, language, zoom: zoomProp, }) {
35
36
  const config = usePlantUmlConfig();
36
37
  const { colorMode } = useColorMode();
37
38
  const containerRef = useRef(null);
39
+ const hintId = useId();
38
40
  const themeOption = config?.options.theme ?? 'auto';
39
41
  const dark = themeOption === 'auto' ? colorMode === 'dark' : themeOption === 'dark';
40
42
  const lazy = config?.options.lazy ?? true;
43
+ // A fence flag wins over the plugin option, which in turn wins over the built-in default.
44
+ const interactive = zoomProp ?? config?.options.zoom ?? true;
41
45
  const [state, setState] = useState(INITIAL_STATE);
42
46
  const [inView, setInView] = useState(!lazy);
47
+ // Called unconditionally, as hooks must be; it attaches nothing when not interactive.
48
+ const zoom = useZoomPan({
49
+ enabled: interactive && state.status === 'ready',
50
+ resetKey: `${source}|${dark ? 'dark' : 'light'}`,
51
+ });
43
52
  useEffect(() => {
44
53
  if (!lazy || inView)
45
54
  return undefined;
@@ -121,11 +130,15 @@ export default function PlantUmlDiagram({ source, title, language, }) {
121
130
  ]);
122
131
  const label = title ?? DEFAULT_LABEL;
123
132
  const busy = state.status === 'loading' || state.status === 'rendering';
133
+ // The sanitized SVG always lives in this element, zoomable or not, so `role="img" > svg`
134
+ // stays a stable contract for tests and for author CSS.
135
+ const canvas = state.svg !== null && (_jsx("div", { className: styles.canvas, role: "img", "aria-label": label,
136
+ // Sanitized above unless `sanitizeSvg: false` was explicitly opted into.
137
+ dangerouslySetInnerHTML: { __html: state.svg } }));
124
138
  return (_jsxs("figure", { ref: containerRef, className: styles.figure, "aria-busy": busy || undefined, [DATA_ATTR.diagram]: language ?? 'plantuml',
125
139
  [DATA_ATTR.status]: state.status,
126
- [DATA_ATTR.theme]: dark ? 'dark' : 'light', children: [state.status === 'ready' && state.svg !== null && (_jsx("div", { className: styles.canvas, role: "img", "aria-label": label,
127
- // Sanitized above unless `sanitizeSvg: false` was explicitly opted into.
128
- dangerouslySetInnerHTML: { __html: state.svg } })), state.status !== 'ready' && state.status !== 'error' && (
140
+ [DATA_ATTR.theme]: dark ? 'dark' : 'light',
141
+ ...(interactive ? { [DATA_ATTR.interactive]: 'true' } : {}), children: [state.status === 'ready' && state.svg !== null && !interactive && canvas, state.status === 'ready' && state.svg !== null && interactive && (_jsxs("div", { ref: zoom.stageRef, className: styles.stage, children: [_jsx("div", { ref: zoom.viewportRef, className: styles.viewport, tabIndex: 0, "aria-describedby": hintId, "aria-keyshortcuts": "Plus Minus 0 ArrowUp ArrowDown ArrowLeft ArrowRight", onKeyDown: zoom.onKeyDown, [DATA_ATTR.zoom]: '1', children: _jsx("div", { ref: zoom.layerRef, className: styles.layer, children: canvas }) }), _jsxs("div", { className: styles.toolbar, role: "group", "aria-label": `${label} zoom controls`, children: [_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom out", onClick: zoom.zoomOut, children: _jsx("span", { "aria-hidden": "true", children: "\u2212" }) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom in", onClick: zoom.zoomIn, children: _jsx("span", { "aria-hidden": "true", children: "+" }) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Reset zoom", onClick: zoom.reset, children: _jsx("span", { "aria-hidden": "true", children: "\u27F2" }) }), zoom.fullscreenSupported && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Toggle fullscreen", onClick: zoom.toggleFullscreen, children: _jsx("span", { "aria-hidden": "true", children: "\u26F6" }) })), _jsx("span", { ref: zoom.readoutRef, className: styles.readout, "aria-hidden": "true", children: "100%" })] })] })), state.status === 'ready' && state.svg !== null && interactive && (_jsx("p", { id: hintId, className: styles.visuallyHidden, children: "Zoomable diagram. Use the zoom controls, or press plus and minus to zoom, the arrow keys to pan, and zero to reset." })), state.status !== 'ready' && state.status !== 'error' && (
129
142
  // `aria-busy` on the figure already conveys progress; announcing every phase change
130
143
  // would make a page with several diagrams unusable with a screen reader.
131
144
  _jsxs("div", { className: styles.placeholder, children: [_jsx("span", { className: styles.spinner, "aria-hidden": "true" }), _jsxs("span", { children: [state.status === 'loading' ? 'Loading PlantUML runtime…' : null, state.status === 'rendering' ? `Rendering ${label}…` : null, state.status === 'idle' ? `${label} (waiting to render)` : null] })] })), state.status === 'error' && (_jsxs("div", { className: styles.error, role: "alert", children: [_jsxs("p", { className: styles.errorHeading, children: [_jsx("span", { className: styles.errorIcon, "aria-hidden": "true", children: "\u26A0" }), _jsx("span", { children: "Error: PlantUML diagram could not be rendered" })] }), _jsx("pre", { className: styles.errorMessage, children: state.error }), config?.options.showSourceOnError !== false && (_jsxs("details", { className: styles.errorDetails, children: [_jsx("summary", { children: "Show diagram source" }), _jsx("pre", { className: styles.source, children: source })] }))] })), title !== undefined && _jsx("figcaption", { className: styles.caption, children: title }), _jsx("noscript", { dangerouslySetInnerHTML: {
@@ -14,6 +14,141 @@
14
14
  height: auto;
15
15
  }
16
16
 
17
+ /*
18
+ * Zoom and pan.
19
+ *
20
+ * The transform is applied to `.layer`, never to the SVG. Transforms do not affect layout, so
21
+ * the figure keeps the exact height it has at 100% no matter how far the reader zooms in, and
22
+ * `max-width: 100%` on the SVG keeps constraining the base size as it always did.
23
+ */
24
+ .stage {
25
+ position: relative;
26
+ }
27
+
28
+ .viewport {
29
+ /* `clip` also prevents find-in-page from silently scrolling this box. */
30
+ overflow: hidden;
31
+ overflow: clip;
32
+ max-height: var(--plantuml-zoom-max-height, none);
33
+ /*
34
+ * One finger scrolls the page and two fingers are the browser's own pinch zoom, so a
35
+ * full-width diagram can never become a scroll trap on a phone.
36
+ */
37
+ touch-action: pan-y pinch-zoom;
38
+ cursor: grab;
39
+ outline-offset: 2px;
40
+ }
41
+
42
+ .viewport[data-plantuml-dragging='true'] {
43
+ cursor: grabbing;
44
+ user-select: none;
45
+ }
46
+
47
+ /* Inside the viewport the SVG must not be clipped by the old scroll container. */
48
+ .viewport .canvas {
49
+ display: block;
50
+ overflow: visible;
51
+ }
52
+
53
+ .layer {
54
+ transform-origin: 0 0;
55
+ transition: transform var(--plantuml-zoom-transition, 150ms) ease-out;
56
+ }
57
+
58
+ /* Direct manipulation must never lag behind the finger, so gestures suppress the easing. */
59
+ .layer[data-plantuml-continuous='true'] {
60
+ transition: none;
61
+ }
62
+
63
+ .toolbar {
64
+ position: absolute;
65
+ top: 0.5rem;
66
+ right: 0.5rem;
67
+ display: flex;
68
+ gap: 0.25rem;
69
+ align-items: center;
70
+ padding: 0.25rem;
71
+ border: 1px solid var(--ifm-color-emphasis-300);
72
+ border-radius: var(--ifm-global-radius);
73
+ background: var(--ifm-background-surface-color);
74
+ opacity: 0.92;
75
+ }
76
+
77
+ .toolbarButton {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ width: 1.75rem;
82
+ height: 1.75rem;
83
+ padding: 0;
84
+ border: 1px solid transparent;
85
+ border-radius: var(--ifm-global-radius);
86
+ background: transparent;
87
+ color: var(--ifm-font-color-base);
88
+ font-size: 1rem;
89
+ line-height: 1;
90
+ cursor: pointer;
91
+ }
92
+
93
+ .toolbarButton:hover {
94
+ background: var(--ifm-color-emphasis-200);
95
+ }
96
+
97
+ .toolbarButton:focus-visible {
98
+ outline: 2px solid var(--ifm-color-primary);
99
+ outline-offset: 1px;
100
+ }
101
+
102
+ .readout {
103
+ min-width: 3.25rem;
104
+ padding: 0 0.25rem;
105
+ color: var(--ifm-color-emphasis-700);
106
+ font-size: 0.75rem;
107
+ font-variant-numeric: tabular-nums;
108
+ text-align: right;
109
+ }
110
+
111
+ .stage:fullscreen {
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ background: var(--ifm-background-color);
116
+ }
117
+
118
+ .stage:fullscreen .viewport {
119
+ width: 100vw;
120
+ height: 100dvh;
121
+ max-height: none;
122
+ }
123
+
124
+ /* The default backdrop is black, which makes a transparent light-mode diagram look broken. */
125
+ .stage::backdrop {
126
+ background: var(--ifm-background-color, #ffffff);
127
+ }
128
+
129
+ .visuallyHidden {
130
+ position: absolute;
131
+ width: 1px;
132
+ height: 1px;
133
+ margin: -1px;
134
+ padding: 0;
135
+ overflow: hidden;
136
+ clip: rect(0, 0, 0, 0);
137
+ white-space: nowrap;
138
+ border: 0;
139
+ }
140
+
141
+ @media (forced-colors: active) {
142
+ .toolbar {
143
+ border-color: ButtonBorder;
144
+ }
145
+
146
+ .toolbarButton {
147
+ border-color: ButtonBorder;
148
+ color: ButtonText;
149
+ }
150
+ }
151
+
17
152
  .placeholder {
18
153
  display: flex;
19
154
  align-items: center;
@@ -45,6 +180,14 @@
45
180
  .spinner {
46
181
  animation: none;
47
182
  }
183
+
184
+ /*
185
+ * Only the easing of discrete zoom steps is decoration. Dragging and wheel zooming are
186
+ * direct manipulation and stay instantaneous either way.
187
+ */
188
+ .layer {
189
+ --plantuml-zoom-transition: 0s;
190
+ }
48
191
  }
49
192
 
50
193
  .caption {
@@ -0,0 +1,25 @@
1
+ import { type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject } from 'react';
2
+ import { MAX_SCALE, MIN_SCALE } from './zoomMath.js';
3
+ export interface ZoomPanHandle {
4
+ viewportRef: MutableRefObject<HTMLDivElement | null>;
5
+ layerRef: MutableRefObject<HTMLDivElement | null>;
6
+ readoutRef: MutableRefObject<HTMLSpanElement | null>;
7
+ stageRef: MutableRefObject<HTMLDivElement | null>;
8
+ zoomIn: () => void;
9
+ zoomOut: () => void;
10
+ reset: () => void;
11
+ toggleFullscreen: () => void;
12
+ onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void;
13
+ /** Whether this browser can fullscreen an element, so the button can be hidden if not. */
14
+ fullscreenSupported: boolean;
15
+ }
16
+ export interface UseZoomPanParams {
17
+ enabled: boolean;
18
+ /**
19
+ * Changing this resets the view. Pass something that identifies the rendered picture, so a
20
+ * new diagram or a colour-mode switch starts from a clean 100% view.
21
+ */
22
+ resetKey: string;
23
+ }
24
+ export declare function useZoomPan({ enabled, resetKey }: UseZoomPanParams): ZoomPanHandle;
25
+ export { MAX_SCALE, MIN_SCALE };
@@ -0,0 +1,311 @@
1
+ import { useCallback, useEffect, useRef, } from 'react';
2
+ import { clampTransform, fitScale, formatPercent, formatZoom, IDENTITY, MAX_SCALE, MIN_SCALE, normalizeWheelDelta, panBy, PAN_STEP, SCALE_STEP, toCssTransform, wheelZoomFactor, zoomAbout, } from './zoomMath.js';
3
+ /**
4
+ * Zoom, pan and fullscreen for one diagram.
5
+ *
6
+ * The hook holds no React state at all. The transform lives in a ref and is written straight
7
+ * to the DOM, so panning never re-renders the component — and, more usefully, "no state update
8
+ * after unmount" becomes structurally true rather than something a guard has to enforce.
9
+ */
10
+ /** Distance the pointer must travel before a press becomes a drag rather than a click. */
11
+ const DRAG_THRESHOLD_PX = 3;
12
+ /** Fraction of the viewport a Shift+arrow press pans. */
13
+ const PAGE_PAN_RATIO = 0.9;
14
+ function supportsFullscreen() {
15
+ if (typeof document === 'undefined')
16
+ return false;
17
+ const doc = document;
18
+ return Boolean(doc.fullscreenEnabled ||
19
+ typeof document.documentElement.webkitRequestFullscreen ===
20
+ 'function');
21
+ }
22
+ function currentFullscreenElement() {
23
+ if (typeof document === 'undefined')
24
+ return null;
25
+ const doc = document;
26
+ return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;
27
+ }
28
+ export function useZoomPan({ enabled, resetKey }) {
29
+ const stageRef = useRef(null);
30
+ const viewportRef = useRef(null);
31
+ const layerRef = useRef(null);
32
+ const readoutRef = useRef(null);
33
+ const transformRef = useRef(IDENTITY);
34
+ /** The view to restore when leaving fullscreen. */
35
+ const beforeFullscreenRef = useRef(null);
36
+ const frameRef = useRef(null);
37
+ /**
38
+ * Layout sizes, never `getBoundingClientRect()`: the layer's rect includes the transform we
39
+ * are about to change, which would feed back into the next measurement.
40
+ */
41
+ const measure = useCallback(() => {
42
+ const viewport = viewportRef.current;
43
+ const layer = layerRef.current;
44
+ return {
45
+ viewportWidth: viewport?.clientWidth ?? 0,
46
+ viewportHeight: viewport?.clientHeight ?? 0,
47
+ contentWidth: layer?.offsetWidth ?? 0,
48
+ contentHeight: layer?.offsetHeight ?? 0,
49
+ };
50
+ }, []);
51
+ const write = useCallback((next) => {
52
+ transformRef.current = next;
53
+ const layer = layerRef.current;
54
+ const viewport = viewportRef.current;
55
+ if (layer)
56
+ layer.style.transform = toCssTransform(next);
57
+ if (viewport)
58
+ viewport.dataset['plantumlZoom'] = formatZoom(next.k);
59
+ const readout = readoutRef.current;
60
+ if (readout)
61
+ readout.textContent = formatPercent(next.k);
62
+ }, []);
63
+ const apply = useCallback((next) => {
64
+ write(clampTransform(next, measure()));
65
+ }, [measure, write]);
66
+ const reset = useCallback(() => {
67
+ apply(IDENTITY);
68
+ }, [apply]);
69
+ /** Zooms about the middle of the viewport, which is what buttons and keys should do. */
70
+ const zoomByStep = useCallback((factor) => {
71
+ const viewport = viewportRef.current;
72
+ const focalX = (viewport?.clientWidth ?? 0) / 2;
73
+ const focalY = (viewport?.clientHeight ?? 0) / 2;
74
+ apply(zoomAbout(transformRef.current, factor, focalX, focalY));
75
+ }, [apply]);
76
+ const zoomIn = useCallback(() => zoomByStep(SCALE_STEP), [zoomByStep]);
77
+ const zoomOut = useCallback(() => zoomByStep(1 / SCALE_STEP), [zoomByStep]);
78
+ const toggleFullscreen = useCallback(() => {
79
+ const stage = stageRef.current;
80
+ if (!stage)
81
+ return;
82
+ const doc = document;
83
+ if (currentFullscreenElement()) {
84
+ void (doc.exitFullscreen?.() ?? doc.webkitExitFullscreen?.());
85
+ return;
86
+ }
87
+ beforeFullscreenRef.current = transformRef.current;
88
+ void (stage.requestFullscreen?.() ?? stage.webkitRequestFullscreen?.());
89
+ }, []);
90
+ const onKeyDown = useCallback((event) => {
91
+ if (!enabled)
92
+ return;
93
+ // Leave Ctrl+0, Cmd+Arrow and friends to the browser.
94
+ if (event.ctrlKey || event.metaKey || event.altKey)
95
+ return;
96
+ const viewport = viewportRef.current;
97
+ const pageX = (viewport?.clientWidth ?? 0) * PAGE_PAN_RATIO;
98
+ const pageY = (viewport?.clientHeight ?? 0) * PAGE_PAN_RATIO;
99
+ const stepX = event.shiftKey ? pageX : PAN_STEP;
100
+ const stepY = event.shiftKey ? pageY : PAN_STEP;
101
+ switch (event.key) {
102
+ case '+':
103
+ case '=':
104
+ zoomIn();
105
+ break;
106
+ case '-':
107
+ case '_':
108
+ zoomOut();
109
+ break;
110
+ case '0':
111
+ reset();
112
+ break;
113
+ case 'ArrowLeft':
114
+ apply(panBy(transformRef.current, stepX, 0));
115
+ break;
116
+ case 'ArrowRight':
117
+ apply(panBy(transformRef.current, -stepX, 0));
118
+ break;
119
+ case 'ArrowUp':
120
+ apply(panBy(transformRef.current, 0, stepY));
121
+ break;
122
+ case 'ArrowDown':
123
+ apply(panBy(transformRef.current, 0, -stepY));
124
+ break;
125
+ default:
126
+ // Not ours: let the key through, so Tab still escapes the diagram.
127
+ return;
128
+ }
129
+ event.preventDefault();
130
+ }, [apply, enabled, reset, zoomIn, zoomOut]);
131
+ // Everything imperative lives in this one effect, so one cleanup removes all of it.
132
+ useEffect(() => {
133
+ if (!enabled)
134
+ return undefined;
135
+ const viewport = viewportRef.current;
136
+ if (!viewport)
137
+ return undefined;
138
+ // The layer node is replaced whenever the rendered SVG changes; start from a clean view.
139
+ write(IDENTITY);
140
+ let dragging = false;
141
+ let pointerId = null;
142
+ let startX = 0;
143
+ let startY = 0;
144
+ let startTransform = IDENTITY;
145
+ let pendingX = 0;
146
+ let pendingY = 0;
147
+ let suppressClick = false;
148
+ const setContinuous = (on) => {
149
+ const layer = layerRef.current;
150
+ if (layer)
151
+ layer.dataset['plantumlContinuous'] = on ? 'true' : 'false';
152
+ };
153
+ const onWheel = (event) => {
154
+ // Plain wheel must keep scrolling the page. Only Ctrl zooms — Cmd is the browser's own
155
+ // page zoom on macOS, and trackpad pinch already arrives here as Ctrl+wheel.
156
+ if (!event.ctrlKey)
157
+ return;
158
+ event.preventDefault();
159
+ const rect = viewport.getBoundingClientRect();
160
+ const factor = wheelZoomFactor(normalizeWheelDelta(event.deltaY, event.deltaMode));
161
+ setContinuous(true);
162
+ apply(zoomAbout(transformRef.current, factor, event.clientX - rect.left, event.clientY - rect.top));
163
+ };
164
+ const flushPan = () => {
165
+ frameRef.current = null;
166
+ apply(panBy(startTransform, pendingX, pendingY));
167
+ };
168
+ const onPointerDown = (event) => {
169
+ if (!event.isPrimary || event.button !== 0)
170
+ return;
171
+ // Deliberately no preventDefault: it would kill focus and text selection, and links
172
+ // inside a diagram must still be clickable.
173
+ pointerId = event.pointerId;
174
+ startX = event.clientX;
175
+ startY = event.clientY;
176
+ startTransform = transformRef.current;
177
+ pendingX = 0;
178
+ pendingY = 0;
179
+ };
180
+ const onPointerMove = (event) => {
181
+ if (pointerId === null || event.pointerId !== pointerId)
182
+ return;
183
+ pendingX = event.clientX - startX;
184
+ pendingY = event.clientY - startY;
185
+ if (!dragging) {
186
+ if (Math.hypot(pendingX, pendingY) < DRAG_THRESHOLD_PX)
187
+ return;
188
+ dragging = true;
189
+ suppressClick = true;
190
+ viewport.dataset['plantumlDragging'] = 'true';
191
+ setContinuous(true);
192
+ try {
193
+ viewport.setPointerCapture(pointerId);
194
+ }
195
+ catch {
196
+ // Capture is unavailable in some environments; dragging still works without it.
197
+ }
198
+ }
199
+ event.preventDefault();
200
+ if (frameRef.current === null) {
201
+ frameRef.current = requestAnimationFrame(flushPan);
202
+ }
203
+ };
204
+ const endDrag = (event) => {
205
+ if (pointerId === null || event.pointerId !== pointerId)
206
+ return;
207
+ if (frameRef.current !== null) {
208
+ cancelAnimationFrame(frameRef.current);
209
+ frameRef.current = null;
210
+ flushPan();
211
+ }
212
+ try {
213
+ if (viewport.hasPointerCapture(pointerId))
214
+ viewport.releasePointerCapture(pointerId);
215
+ }
216
+ catch {
217
+ // Nothing to release.
218
+ }
219
+ pointerId = null;
220
+ dragging = false;
221
+ delete viewport.dataset['plantumlDragging'];
222
+ setContinuous(false);
223
+ };
224
+ // A drag that ends over a link must not activate it.
225
+ const onClickCapture = (event) => {
226
+ if (!suppressClick)
227
+ return;
228
+ suppressClick = false;
229
+ event.preventDefault();
230
+ event.stopPropagation();
231
+ };
232
+ const onFullscreenChange = () => {
233
+ const stage = stageRef.current;
234
+ const isFullscreen = stage !== null && currentFullscreenElement() === stage;
235
+ if (isFullscreen) {
236
+ // Fill the screen: the viewport is much larger now, so refit rather than keep a
237
+ // transform that was computed for a column-width box.
238
+ apply({ ...IDENTITY, k: fitScale(measure()) });
239
+ }
240
+ else {
241
+ apply(beforeFullscreenRef.current ?? IDENTITY);
242
+ beforeFullscreenRef.current = null;
243
+ }
244
+ };
245
+ viewport.addEventListener('wheel', onWheel, { passive: false });
246
+ viewport.addEventListener('pointerdown', onPointerDown);
247
+ viewport.addEventListener('pointermove', onPointerMove);
248
+ viewport.addEventListener('pointerup', endDrag);
249
+ viewport.addEventListener('pointercancel', endDrag);
250
+ viewport.addEventListener('lostpointercapture', endDrag);
251
+ viewport.addEventListener('click', onClickCapture, true);
252
+ document.addEventListener('fullscreenchange', onFullscreenChange);
253
+ document.addEventListener('webkitfullscreenchange', onFullscreenChange);
254
+ // Re-clamp when the column width changes (window resize, sidebar collapse), so a panned
255
+ // diagram cannot end up stranded outside a narrower viewport.
256
+ const observer = typeof ResizeObserver === 'undefined'
257
+ ? null
258
+ : new ResizeObserver(() => apply(transformRef.current));
259
+ observer?.observe(viewport);
260
+ return () => {
261
+ viewport.removeEventListener('wheel', onWheel);
262
+ viewport.removeEventListener('pointerdown', onPointerDown);
263
+ viewport.removeEventListener('pointermove', onPointerMove);
264
+ viewport.removeEventListener('pointerup', endDrag);
265
+ viewport.removeEventListener('pointercancel', endDrag);
266
+ viewport.removeEventListener('lostpointercapture', endDrag);
267
+ viewport.removeEventListener('click', onClickCapture, true);
268
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
269
+ document.removeEventListener('webkitfullscreenchange', onFullscreenChange);
270
+ observer?.disconnect();
271
+ if (frameRef.current !== null) {
272
+ cancelAnimationFrame(frameRef.current);
273
+ frameRef.current = null;
274
+ }
275
+ if (pointerId !== null) {
276
+ try {
277
+ if (viewport.hasPointerCapture(pointerId))
278
+ viewport.releasePointerCapture(pointerId);
279
+ }
280
+ catch {
281
+ // Already gone.
282
+ }
283
+ }
284
+ };
285
+ // `resetKey` is a dependency so a re-render of the same node with new content re-arms
286
+ // the listeners against a clean view.
287
+ }, [apply, enabled, measure, resetKey, write]);
288
+ /**
289
+ * A cache hit resolves before any phase is reported, so a colour-mode toggle can go
290
+ * `ready -> ready` without ever unmounting the layer. The effect above would not re-run in
291
+ * that case, which is why the reset is also driven directly by `resetKey`.
292
+ */
293
+ useEffect(() => {
294
+ if (!enabled)
295
+ return;
296
+ write(IDENTITY);
297
+ }, [enabled, resetKey, write]);
298
+ return {
299
+ stageRef,
300
+ viewportRef,
301
+ layerRef,
302
+ readoutRef,
303
+ zoomIn,
304
+ zoomOut,
305
+ reset,
306
+ toggleFullscreen,
307
+ onKeyDown,
308
+ fullscreenSupported: supportsFullscreen(),
309
+ };
310
+ }
311
+ export { MAX_SCALE, MIN_SCALE };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Geometry for zooming and panning a diagram.
3
+ *
4
+ * Every function here is pure and DOM-free, which is what makes the interaction testable:
5
+ * jsdom reports every element as zero-sized, so the maths cannot be verified through the
6
+ * component. It is verified directly instead, and the component tests only check wiring.
7
+ */
8
+ export interface Transform {
9
+ /** Scale factor. */
10
+ k: number;
11
+ /** Horizontal translation in CSS pixels, applied before scaling (`transform-origin: 0 0`). */
12
+ x: number;
13
+ /** Vertical translation in CSS pixels. */
14
+ y: number;
15
+ }
16
+ export interface Bounds {
17
+ viewportWidth: number;
18
+ viewportHeight: number;
19
+ /** Layout size of the content at scale 1 — never a `getBoundingClientRect()` measurement. */
20
+ contentWidth: number;
21
+ contentHeight: number;
22
+ }
23
+ export declare const IDENTITY: Transform;
24
+ /** Smallest and largest scale a reader can reach, and the discrete step for buttons and keys. */
25
+ export declare const MIN_SCALE = 0.25;
26
+ export declare const MAX_SCALE = 8;
27
+ export declare const SCALE_STEP = 1.25;
28
+ /** Arrow-key pan distance, in CSS pixels. */
29
+ export declare const PAN_STEP = 48;
30
+ export declare function clamp(value: number, min: number, max: number): number;
31
+ /**
32
+ * Scales about a focal point expressed in viewport coordinates, keeping the content under
33
+ * that point fixed.
34
+ *
35
+ * With `transform-origin: 0 0`, a content point `p` maps to `v = p * k + t`. Solving for the
36
+ * translation that leaves `v` unchanged at the new scale gives `t' = f - (f - t) * (k'/k)`.
37
+ *
38
+ * The ratio is deliberately computed from the *achieved* scale rather than the requested
39
+ * factor: at the min or max limit the two differ, and using the requested factor there would
40
+ * drift the diagram sideways on every further wheel tick.
41
+ */
42
+ export declare function zoomAbout(transform: Transform, factor: number, focalX: number, focalY: number, min?: number, max?: number): Transform;
43
+ export declare function panBy(transform: Transform, dx: number, dy: number): Transform;
44
+ /**
45
+ * Keeps the diagram reachable: an edge can never be dragged inside the viewport, and content
46
+ * that fits is left-aligned — matching the `justify-content: flex-start` behaviour diagrams
47
+ * had before they became zoomable.
48
+ *
49
+ * The axes are clamped independently, so a wide, short diagram pans horizontally while staying
50
+ * pinned vertically.
51
+ */
52
+ export declare function clampTransform(transform: Transform, bounds: Bounds): Transform;
53
+ /**
54
+ * Normalizes wheel deltas to pixels.
55
+ *
56
+ * Firefox reports line-based deltas for a mouse wheel, so an un-normalized `deltaY` zooms
57
+ * roughly sixteen times slower there than in Chromium.
58
+ */
59
+ export declare function normalizeWheelDelta(deltaY: number, deltaMode: number): number;
60
+ /** Converts a normalized wheel delta into a multiplicative scale factor. */
61
+ export declare function wheelZoomFactor(normalizedDelta: number): number;
62
+ /**
63
+ * Largest scale at which the whole diagram still fits inside the given viewport.
64
+ *
65
+ * Used when entering fullscreen. Small diagrams are magnified rather than left stranded in
66
+ * the middle of a large screen — the output is vector, so it stays crisp — but never beyond
67
+ * `max`, and never below {@link MIN_SCALE}.
68
+ */
69
+ export declare function fitScale(bounds: Bounds, max?: number): number;
70
+ export declare function toCssTransform(transform: Transform): string;
71
+ /** Machine-readable scale for `data-plantuml-zoom`, used by tests and author CSS. */
72
+ export declare function formatZoom(scale: number): string;
73
+ /** Human-readable scale for the toolbar readout. */
74
+ export declare function formatPercent(scale: number): string;
75
+ export declare function isIdentity(transform: Transform): boolean;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Geometry for zooming and panning a diagram.
3
+ *
4
+ * Every function here is pure and DOM-free, which is what makes the interaction testable:
5
+ * jsdom reports every element as zero-sized, so the maths cannot be verified through the
6
+ * component. It is verified directly instead, and the component tests only check wiring.
7
+ */
8
+ export const IDENTITY = { k: 1, x: 0, y: 0 };
9
+ /** Smallest and largest scale a reader can reach, and the discrete step for buttons and keys. */
10
+ export const MIN_SCALE = 0.25;
11
+ export const MAX_SCALE = 8;
12
+ export const SCALE_STEP = 1.25;
13
+ /** Arrow-key pan distance, in CSS pixels. */
14
+ export const PAN_STEP = 48;
15
+ export function clamp(value, min, max) {
16
+ return Math.min(Math.max(value, min), max);
17
+ }
18
+ /**
19
+ * Scales about a focal point expressed in viewport coordinates, keeping the content under
20
+ * that point fixed.
21
+ *
22
+ * With `transform-origin: 0 0`, a content point `p` maps to `v = p * k + t`. Solving for the
23
+ * translation that leaves `v` unchanged at the new scale gives `t' = f - (f - t) * (k'/k)`.
24
+ *
25
+ * The ratio is deliberately computed from the *achieved* scale rather than the requested
26
+ * factor: at the min or max limit the two differ, and using the requested factor there would
27
+ * drift the diagram sideways on every further wheel tick.
28
+ */
29
+ export function zoomAbout(transform, factor, focalX, focalY, min = MIN_SCALE, max = MAX_SCALE) {
30
+ const k = clamp(transform.k * factor, min, max);
31
+ const ratio = k / transform.k;
32
+ return {
33
+ k,
34
+ x: focalX - (focalX - transform.x) * ratio,
35
+ y: focalY - (focalY - transform.y) * ratio,
36
+ };
37
+ }
38
+ export function panBy(transform, dx, dy) {
39
+ return { k: transform.k, x: transform.x + dx, y: transform.y + dy };
40
+ }
41
+ /**
42
+ * Keeps the diagram reachable: an edge can never be dragged inside the viewport, and content
43
+ * that fits is left-aligned — matching the `justify-content: flex-start` behaviour diagrams
44
+ * had before they became zoomable.
45
+ *
46
+ * The axes are clamped independently, so a wide, short diagram pans horizontally while staying
47
+ * pinned vertically.
48
+ */
49
+ export function clampTransform(transform, bounds) {
50
+ const scaledWidth = bounds.contentWidth * transform.k;
51
+ const scaledHeight = bounds.contentHeight * transform.k;
52
+ const x = scaledWidth <= bounds.viewportWidth
53
+ ? 0
54
+ : clamp(transform.x, bounds.viewportWidth - scaledWidth, 0);
55
+ const y = scaledHeight <= bounds.viewportHeight
56
+ ? 0
57
+ : clamp(transform.y, bounds.viewportHeight - scaledHeight, 0);
58
+ return { k: transform.k, x, y };
59
+ }
60
+ /** `WheelEvent.deltaMode` constants, which jsdom does not define. */
61
+ const DELTA_PIXEL = 0;
62
+ const DELTA_LINE = 1;
63
+ const LINE_HEIGHT_PX = 16;
64
+ const PAGE_HEIGHT_PX = 400;
65
+ /**
66
+ * Normalizes wheel deltas to pixels.
67
+ *
68
+ * Firefox reports line-based deltas for a mouse wheel, so an un-normalized `deltaY` zooms
69
+ * roughly sixteen times slower there than in Chromium.
70
+ */
71
+ export function normalizeWheelDelta(deltaY, deltaMode) {
72
+ if (deltaMode === DELTA_PIXEL)
73
+ return deltaY;
74
+ if (deltaMode === DELTA_LINE)
75
+ return deltaY * LINE_HEIGHT_PX;
76
+ return deltaY * PAGE_HEIGHT_PX;
77
+ }
78
+ /** Converts a normalized wheel delta into a multiplicative scale factor. */
79
+ export function wheelZoomFactor(normalizedDelta) {
80
+ return Math.exp(-normalizedDelta * 0.002);
81
+ }
82
+ /**
83
+ * Largest scale at which the whole diagram still fits inside the given viewport.
84
+ *
85
+ * Used when entering fullscreen. Small diagrams are magnified rather than left stranded in
86
+ * the middle of a large screen — the output is vector, so it stays crisp — but never beyond
87
+ * `max`, and never below {@link MIN_SCALE}.
88
+ */
89
+ export function fitScale(bounds, max = MAX_SCALE) {
90
+ if (bounds.contentWidth <= 0 || bounds.contentHeight <= 0)
91
+ return 1;
92
+ const scale = Math.min(bounds.viewportWidth / bounds.contentWidth, bounds.viewportHeight / bounds.contentHeight);
93
+ if (!Number.isFinite(scale) || scale <= 0)
94
+ return 1;
95
+ return clamp(scale, MIN_SCALE, max);
96
+ }
97
+ /** Rounds away floating-point noise so repeated zoom in/out returns to exactly 1. */
98
+ function tidy(value) {
99
+ return Math.abs(value) < 1e-6 ? 0 : Math.round(value * 1e6) / 1e6;
100
+ }
101
+ export function toCssTransform(transform) {
102
+ return `translate(${tidy(transform.x)}px, ${tidy(transform.y)}px) scale(${tidy(transform.k)})`;
103
+ }
104
+ /** Machine-readable scale for `data-plantuml-zoom`, used by tests and author CSS. */
105
+ export function formatZoom(scale) {
106
+ return String(tidy(Math.round(scale * 1000) / 1000));
107
+ }
108
+ /** Human-readable scale for the toolbar readout. */
109
+ export function formatPercent(scale) {
110
+ return `${Math.round(scale * 100)}%`;
111
+ }
112
+ export function isIdentity(transform) {
113
+ return tidy(transform.k) === 1 && tidy(transform.x) === 0 && tidy(transform.y) === 0;
114
+ }
@@ -23,5 +23,14 @@ export declare function isPlantUmlLanguage(props: CodeBlockProps, languages: rea
23
23
  * than being rendered from a partially-known source string.
24
24
  */
25
25
  export declare function extractSource(children: ReactNode): string | null;
26
+ /**
27
+ * Reads a boolean flag from the fence metastring: either bare (`zoom`) or explicit
28
+ * (`zoom=true` / `zoom=false`).
29
+ *
30
+ * Returns `undefined` when the flag is absent or malformed, which the caller reads as "fall
31
+ * back to the plugin option". A fence metastring is authored prose, not configuration, so an
32
+ * unrecognized value is ignored rather than failing the site build.
33
+ */
34
+ export declare function parseBooleanMeta(props: CodeBlockProps, key: string): boolean | undefined;
26
35
  /** Reads `title="..."` (or `title='...'`) from the fence metastring. */
27
36
  export declare function parseTitle(props: CodeBlockProps): string | undefined;
@@ -48,6 +48,39 @@ export function extractSource(children) {
48
48
  // MDX always terminates a fence with a newline that is not part of the diagram.
49
49
  return source.replace(/\n$/, '');
50
50
  }
51
+ /**
52
+ * Quoted segments, removed before flag matching so that a value such as
53
+ * `title="zoom=false"` can never be mistaken for a flag.
54
+ */
55
+ const QUOTED_SEGMENT = /(["'])(?:\\.|(?!\1).)*\1/g;
56
+ /**
57
+ * Reads a boolean flag from the fence metastring: either bare (`zoom`) or explicit
58
+ * (`zoom=true` / `zoom=false`).
59
+ *
60
+ * Returns `undefined` when the flag is absent or malformed, which the caller reads as "fall
61
+ * back to the plugin option". A fence metastring is authored prose, not configuration, so an
62
+ * unrecognized value is ignored rather than failing the site build.
63
+ */
64
+ export function parseBooleanMeta(props, key) {
65
+ const metastring = props.metastring;
66
+ if (typeof metastring !== 'string' || metastring === '')
67
+ return undefined;
68
+ const withoutQuoted = metastring.replace(QUOTED_SEGMENT, ' ');
69
+ // Word boundaries by hand: `nozoom` and `zoomed` must not match `zoom`.
70
+ const pattern = new RegExp(`(?:^|\\s)${key}(?:=(\\S+))?(?=\\s|$)`, 'i');
71
+ const match = pattern.exec(withoutQuoted);
72
+ if (!match)
73
+ return undefined;
74
+ const value = match[1];
75
+ if (value === undefined)
76
+ return true;
77
+ const normalized = value.toLowerCase();
78
+ if (normalized === 'true')
79
+ return true;
80
+ if (normalized === 'false')
81
+ return false;
82
+ return undefined;
83
+ }
51
84
  /** Reads `title="..."` (or `title='...'`) from the fence metastring. */
52
85
  export function parseTitle(props) {
53
86
  if (typeof props.title === 'string' && props.title !== '')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matfsw/docusaurus-plantuml-plugin",
3
- "version": "0.1.0",
3
+ "version": "1.0.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",