@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 +38 -1
- package/README.md +150 -13
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +7 -0
- package/dist/index.cjs +5 -2
- package/dist/index.d.cts +5 -0
- package/dist/options.d.ts +5 -0
- package/dist/options.js +3 -0
- package/dist/theme/MDXComponents/Code/index.js +2 -2
- package/dist/theme/PlantUmlDiagram/index.d.ts +6 -1
- package/dist/theme/PlantUmlDiagram/index.js +18 -5
- package/dist/theme/PlantUmlDiagram/styles.module.css +143 -0
- package/dist/theme/PlantUmlDiagram/useZoomPan.d.ts +25 -0
- package/dist/theme/PlantUmlDiagram/useZoomPan.js +311 -0
- package/dist/theme/PlantUmlDiagram/zoomMath.d.ts +75 -0
- package/dist/theme/PlantUmlDiagram/zoomMath.js +114 -0
- package/dist/theme/codeBlockMeta.d.ts +9 -0
- package/dist/theme/codeBlockMeta.js +33 -0
- package/package.json +1 -1
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/
|
|
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: **
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
314
|
-
|
|
|
315
|
-
| `data-plantuml-diagram`
|
|
316
|
-
| `data-plantuml-status`
|
|
317
|
-
| `data-plantuml-theme`
|
|
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,
|
|
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
|
|
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
|
|
package/dist/constants.d.ts
CHANGED
|
@@ -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',
|
|
127
|
-
|
|
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