@matfsw/docusaurus-plantuml-plugin 0.1.0 → 1.0.1

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,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.1]
11
+
12
+ ### Fixed
13
+
14
+ - Maximizing a diagram no longer uses the Fullscreen API. It took the whole browser window
15
+ fullscreen in Firefox instead of presenting the diagram, and the `::backdrop` sits outside
16
+ the fullscreened element, so the page showed through behind a diagram with a transparent
17
+ background. The diagram now expands into an opaque in-page overlay that fills the browser
18
+ viewport, closed with the same button or <kbd>Escape</kbd>.
19
+ - The maximize control is now available in every browser. It previously required
20
+ `Element.requestFullscreen()` and was hidden where that is missing, notably iOS Safari.
21
+
22
+ ### Added
23
+
24
+ - `data-plantuml-maximized` on the figure while a diagram fills the viewport.
25
+
26
+ ### Changed
27
+
28
+ - The fourth toolbar button is labelled `Maximize diagram` (was `Toggle fullscreen`) and
29
+ exposes `aria-pressed`. End-to-end tests selecting it by its accessible name need updating.
30
+
31
+ ## [1.0.0]
32
+
33
+ First stable release. The public surface — plugin options, fence metadata flags, the `data-*`
34
+ attributes and the shape of the rendered markup — is now covered by semantic versioning.
35
+
36
+ ### Added
37
+
38
+ - Zoom and pan for rendered diagrams: Ctrl + wheel (and trackpad pinch) zooms about the
39
+ pointer, dragging pans, and a `role="group"` control toolbar offers zoom out, zoom in, reset
40
+ and — where the browser supports it — fullscreen.
41
+ - Keyboard operation on a focusable viewport: `+`/`=`, `-`/`_`, `0`, arrow keys, and
42
+ Shift + arrows to pan by most of the viewport. Modified keys are left to the browser, and
43
+ `Tab` always escapes.
44
+ - `zoom` plugin option (default `true`) and a per-fence `zoom` / `zoom=false` metastring flag
45
+ that overrides it for a single diagram.
46
+ - `data-plantuml-interactive` on the figure and `data-plantuml-zoom` on the viewport.
47
+ - `parseBooleanMeta()` for reading boolean flags from a fence metastring.
48
+
49
+ ### Changed
50
+
51
+ - **Breaking: the rendered markup changed for zoomable diagrams.** With `zoom` enabled — the
52
+ default — the `role="img"` container is wrapped by a viewport and a transform layer, and a
53
+ control group is added beside it. Site CSS that targeted
54
+ `[data-plantuml-diagram] > div[role="img"]` as a **direct child** must be updated;
55
+ `div[role="img"] > svg` still holds. Setting `zoom: false` restores the `0.1.0` markup
56
+ exactly.
57
+ - **Breaking: zoomable diagrams add keyboard tab stops.** A zoomable diagram contributes about
58
+ four (the viewport and its buttons).
59
+ - Plain scrolling over a diagram is unaffected: only Ctrl + wheel zooms, and on touchscreens
60
+ one finger still scrolls the page and two-finger pinch is still the browser's own page zoom.
61
+
62
+ ### Fixed
63
+
64
+ - `configureWebpack` no longer throws on Docusaurus `3.5.x`, where `currentBundler` does not
65
+ exist. That release line is now covered by the CI compatibility matrix.
66
+
10
67
  ## [0.1.0]
11
68
 
12
69
  Initial release.
@@ -29,5 +86,7 @@ Initial release.
29
86
  opt-out.
30
87
  - Plugin option validation that rejects unknown keys and out-of-range values at build time.
31
88
 
32
- [Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v0.1.0...HEAD
89
+ [Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.0.1...HEAD
90
+ [1.0.1]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.0.0...v1.0.1
91
+ [1.0.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v0.1.0...v1.0.0
33
92
  [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,88 @@ 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, maximize |
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
+ ### Maximizing
284
+
285
+ The fourth toolbar button expands the diagram to fill the browser viewport, fitted to the
286
+ available space, over an opaque background. <kbd>Escape</kbd> or the same button restores it,
287
+ along with whatever zoom level you had before.
288
+
289
+ This is an in-page overlay rather than the Fullscreen API. `requestFullscreen()` takes the
290
+ entire browser window fullscreen in Firefox instead of presenting the element, and its
291
+ `::backdrop` sits outside the element so the page shows through behind the diagram. An overlay
292
+ has neither problem, behaves identically in every browser, and needs no capability detection —
293
+ so the control also works on iOS Safari, which has no element fullscreen at all.
294
+
295
+ ### Turning it off
296
+
297
+ Globally:
298
+
299
+ ```ts
300
+ ['@matfsw/docusaurus-plantuml-plugin', {zoom: false}];
301
+ ```
302
+
303
+ Or for a single fence, which overrides the option either way:
304
+
305
+ ````markdown
306
+ ```plantuml title="Small diagram" zoom=false
307
+ @startuml
308
+ Alice -> Bob : Hello
309
+ @enduml
310
+ ```
311
+ ````
312
+
313
+ ### Why it is on by default, and what that costs
314
+
315
+ A reader who needs to zoom is rarely the author who would have enabled it, so leaving it off
316
+ would mean most readers never discover it. The costs are real and worth knowing:
317
+
318
+ - Each zoomable diagram adds roughly **four keyboard tab stops** (the viewport and three or
319
+ four buttons). On a page with six diagrams that is a meaningful amount of tabbing.
320
+ - The rendered markup gains a viewport and transform wrapper around the `role="img"` container.
321
+ Site CSS that targets `[data-plantuml-diagram] > div[role="img"]` as a **direct child** needs
322
+ updating — see [Accessibility](#accessibility) for both shapes.
323
+ - Each diagram costs one non-passive `wheel` listener and one `ResizeObserver`, and only in the
324
+ `ready` state.
325
+
326
+ Setting `zoom: false` restores the previous markup exactly.
327
+
238
328
  ## Lazy loading
239
329
 
240
330
  With `lazy: true` (the default), a diagram is not rendered until it scrolls within 300 px of
@@ -278,7 +368,7 @@ in-memory cache for the rest of the session. A storage failure never breaks rend
278
368
 
279
369
  ## Accessibility
280
370
 
281
- Each diagram renders as:
371
+ With `zoom: false`, a diagram renders as:
282
372
 
283
373
  ```html
284
374
  <figure
@@ -293,6 +383,32 @@ Each diagram renders as:
293
383
  </figure>
294
384
  ```
295
385
 
386
+ With zoom enabled (the default), the same `role="img"` element is wrapped by a viewport and a
387
+ transform layer, and a control group is added beside it:
388
+
389
+ ```html
390
+ <figure
391
+ data-plantuml-diagram="plantuml"
392
+ data-plantuml-status="ready"
393
+ data-plantuml-theme="light"
394
+ data-plantuml-interactive="true"
395
+ >
396
+ <div class="stage">
397
+ <div class="viewport" tabindex="0" aria-describedby="…" data-plantuml-zoom="1">
398
+ <div class="layer">
399
+ <div role="img" aria-label="Authentication sequence"><svg>…</svg></div>
400
+ </div>
401
+ </div>
402
+ <div role="group" aria-label="Authentication sequence zoom controls">…buttons…</div>
403
+ </div>
404
+ <p class="visuallyHidden" id="…">Zoomable diagram. Use the zoom controls, …</p>
405
+ <figcaption>Authentication sequence</figcaption>
406
+ <noscript><pre>@startuml …</pre></noscript>
407
+ </figure>
408
+ ```
409
+
410
+ `div[role="img"] > svg` holds in both shapes, so a selector written against it keeps working.
411
+
296
412
  - The fence `title="..."` becomes the `<figcaption>` and the `aria-label`. Without a title,
297
413
  the accessible label falls back to **`PlantUML diagram`**.
298
414
  - Progress is conveyed with `aria-busy` on the `<figure>`, deliberately **not** with an
@@ -303,18 +419,31 @@ Each diagram renders as:
303
419
  a ⚠ glyph in addition to the danger colouring, so it survives colour-blindness and
304
420
  forced-colours mode.
305
421
  - Readers without JavaScript get the diagram source in a `<noscript>` block.
306
- - The spinner animation is disabled under `prefers-reduced-motion: reduce`.
422
+ - The spinner animation is disabled under `prefers-reduced-motion: reduce`, as is the easing of
423
+ discrete zoom steps. Dragging and wheel zooming stay instantaneous either way, because direct
424
+ manipulation is not decoration.
425
+ - Zoom controls live **outside** the `role="img"` element. That role makes its whole subtree
426
+ opaque to assistive technology, so a button placed inside would be invisible to screen-reader
427
+ users.
428
+ - The control group is `role="group"`, not `role="toolbar"`: the toolbar role obliges arrow-key
429
+ navigation between its buttons, and the arrow keys already pan the diagram.
430
+ - Button labels are static and the zoom percentage is `aria-hidden`. A live percentage would
431
+ announce on every wheel tick.
432
+ - Keyboard instructions are attached to the viewport with `aria-describedby` rather than being
433
+ announced on focus repeatedly.
307
434
 
308
435
  ### `data-*` contract
309
436
 
310
437
  These attributes are part of the public surface; write your own end-to-end tests against
311
438
  them.
312
439
 
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` |
440
+ | Attribute | Values |
441
+ | --------------------------- | ---------------------------------------------------------- |
442
+ | `data-plantuml-diagram` | the fence language that matched, e.g. `plantuml` or `puml` |
443
+ | `data-plantuml-status` | `idle` \| `loading` \| `rendering` \| `ready` \| `error` |
444
+ | `data-plantuml-theme` | `light` \| `dark` |
445
+ | `data-plantuml-interactive` | `true`, on the `<figure>`, only when zoom is enabled |
446
+ | `data-plantuml-zoom` | current scale on the viewport, e.g. `1`, `2.5` |
318
447
 
319
448
  ## SVG sanitization and security model
320
449
 
@@ -408,6 +537,9 @@ The runtime requires:
408
537
  - `MutationObserver`
409
538
  - `IntersectionObserver` — optional; a graceful fallback renders immediately when it is absent
410
539
  - `sessionStorage` — optional; only for `cache: 'session'`, which degrades to memory without it
540
+ - Pointer Events — required for drag-to-pan when `zoom` is enabled
541
+ - `ResizeObserver` — optional; without it zoom still works, but the view is not re-clamped when
542
+ the column is resized
411
543
 
412
544
  That is modern evergreen Chrome, Edge, Firefox and Safari. Internet Explorer is not
413
545
  supported and will not be.
@@ -525,7 +657,10 @@ Four layers, all deterministic and none of them calling an external PlantUML ser
525
657
  retry, FIFO queue ordering, queue continuation after failure, timeouts, unmount during
526
658
  render, light/dark cache separation, memory cache eviction, `sessionStorage` failure and
527
659
  corruption handling, SVG sanitization against synthetic malicious input, the loading/ready/
528
- error UI, SSR without browser globals, and the `IntersectionObserver` fallback.
660
+ error UI, SSR without browser globals, the `IntersectionObserver` fallback, and the zoom
661
+ layer: focal-point and clamping geometry as pure functions, the wheel policy (plain wheel
662
+ ignored, Ctrl honoured, Cmd ignored), keyboard handling, both reset triggers, and listener
663
+ teardown.
529
664
  Docusaurus `@theme/*` and `@docusaurus/*` aliases only exist inside a real Docusaurus
530
665
  webpack build, so the Vitest config points them at local stubs.
531
666
  2. **Package verification** (`npm run pack:check`). Runs `npm pack --dry-run --json`, prints
@@ -537,7 +672,11 @@ Four layers, all deterministic and none of them calling an external PlantUML ser
537
672
  fixture in a temporary directory, runs a full `docusaurus build`, and serves it. This is
538
673
  what proves the package works as consumers actually install it.
539
674
  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
675
+ build). Asserts that zooming magnifies the diagram without changing the figure's height,
676
+ that a plain wheel scrolls the page while Ctrl + wheel zooms without scrolling, that the
677
+ focal point stays under the cursor, that panning is clamped so an edge never comes inside
678
+ the viewport, and that `touch-action` stays `pan-y pinch-zoom`. It also asserts that real
679
+ `<svg>` elements appear inside the expected container with the
541
680
  expected diagram labels, that multiple diagrams render on one page, that `.md` and `.mdx`
542
681
  both work, that ordinary code blocks are untouched, that an invalid diagram produces the
543
682
  documented error state, that toggling dark mode re-renders, that client-side navigation
@@ -639,6 +778,12 @@ package.
639
778
  8 MB download is subject to the same budget as a render.
640
779
  - **Consuming the plugin through a `file:` link needs a webpack tweak.** See the entry in
641
780
  Troubleshooting below.
781
+ - **No pinch-to-zoom of the diagram itself on touchscreens.** Two-finger pinch is deliberately
782
+ left to the browser's own page zoom, so a full-width diagram can never become a scroll trap
783
+ and page zoom is never taken away. Trackpad pinch on a laptop does zoom the diagram, because
784
+ the browser reports it as Ctrl + wheel.
785
+ - **Zoom state is not persisted.** Navigating away and back, or switching colour mode, returns
786
+ the diagram to 100%.
642
787
 
643
788
  ## Troubleshooting
644
789
 
@@ -19,4 +19,13 @@ 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";
29
+ /** `"true"` on the figure while the diagram fills the browser viewport. */
30
+ readonly maximized: "data-plantuml-maximized";
22
31
  };
package/dist/constants.js CHANGED
@@ -21,4 +21,13 @@ 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',
31
+ /** `"true"` on the figure while the diagram fills the browser viewport. */
32
+ maximized: 'data-plantuml-maximized',
24
33
  };
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,16 @@ 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' } : {}),
142
+ ...(zoom.maximized ? { [DATA_ATTR.maximized]: 'true' } : {}), children: [state.status === 'ready' && state.svg !== null && !interactive && canvas, state.status === 'ready' && state.svg !== null && interactive && (_jsxs("div", { ref: zoom.stageRef, className: zoom.maximized ? `${styles.stage} ${styles.maximized}` : 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" }) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Maximize diagram", "aria-pressed": zoom.maximized, onClick: zoom.toggleMaximize, children: _jsx("span", { "aria-hidden": "true", children: zoom.maximized ? '✕' : '⛶' }) }), _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
143
  // `aria-busy` on the figure already conveys progress; announcing every phase change
130
144
  // would make a page with several diagrams unusable with a screen reader.
131
145
  _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,153 @@
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
+ /*
112
+ * Maximized is an in-page overlay, not the Fullscreen API.
113
+ *
114
+ * `requestFullscreen()` took the whole browser window fullscreen in Firefox, and its
115
+ * `::backdrop` sits outside the element so the page showed through behind the diagram. A
116
+ * fixed overlay is fully opaque, behaves the same everywhere, and needs no capability check.
117
+ */
118
+ .maximized {
119
+ position: fixed;
120
+ inset: 0;
121
+ z-index: var(--ifm-z-index-overlay, 400);
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ /*
126
+ * `--ifm-background-surface-color`, not `--ifm-background-color`: Infima defines the latter
127
+ * as `#0000` in light mode, so the overlay would be transparent and the page would show
128
+ * through — which is the defect this overlay exists to fix. The surface colour is opaque in
129
+ * both colour modes (white / #242526), and a `var()` fallback would not have saved us here,
130
+ * because the variable is defined, just transparent.
131
+ */
132
+ background: var(--ifm-background-surface-color, #ffffff);
133
+ }
134
+
135
+ .maximized .viewport {
136
+ width: 100vw;
137
+ height: 100dvh;
138
+ max-height: none;
139
+ }
140
+
141
+ .visuallyHidden {
142
+ position: absolute;
143
+ width: 1px;
144
+ height: 1px;
145
+ margin: -1px;
146
+ padding: 0;
147
+ overflow: hidden;
148
+ clip: rect(0, 0, 0, 0);
149
+ white-space: nowrap;
150
+ border: 0;
151
+ }
152
+
153
+ @media (forced-colors: active) {
154
+ .toolbar {
155
+ border-color: ButtonBorder;
156
+ }
157
+
158
+ .toolbarButton {
159
+ border-color: ButtonBorder;
160
+ color: ButtonText;
161
+ }
162
+ }
163
+
17
164
  .placeholder {
18
165
  display: flex;
19
166
  align-items: center;
@@ -45,6 +192,14 @@
45
192
  .spinner {
46
193
  animation: none;
47
194
  }
195
+
196
+ /*
197
+ * Only the easing of discrete zoom steps is decoration. Dragging and wheel zooming are
198
+ * direct manipulation and stay instantaneous either way.
199
+ */
200
+ .layer {
201
+ --plantuml-zoom-transition: 0s;
202
+ }
48
203
  }
49
204
 
50
205
  .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
+ toggleMaximize: () => void;
12
+ onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void;
13
+ /** Whether the diagram currently fills the browser viewport. */
14
+ maximized: 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,313 @@
1
+ import { useCallback, useEffect, useRef, useState, } 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 maximize for one diagram.
5
+ *
6
+ * The transform lives in a ref and is written straight to the DOM, so panning and zooming
7
+ * never re-render the component. The single piece of React state is `maximized`, which changes
8
+ * only on an explicit click or Escape — it drives markup, so it belongs in React, and at one
9
+ * transition per user action it costs nothing.
10
+ */
11
+ /** Distance the pointer must travel before a press becomes a drag rather than a click. */
12
+ const DRAG_THRESHOLD_PX = 3;
13
+ /** Fraction of the viewport a Shift+arrow press pans. */
14
+ const PAGE_PAN_RATIO = 0.9;
15
+ export function useZoomPan({ enabled, resetKey }) {
16
+ const stageRef = useRef(null);
17
+ const viewportRef = useRef(null);
18
+ const layerRef = useRef(null);
19
+ const readoutRef = useRef(null);
20
+ const [maximized, setMaximized] = useState(false);
21
+ const transformRef = useRef(IDENTITY);
22
+ /** The view to restore when the diagram is un-maximized. */
23
+ const beforeMaximizeRef = useRef(null);
24
+ const frameRef = useRef(null);
25
+ /**
26
+ * 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
+ */
29
+ const measure = useCallback(() => {
30
+ const viewport = viewportRef.current;
31
+ const layer = layerRef.current;
32
+ return {
33
+ viewportWidth: viewport?.clientWidth ?? 0,
34
+ viewportHeight: viewport?.clientHeight ?? 0,
35
+ contentWidth: layer?.offsetWidth ?? 0,
36
+ contentHeight: layer?.offsetHeight ?? 0,
37
+ };
38
+ }, []);
39
+ const write = useCallback((next) => {
40
+ transformRef.current = next;
41
+ const layer = layerRef.current;
42
+ const viewport = viewportRef.current;
43
+ if (layer)
44
+ layer.style.transform = toCssTransform(next);
45
+ if (viewport)
46
+ viewport.dataset['plantumlZoom'] = formatZoom(next.k);
47
+ const readout = readoutRef.current;
48
+ if (readout)
49
+ readout.textContent = formatPercent(next.k);
50
+ }, []);
51
+ const apply = useCallback((next) => {
52
+ write(clampTransform(next, measure()));
53
+ }, [measure, write]);
54
+ const reset = useCallback(() => {
55
+ apply(IDENTITY);
56
+ }, [apply]);
57
+ /** Zooms about the middle of the viewport, which is what buttons and keys should do. */
58
+ const zoomByStep = useCallback((factor) => {
59
+ const viewport = viewportRef.current;
60
+ const focalX = (viewport?.clientWidth ?? 0) / 2;
61
+ const focalY = (viewport?.clientHeight ?? 0) / 2;
62
+ apply(zoomAbout(transformRef.current, factor, focalX, focalY));
63
+ }, [apply]);
64
+ const zoomIn = useCallback(() => zoomByStep(SCALE_STEP), [zoomByStep]);
65
+ const zoomOut = useCallback(() => zoomByStep(1 / SCALE_STEP), [zoomByStep]);
66
+ const toggleMaximize = useCallback(() => {
67
+ setMaximized((previous) => !previous);
68
+ }, []);
69
+ const onKeyDown = useCallback((event) => {
70
+ if (!enabled)
71
+ return;
72
+ // Leave Ctrl+0, Cmd+Arrow and friends to the browser.
73
+ if (event.ctrlKey || event.metaKey || event.altKey)
74
+ return;
75
+ const viewport = viewportRef.current;
76
+ const pageX = (viewport?.clientWidth ?? 0) * PAGE_PAN_RATIO;
77
+ const pageY = (viewport?.clientHeight ?? 0) * PAGE_PAN_RATIO;
78
+ const stepX = event.shiftKey ? pageX : PAN_STEP;
79
+ const stepY = event.shiftKey ? pageY : PAN_STEP;
80
+ switch (event.key) {
81
+ case '+':
82
+ case '=':
83
+ zoomIn();
84
+ break;
85
+ case '-':
86
+ case '_':
87
+ zoomOut();
88
+ break;
89
+ case '0':
90
+ reset();
91
+ break;
92
+ case 'ArrowLeft':
93
+ apply(panBy(transformRef.current, stepX, 0));
94
+ break;
95
+ case 'ArrowRight':
96
+ apply(panBy(transformRef.current, -stepX, 0));
97
+ break;
98
+ case 'ArrowUp':
99
+ apply(panBy(transformRef.current, 0, stepY));
100
+ break;
101
+ case 'ArrowDown':
102
+ apply(panBy(transformRef.current, 0, -stepY));
103
+ break;
104
+ default:
105
+ // Not ours: let the key through, so Tab still escapes the diagram.
106
+ return;
107
+ }
108
+ event.preventDefault();
109
+ }, [apply, enabled, reset, zoomIn, zoomOut]);
110
+ // Everything imperative lives in this one effect, so one cleanup removes all of it.
111
+ useEffect(() => {
112
+ if (!enabled)
113
+ return undefined;
114
+ const viewport = viewportRef.current;
115
+ if (!viewport)
116
+ return undefined;
117
+ // The layer node is replaced whenever the rendered SVG changes; start from a clean view.
118
+ write(IDENTITY);
119
+ let dragging = false;
120
+ let pointerId = null;
121
+ let startX = 0;
122
+ let startY = 0;
123
+ let startTransform = IDENTITY;
124
+ let pendingX = 0;
125
+ let pendingY = 0;
126
+ let suppressClick = false;
127
+ const setContinuous = (on) => {
128
+ const layer = layerRef.current;
129
+ if (layer)
130
+ layer.dataset['plantumlContinuous'] = on ? 'true' : 'false';
131
+ };
132
+ const onWheel = (event) => {
133
+ // Plain wheel must keep scrolling the page. Only Ctrl zooms — Cmd is the browser's own
134
+ // page zoom on macOS, and trackpad pinch already arrives here as Ctrl+wheel.
135
+ if (!event.ctrlKey)
136
+ return;
137
+ event.preventDefault();
138
+ const rect = viewport.getBoundingClientRect();
139
+ const factor = wheelZoomFactor(normalizeWheelDelta(event.deltaY, event.deltaMode));
140
+ setContinuous(true);
141
+ apply(zoomAbout(transformRef.current, factor, event.clientX - rect.left, event.clientY - rect.top));
142
+ };
143
+ const flushPan = () => {
144
+ frameRef.current = null;
145
+ apply(panBy(startTransform, pendingX, pendingY));
146
+ };
147
+ const onPointerDown = (event) => {
148
+ if (!event.isPrimary || event.button !== 0)
149
+ return;
150
+ // Deliberately no preventDefault: it would kill focus and text selection, and links
151
+ // inside a diagram must still be clickable.
152
+ pointerId = event.pointerId;
153
+ startX = event.clientX;
154
+ startY = event.clientY;
155
+ startTransform = transformRef.current;
156
+ pendingX = 0;
157
+ pendingY = 0;
158
+ };
159
+ const onPointerMove = (event) => {
160
+ if (pointerId === null || event.pointerId !== pointerId)
161
+ return;
162
+ pendingX = event.clientX - startX;
163
+ pendingY = event.clientY - startY;
164
+ if (!dragging) {
165
+ if (Math.hypot(pendingX, pendingY) < DRAG_THRESHOLD_PX)
166
+ return;
167
+ dragging = true;
168
+ suppressClick = true;
169
+ viewport.dataset['plantumlDragging'] = 'true';
170
+ setContinuous(true);
171
+ try {
172
+ viewport.setPointerCapture(pointerId);
173
+ }
174
+ catch {
175
+ // Capture is unavailable in some environments; dragging still works without it.
176
+ }
177
+ }
178
+ event.preventDefault();
179
+ if (frameRef.current === null) {
180
+ frameRef.current = requestAnimationFrame(flushPan);
181
+ }
182
+ };
183
+ const endDrag = (event) => {
184
+ if (pointerId === null || event.pointerId !== pointerId)
185
+ return;
186
+ if (frameRef.current !== null) {
187
+ cancelAnimationFrame(frameRef.current);
188
+ frameRef.current = null;
189
+ flushPan();
190
+ }
191
+ try {
192
+ if (viewport.hasPointerCapture(pointerId))
193
+ viewport.releasePointerCapture(pointerId);
194
+ }
195
+ catch {
196
+ // Nothing to release.
197
+ }
198
+ pointerId = null;
199
+ dragging = false;
200
+ delete viewport.dataset['plantumlDragging'];
201
+ setContinuous(false);
202
+ };
203
+ // A drag that ends over a link must not activate it.
204
+ const onClickCapture = (event) => {
205
+ if (!suppressClick)
206
+ return;
207
+ suppressClick = false;
208
+ event.preventDefault();
209
+ event.stopPropagation();
210
+ };
211
+ viewport.addEventListener('wheel', onWheel, { passive: false });
212
+ viewport.addEventListener('pointerdown', onPointerDown);
213
+ viewport.addEventListener('pointermove', onPointerMove);
214
+ viewport.addEventListener('pointerup', endDrag);
215
+ viewport.addEventListener('pointercancel', endDrag);
216
+ viewport.addEventListener('lostpointercapture', endDrag);
217
+ viewport.addEventListener('click', onClickCapture, true);
218
+ // Re-clamp when the column width changes (window resize, sidebar collapse), so a panned
219
+ // diagram cannot end up stranded outside a narrower viewport.
220
+ const observer = typeof ResizeObserver === 'undefined'
221
+ ? null
222
+ : new ResizeObserver(() => apply(transformRef.current));
223
+ observer?.observe(viewport);
224
+ return () => {
225
+ viewport.removeEventListener('wheel', onWheel);
226
+ viewport.removeEventListener('pointerdown', onPointerDown);
227
+ viewport.removeEventListener('pointermove', onPointerMove);
228
+ viewport.removeEventListener('pointerup', endDrag);
229
+ viewport.removeEventListener('pointercancel', endDrag);
230
+ viewport.removeEventListener('lostpointercapture', endDrag);
231
+ viewport.removeEventListener('click', onClickCapture, true);
232
+ observer?.disconnect();
233
+ if (frameRef.current !== null) {
234
+ cancelAnimationFrame(frameRef.current);
235
+ frameRef.current = null;
236
+ }
237
+ if (pointerId !== null) {
238
+ try {
239
+ if (viewport.hasPointerCapture(pointerId))
240
+ viewport.releasePointerCapture(pointerId);
241
+ }
242
+ catch {
243
+ // Already gone.
244
+ }
245
+ }
246
+ };
247
+ // `resetKey` is a dependency so a re-render of the same node with new content re-arms
248
+ // the listeners against a clean view.
249
+ }, [apply, enabled, measure, resetKey, write]);
250
+ /**
251
+ * A cache hit resolves before any phase is reported, so a colour-mode toggle can go
252
+ * `ready -> ready` without ever unmounting the layer. The effect above would not re-run in
253
+ * that case, which is why the reset is also driven directly by `resetKey`.
254
+ */
255
+ useEffect(() => {
256
+ if (!enabled)
257
+ return;
258
+ write(IDENTITY);
259
+ }, [enabled, resetKey, write]);
260
+ /**
261
+ * Maximizing is done in the page, not with the Fullscreen API.
262
+ *
263
+ * `requestFullscreen()` gave two problems it cannot solve: Firefox takes the whole browser
264
+ * window fullscreen rather than presenting the element, and the `::backdrop` is outside the
265
+ * element so the page behind it shows through. A fixed-position overlay we own has neither
266
+ * issue, works identically in every browser, and needs no capability detection — which also
267
+ * restores the control on iOS Safari, where element fullscreen does not exist.
268
+ */
269
+ useEffect(() => {
270
+ if (!enabled || !maximized)
271
+ return undefined;
272
+ beforeMaximizeRef.current = transformRef.current;
273
+ // The overlay covers the viewport; scrolling the page underneath it would be confusing.
274
+ const body = document.body;
275
+ const previousOverflow = body.style.overflow;
276
+ body.style.overflow = 'hidden';
277
+ // The layout is already committed by the time an effect runs, so the viewport now
278
+ // reports its maximized size and the diagram can be fitted to it.
279
+ apply({ ...IDENTITY, k: fitScale(measure()) });
280
+ viewportRef.current?.focus();
281
+ const onKeyDownDocument = (event) => {
282
+ if (event.key !== 'Escape')
283
+ return;
284
+ event.preventDefault();
285
+ setMaximized(false);
286
+ };
287
+ document.addEventListener('keydown', onKeyDownDocument);
288
+ return () => {
289
+ document.removeEventListener('keydown', onKeyDownDocument);
290
+ body.style.overflow = previousOverflow;
291
+ apply(beforeMaximizeRef.current ?? IDENTITY);
292
+ beforeMaximizeRef.current = null;
293
+ };
294
+ }, [apply, enabled, maximized, measure]);
295
+ // A diagram that is re-rendered or unmounted must not leave the page scroll-locked.
296
+ useEffect(() => {
297
+ if (!enabled)
298
+ setMaximized(false);
299
+ }, [enabled]);
300
+ return {
301
+ stageRef,
302
+ viewportRef,
303
+ layerRef,
304
+ readoutRef,
305
+ zoomIn,
306
+ zoomOut,
307
+ reset,
308
+ toggleMaximize,
309
+ onKeyDown,
310
+ maximized,
311
+ };
312
+ }
313
+ 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 maximizing. 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 maximizing. 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.1",
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",