@matfsw/docusaurus-plantuml-plugin 1.5.2 → 1.6.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 +66 -1
- package/README.md +49 -7
- package/dist/theme/PlantUmlDiagram/index.js +2 -2
- package/dist/theme/PlantUmlDiagram/styles.module.css +85 -32
- package/dist/theme/PlantUmlDiagram/useZoomPan.js +18 -26
- package/dist/theme/PlantUmlDiagram/zoomMath.d.ts +11 -5
- package/dist/theme/PlantUmlDiagram/zoomMath.js +36 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.6.1] - 2026-08-21
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A diagram that fits can now be dragged around the space beside it.** The viewport has always
|
|
15
|
+
shown a grab cursor, but content that fitted was pinned at the origin, so on anything short of
|
|
16
|
+
a magnified diagram the cursor promised something that did not happen. It was most obvious
|
|
17
|
+
maximized: a modest diagram is fitted into the top-left corner of the screen with room to
|
|
18
|
+
spare, and none of that room could be used.
|
|
19
|
+
|
|
20
|
+
`clampTransform` now bounds each axis by the empty space beside the picture,
|
|
21
|
+
`viewport - content × scale`, instead of pinning content that fits at the origin. The diagram
|
|
22
|
+
moves through that space and no further, so it can never leave the viewport, and a magnified
|
|
23
|
+
one still cannot be dragged until an edge comes inside it. Axes stay independent, so a wide,
|
|
24
|
+
short diagram pans sideways while moving only through what little vertical room it has, and
|
|
25
|
+
**Reset** puts everything back.
|
|
26
|
+
|
|
27
|
+
Note that *fitting* makes one axis flush with the screen by construction, so a freshly
|
|
28
|
+
maximized diagram slides only along the other one. Zoom out a step and it moves both ways.
|
|
29
|
+
|
|
30
|
+
- **Dragging no longer marks the diagram's text.** `pointerdown` cannot `preventDefault()`
|
|
31
|
+
without breaking focus, links and double-click, so the browser anchored a text selection on
|
|
32
|
+
the first press — before the 3px drag threshold could tell a drag from a click. The labels
|
|
33
|
+
flashed blue for the whole gesture, a drag past the frame kept extending the selection across
|
|
34
|
+
the page, and it could not be cleared by clicking, because the click that ends a drag is the
|
|
35
|
+
one the component swallows so a drag over a link does not follow it.
|
|
36
|
+
|
|
37
|
+
The canvas inside a zoom viewport is now `user-select: none`, which removes the anchor rather
|
|
38
|
+
than trying to undo its effects. The text stays reachable where it is actually read: the `</>`
|
|
39
|
+
source view with Copy, the diagram search, and the browser's find-in-page. A diagram with
|
|
40
|
+
`zoom=false` is not a drag surface and is untouched; a site that wants the selection back can
|
|
41
|
+
say `[data-plantuml-diagram] svg {user-select: text}`.
|
|
42
|
+
|
|
43
|
+
## [1.6.0] - 2026-08-21
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **The zoom controls no longer cover the diagram.** The toolbar was painted over the frame's
|
|
48
|
+
top-right corner and the minimap toggle over its bottom-left, so at 100% — the view a reader
|
|
49
|
+
arrives at without touching anything — a sequence diagram's first participant and a graph's
|
|
50
|
+
leftmost node were sitting underneath a row of buttons. No placement fixes that, because the
|
|
51
|
+
picture is whatever shape its author drew.
|
|
52
|
+
|
|
53
|
+
A zoomable diagram is now two stacked rows: every control in one row (search bar included),
|
|
54
|
+
and the diagram beneath it. Nothing is painted over the picture at any zoom level; the frame
|
|
55
|
+
is one button-bar row taller than before, which is the price of never covering the content.
|
|
56
|
+
The control row is also the frame's **first** child in the DOM, so tab order follows what the
|
|
57
|
+
reader sees. Maximizing uses the same two rows.
|
|
58
|
+
|
|
59
|
+
The **minimap toggle has moved into the toolbar**, beside Maximize; it no longer has a row of
|
|
60
|
+
its own beneath the diagram. The toolbar wraps rather than overflowing a narrow column. The
|
|
61
|
+
minimap _panel_ still opens over the diagram's bottom-left corner — the reader opens it
|
|
62
|
+
deliberately, closes it from its own ✕, and putting it opposite the toolbar keeps the control
|
|
63
|
+
from covering the map. The source view's header no longer reserves a 12rem gutter for a
|
|
64
|
+
toolbar that is no longer above it.
|
|
65
|
+
|
|
66
|
+
Released as a **minor** rather than the patch these headings would normally derive: the
|
|
67
|
+
rendered markup changed shape. The minimap's own bar element is gone, its toggle is a button
|
|
68
|
+
inside the existing control group, and that group is now the frame's first child rather than
|
|
69
|
+
its last. Site CSS or tests that reached for those positions need updating; the documented
|
|
70
|
+
selectors — `[data-plantuml-diagram]`, `div[role="img"] > svg`, `[data-plantuml-zoom]` and the
|
|
71
|
+
control group's accessible name — are all unchanged.
|
|
72
|
+
|
|
10
73
|
## [1.5.2] - 2026-08-20
|
|
11
74
|
|
|
12
75
|
### Changed
|
|
@@ -540,7 +603,9 @@ Initial release.
|
|
|
540
603
|
opt-out.
|
|
541
604
|
- Plugin option validation that rejects unknown keys and out-of-range values at build time.
|
|
542
605
|
|
|
543
|
-
[Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.
|
|
606
|
+
[Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.6.1...HEAD
|
|
607
|
+
[1.6.1]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.6.0...v1.6.1
|
|
608
|
+
[1.6.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.5.2...v1.6.0
|
|
544
609
|
[1.5.2]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.5.1...v1.5.2
|
|
545
610
|
[1.5.1]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.5.0...v1.5.1
|
|
546
611
|
[1.5.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.4.0...v1.5.0
|
package/README.md
CHANGED
|
@@ -525,7 +525,24 @@ diagram, whichever engine produced it.
|
|
|
525
525
|
## Zoom and pan
|
|
526
526
|
|
|
527
527
|
Large diagrams are unreadable at column width. Rendered diagrams are therefore zoomable and
|
|
528
|
-
pannable by default, with a small control toolbar
|
|
528
|
+
pannable by default, with a small control toolbar above the picture.
|
|
529
|
+
|
|
530
|
+
### The frame's layout
|
|
531
|
+
|
|
532
|
+
A zoomable diagram is two stacked rows: **the controls**, at the top right, and **the diagram**
|
|
533
|
+
beneath them. The search bar opens beside the toolbar, in the same row, and the toolbar wraps
|
|
534
|
+
rather than overflowing a narrow column.
|
|
535
|
+
|
|
536
|
+
Nothing is painted over the picture, so at 100% — the view a reader arrives at — the whole
|
|
537
|
+
diagram is visible. The controls used to float over the frame's corners, which is exactly where
|
|
538
|
+
a sequence diagram draws its first participant and a graph its leftmost node; no amount of
|
|
539
|
+
nudging fixes that, because the picture is whatever shape its author drew. The row costs the
|
|
540
|
+
height of one small button bar per diagram, which is the price of never covering the content.
|
|
541
|
+
|
|
542
|
+
Zooming in can still slide the diagram under the row's edge — the viewport clips it there — and
|
|
543
|
+
that is deliberate: by then the reader is driving, and a frame that grew as you zoomed would
|
|
544
|
+
reflow the page around it. The **minimap**, once opened, does float over the picture's
|
|
545
|
+
bottom-left corner: the reader asked for it, and it closes with its own ✕.
|
|
529
546
|
|
|
530
547
|
### Interaction
|
|
531
548
|
|
|
@@ -533,7 +550,7 @@ pannable by default, with a small control toolbar in the top-right corner.
|
|
|
533
550
|
| ----------------------------------- | -------------------------------------------------------------- |
|
|
534
551
|
| Plain scroll wheel | Scrolls the page. Never intercepted. |
|
|
535
552
|
| **Ctrl** + wheel, or trackpad pinch | Zooms about the pointer |
|
|
536
|
-
| Drag |
|
|
553
|
+
| Drag | Moves the diagram, at any zoom level |
|
|
537
554
|
| One finger on a touchscreen | Scrolls the page |
|
|
538
555
|
| Two-finger pinch on a touchscreen | The browser's own page zoom |
|
|
539
556
|
| Toolbar buttons | Zoom out, zoom in, reset, maximize — plus fit, while maximized |
|
|
@@ -564,11 +581,32 @@ working, and `Tab` always moves on — the diagram is never a keyboard trap.
|
|
|
564
581
|
|
|
565
582
|
The toolbar buttons and the keyboard zoom about the **top-left corner** of the viewport, while
|
|
566
583
|
the wheel zooms about the **pointer**. That difference is deliberate: a diagram rarely fills its
|
|
567
|
-
viewport, and one that fits
|
|
584
|
+
viewport, and one that fits opens at the origin, so the empty space sits to its right and below.
|
|
568
585
|
Zooming about the centre would scale that empty space and push the diagram off the top and left
|
|
569
586
|
edges — most visibly when maximized. Anchoring at the top-left grows the diagram into the empty
|
|
570
587
|
space instead and keeps it visible as long as possible.
|
|
571
588
|
|
|
589
|
+
### Moving the diagram
|
|
590
|
+
|
|
591
|
+
Dragging moves the diagram **whatever the zoom level** — including a small one, and including a
|
|
592
|
+
diagram that fits its frame whole. Maximize a modest diagram and it is fitted into a corner of
|
|
593
|
+
the screen with room to spare; that space is now yours to move it into, rather than a grab
|
|
594
|
+
cursor that did nothing.
|
|
595
|
+
|
|
596
|
+
Text inside a zoomable diagram is **not selectable**, because the same box is the drag surface:
|
|
597
|
+
the browser anchors a selection on the first press, before a drag can be told apart from a
|
|
598
|
+
click, so dragging used to paint the labels blue and leave them that way. The text is still
|
|
599
|
+
reachable everywhere it is actually read — the `</>` source view with its Copy button, the
|
|
600
|
+
diagram search, and the browser's find-in-page. A diagram with `zoom=false` is not a drag
|
|
601
|
+
surface and stays selectable, and a site that wants the selection back everywhere can say
|
|
602
|
+
`[data-plantuml-diagram] svg {user-select: text}`.
|
|
603
|
+
|
|
604
|
+
The rule in both directions is that the diagram stays reachable: it can never be dragged so far
|
|
605
|
+
that an edge comes inside the viewport, and never so far that it leaves the viewport altogether.
|
|
606
|
+
Where there is no room — a picture exactly as wide as its frame — it does not budge on that axis,
|
|
607
|
+
so a wide, short diagram still pans sideways while staying put vertically. The arrow keys move it
|
|
608
|
+
the same way, and **Reset** puts it back where it opened.
|
|
609
|
+
|
|
572
610
|
Scale is limited to 0.25×–8×, and the view resets to 100% whenever the diagram source or the
|
|
573
611
|
site colour mode changes.
|
|
574
612
|
|
|
@@ -587,10 +625,12 @@ highlight without touching the plugin.
|
|
|
587
625
|
|
|
588
626
|
### Minimap
|
|
589
627
|
|
|
590
|
-
The button in the
|
|
591
|
-
rectangle marking what the viewport currently shows.
|
|
592
|
-
|
|
593
|
-
|
|
628
|
+
The minimap button in the toolbar opens a minimap: a small copy of the diagram with a
|
|
629
|
+
rectangle marking what the viewport currently shows. It appears in the diagram's bottom-left
|
|
630
|
+
corner — the opposite one from the toolbar, so the control that opens it never covers it.
|
|
631
|
+
Press or drag anywhere on the map to centre the view there — a single press works as "jump
|
|
632
|
+
there". The map follows every zoom, pan and resize, works while maximized, and closes from its
|
|
633
|
+
own ✕ or the toggle.
|
|
594
634
|
|
|
595
635
|
The map is pointer-only and hidden from assistive technology on purpose: the real viewport is
|
|
596
636
|
already keyboard-operable with the arrow keys, so announcing a second, duplicated view of the
|
|
@@ -635,6 +675,8 @@ would mean most readers never discover it. The costs are real and worth knowing:
|
|
|
635
675
|
|
|
636
676
|
- Each zoomable diagram adds a **keyboard tab stop for the viewport and one per toolbar
|
|
637
677
|
button**. On a page with six diagrams that is a meaningful amount of tabbing.
|
|
678
|
+
- The frame is **one button-bar row taller** than the picture, because the controls sit above it
|
|
679
|
+
rather than over it — see [The frame's layout](#the-frames-layout).
|
|
638
680
|
- The rendered markup gains a viewport and transform wrapper around the `role="img"` container.
|
|
639
681
|
Site CSS that targets `[data-plantuml-diagram] > div[role="img"]` as a **direct child** needs
|
|
640
682
|
updating — see [Accessibility](#accessibility) for both shapes.
|
|
@@ -296,9 +296,9 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
296
296
|
...(zoom.maximized ? { [DATA_ATTR.maximized]: 'true' } : {}),
|
|
297
297
|
...(sourceOpen ? { [DATA_ATTR.sourceOpen]: 'true' } : {}),
|
|
298
298
|
...(minimapOpen ? { [DATA_ATTR.minimapOpen]: 'true' } : {}),
|
|
299
|
-
...(search.open ? { [DATA_ATTR.searchOpen]: 'true' } : {}), children: [state.status === 'ready' && state.svg !== null && !interactive && canvas, state.status === 'ready' && state.svg !== null && !interactive && sourceView, state.status === 'ready' && state.svg !== null && !interactive && sourceAvailable && (_jsxs("div", { className: styles.plainToolbar, role: "group", "aria-label": `${label} source controls`, children: [sourceToggle, copyControl] })), state.status === 'ready' && state.svg !== null && interactive && (_jsxs("div", { ref: zoom.stageRef, className: zoom.maximized ? `${styles.stage} ${styles.maximized}` : styles.stage, children: [_jsxs("div", { className: styles.
|
|
299
|
+
...(search.open ? { [DATA_ATTR.searchOpen]: 'true' } : {}), children: [state.status === 'ready' && state.svg !== null && !interactive && canvas, state.status === 'ready' && state.svg !== null && !interactive && sourceView, state.status === 'ready' && state.svg !== null && !interactive && sourceAvailable && (_jsxs("div", { className: styles.plainToolbar, role: "group", "aria-label": `${label} source controls`, children: [sourceToggle, copyControl] })), state.status === 'ready' && state.svg !== null && interactive && (_jsxs("div", { ref: zoom.stageRef, className: zoom.maximized ? `${styles.stage} ${styles.maximized}` : styles.stage, children: [_jsxs("div", { className: styles.controls, children: [search.open && !sourceOpen && (_jsxs("div", { className: styles.searchBar, role: "search", "aria-label": `${label} search`, children: [_jsx("input", { ref: search.inputRef, type: "text", className: styles.searchInput, placeholder: "Search diagram", "aria-label": "Search diagram text", value: search.query, onChange: (event) => search.setQuery(event.target.value), onKeyDown: onSearchKeyDown }), _jsx("span", { className: styles.searchCount, role: "status", children: search.matchCount === 0
|
|
300
300
|
? '0/0'
|
|
301
|
-
: `${search.currentIndex + 1}/${search.matchCount}` }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Previous match", onClick: search.previous, children: _jsx(ChevronUpIcon, {}) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Next match", onClick: search.next, children: _jsx(ChevronDownIcon, {}) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Close search", onClick: closeSearch, children: _jsx(CloseIcon, {}) })] })), _jsxs("div", { className: styles.toolbar, role: "group", "aria-label": `${label} zoom controls`, children: [!sourceOpen && (_jsx("button", { ref: searchToggleRef, type: "button", className: styles.toolbarButton, "aria-label": "Search diagram", "aria-expanded": search.open, onClick: search.toggle, children: _jsx(SearchIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom out", onClick: zoom.zoomOut, children: _jsx(ZoomOutIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom in", onClick: zoom.zoomIn, children: _jsx(ZoomInIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Reset zoom", onClick: zoom.reset, children: _jsx(ResetZoomIcon, {}) })), !sourceOpen && zoom.maximized && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Fit diagram to screen", onClick: zoom.fit, children: _jsx(FitIcon, {}) })), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Maximize diagram", "aria-pressed": zoom.maximized, onClick: zoom.toggleMaximize, children: zoom.maximized ? _jsx(CloseIcon, {}) : _jsx(MaximizeIcon, {}) }), sourceToggle, copyControl, _jsx("span", { ref: zoom.readoutRef, className: sourceOpen ? `${styles.readout} ${styles.hiddenView}` : styles.readout, "aria-hidden": "true", children: "100%" })] })] }),
|
|
301
|
+
: `${search.currentIndex + 1}/${search.matchCount}` }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Previous match", onClick: search.previous, children: _jsx(ChevronUpIcon, {}) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Next match", onClick: search.next, children: _jsx(ChevronDownIcon, {}) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Close search", onClick: closeSearch, children: _jsx(CloseIcon, {}) })] })), _jsxs("div", { className: styles.toolbar, role: "group", "aria-label": `${label} zoom controls`, children: [!sourceOpen && (_jsx("button", { ref: searchToggleRef, type: "button", className: styles.toolbarButton, "aria-label": "Search diagram", "aria-expanded": search.open, onClick: search.toggle, children: _jsx(SearchIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom out", onClick: zoom.zoomOut, children: _jsx(ZoomOutIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom in", onClick: zoom.zoomIn, children: _jsx(ZoomInIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Reset zoom", onClick: zoom.reset, children: _jsx(ResetZoomIcon, {}) })), !sourceOpen && zoom.maximized && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Fit diagram to screen", onClick: zoom.fit, children: _jsx(FitIcon, {}) })), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Maximize diagram", "aria-pressed": zoom.maximized, onClick: zoom.toggleMaximize, children: zoom.maximized ? _jsx(CloseIcon, {}) : _jsx(MaximizeIcon, {}) }), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": minimapOpen ? 'Hide minimap' : 'Show minimap', "aria-expanded": minimapOpen, onClick: () => setMinimapOpen((open) => !open), children: _jsx(MinimapIcon, {}) })), sourceToggle, copyControl, _jsx("span", { ref: zoom.readoutRef, className: sourceOpen ? `${styles.readout} ${styles.hiddenView}` : styles.readout, "aria-hidden": "true", children: "100%" })] })] }), _jsxs("div", { className: styles.stageBody, children: [_jsx("div", { ref: zoom.viewportRef, className: sourceOpen ? `${styles.viewport} ${styles.invisibleView}` : styles.viewport, tabIndex: sourceOpen ? -1 : 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 }) }), !sourceOpen && minimapOpen && (_jsx(Minimap, { svg: state.svg, zoom: zoom, onClose: () => setMinimapOpen(false) })), sourceView] })] })), 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' && (
|
|
302
302
|
// `aria-busy` on the figure already conveys progress; announcing every phase change
|
|
303
303
|
// would make a page with several diagrams unusable with a screen reader.
|
|
304
304
|
_jsxs("div", { className: styles.placeholder, children: [_jsx("span", { className: styles.spinner, "aria-hidden": "true" }), _jsxs("span", { children: [state.status === 'loading' ? `Loading ${engineName} 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" }), _jsxs("span", { children: ["Error: ", engineName, " 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: {
|
|
@@ -52,8 +52,26 @@
|
|
|
52
52
|
* the figure keeps the exact height it has at 100% no matter how far the reader zooms in, and
|
|
53
53
|
* `max-width: 100%` on the SVG keeps constraining the base size as it always did.
|
|
54
54
|
*/
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* The frame is two stacked rows — controls, then picture — not one box with chrome floating
|
|
58
|
+
* over it.
|
|
59
|
+
*
|
|
60
|
+
* Floating it covered the diagram: the toolbar sat over the top right of every figure and the
|
|
61
|
+
* minimap toggle over the bottom left, and at 100% — the view every reader arrives at — that
|
|
62
|
+
* is exactly where a sequence diagram's first participant and a graph's leftmost node are
|
|
63
|
+
* drawn. No amount of nudging fixes that, because the picture is whatever shape its author
|
|
64
|
+
* drew. A row above the picture means the opening view has nothing painted over it at all.
|
|
65
|
+
* Zooming can still slide content under the row's edge, which is fine: by then the reader is
|
|
66
|
+
* driving, and the alternative is a frame that grows as you zoom.
|
|
67
|
+
*
|
|
68
|
+
* `position: relative` stays for the maximized overlay's sake, not for the controls.
|
|
69
|
+
*/
|
|
55
70
|
.stage {
|
|
56
71
|
position: relative;
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-direction: column;
|
|
74
|
+
gap: 0.5rem;
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
.viewport {
|
|
@@ -72,13 +90,30 @@
|
|
|
72
90
|
|
|
73
91
|
.viewport[data-plantuml-dragging='true'] {
|
|
74
92
|
cursor: grabbing;
|
|
75
|
-
user-select: none;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
|
-
/*
|
|
95
|
+
/*
|
|
96
|
+
* Inside the viewport the SVG must not be clipped by the old scroll container — and its text
|
|
97
|
+
* is not selectable, because this box is a drag surface.
|
|
98
|
+
*
|
|
99
|
+
* Pressing on SVG text used to anchor a native selection: `pointerdown` deliberately does not
|
|
100
|
+
* `preventDefault()`, so that focus, links and double-click keep working, which means the
|
|
101
|
+
* browser starts selecting before the 3px drag threshold has been crossed. Marking the box
|
|
102
|
+
* unselectable once dragging began was too late — the anchor already existed, the labels
|
|
103
|
+
* flashed blue for the whole gesture, a drag past the edge kept extending the selection into
|
|
104
|
+
* the page, and the click that would normally collapse it is the very click a drag swallows.
|
|
105
|
+
* Removing the anchor removes all of that at once.
|
|
106
|
+
*
|
|
107
|
+
* The text stays reachable everywhere it was actually read: the `</>` source view (with Copy),
|
|
108
|
+
* this component's own search, and the browser's find-in-page, none of which need a selection.
|
|
109
|
+
* A diagram with `zoom=false` is not a drag surface and is left alone. A site that wants the
|
|
110
|
+
* old behaviour can say `[data-plantuml-diagram] svg {user-select: text}` — an attribute
|
|
111
|
+
* selector, so no hashed class name is needed.
|
|
112
|
+
*/
|
|
79
113
|
.viewport .canvas {
|
|
80
114
|
display: block;
|
|
81
115
|
overflow: visible;
|
|
116
|
+
user-select: none;
|
|
82
117
|
}
|
|
83
118
|
|
|
84
119
|
.layer {
|
|
@@ -108,25 +143,32 @@
|
|
|
108
143
|
/*
|
|
109
144
|
* One flex row holds the search bar and the toolbar, so opening the search never needs to
|
|
110
145
|
* know how wide the toolbar happens to be — it simply takes the space to its left.
|
|
146
|
+
*
|
|
147
|
+
* `flex-end` keeps the toolbar where readers have always found it, at the top right of the
|
|
148
|
+
* frame — now above the picture rather than on top of it.
|
|
111
149
|
*/
|
|
112
150
|
.controls {
|
|
113
|
-
position: absolute;
|
|
114
|
-
top: 0.5rem;
|
|
115
|
-
right: 0.5rem;
|
|
116
151
|
display: flex;
|
|
152
|
+
/*
|
|
153
|
+
* Wrapping, in both directions, because the row is the only thing holding the controls now:
|
|
154
|
+
* in a narrow column the search bar drops below the toolbar rather than squeezing it, and
|
|
155
|
+
* the toolbar itself breaks across lines rather than overflowing the frame.
|
|
156
|
+
*/
|
|
157
|
+
flex-wrap: wrap;
|
|
117
158
|
gap: 0.5rem;
|
|
118
159
|
align-items: flex-start;
|
|
160
|
+
justify-content: flex-end;
|
|
119
161
|
}
|
|
120
162
|
|
|
121
163
|
.toolbar {
|
|
122
164
|
display: flex;
|
|
165
|
+
flex-wrap: wrap;
|
|
123
166
|
gap: 0.25rem;
|
|
124
167
|
align-items: center;
|
|
125
168
|
padding: 0.25rem;
|
|
126
169
|
border: 1px solid var(--ifm-color-emphasis-300);
|
|
127
170
|
border-radius: var(--ifm-global-radius);
|
|
128
171
|
background: var(--ifm-background-surface-color);
|
|
129
|
-
opacity: 0.92;
|
|
130
172
|
}
|
|
131
173
|
|
|
132
174
|
.searchBar {
|
|
@@ -137,7 +179,6 @@
|
|
|
137
179
|
border: 1px solid var(--ifm-color-emphasis-300);
|
|
138
180
|
border-radius: var(--ifm-global-radius);
|
|
139
181
|
background: var(--ifm-background-surface-color);
|
|
140
|
-
opacity: 0.92;
|
|
141
182
|
}
|
|
142
183
|
|
|
143
184
|
.searchInput {
|
|
@@ -275,9 +316,8 @@
|
|
|
275
316
|
position: fixed;
|
|
276
317
|
inset: 0;
|
|
277
318
|
z-index: var(--ifm-z-index-overlay, 400);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
justify-content: center;
|
|
319
|
+
/* Keeps the control rows off the screen's edges; `inset: 0` fixes the border box either way. */
|
|
320
|
+
padding: 0.5rem;
|
|
281
321
|
/*
|
|
282
322
|
* `--ifm-background-surface-color`, not `--ifm-background-color`: Infima defines the latter
|
|
283
323
|
* as `#0000` in light mode, so the overlay would be transparent and the page would show
|
|
@@ -288,35 +328,49 @@
|
|
|
288
328
|
background: var(--ifm-background-surface-color, #ffffff);
|
|
289
329
|
}
|
|
290
330
|
|
|
331
|
+
/*
|
|
332
|
+
* The picture takes every pixel the two control rows leave. `min-height: 0` is what allows
|
|
333
|
+
* that: a flex item's floor is its content, so without it a tall diagram would push the
|
|
334
|
+
* minimap row off the bottom of the screen instead of being clipped by its own viewport.
|
|
335
|
+
*
|
|
336
|
+
* The viewport itself needs no size: it is the lone item in `.stageBody`'s grid cell, so it
|
|
337
|
+
* stretches to whatever the row is given — 100vw/100dvh here would ignore the padding and
|
|
338
|
+
* the rows and overflow the overlay by exactly their height.
|
|
339
|
+
*/
|
|
340
|
+
.maximized .stageBody {
|
|
341
|
+
flex: 1;
|
|
342
|
+
min-height: 0;
|
|
343
|
+
/*
|
|
344
|
+
* The row must take the height it is *given*, not the height the picture wants. A grid
|
|
345
|
+
* track sized `auto` floors at max-content, so a diagram taller than the screen sized the
|
|
346
|
+
* row past the frame and the viewport painted straight over the minimap row below it —
|
|
347
|
+
* and, worse, `fitScale` then measured that overflowing viewport and concluded the diagram
|
|
348
|
+
* already fitted. `minmax(0, 1fr)` removes the floor, so the track is exactly the free
|
|
349
|
+
* height and the viewport clips the picture as it does everywhere else.
|
|
350
|
+
*/
|
|
351
|
+
grid-template-rows: minmax(0, 1fr);
|
|
352
|
+
}
|
|
353
|
+
|
|
291
354
|
.maximized .viewport {
|
|
292
|
-
width: 100vw;
|
|
293
|
-
height: 100dvh;
|
|
294
355
|
max-height: none;
|
|
295
356
|
}
|
|
296
357
|
|
|
297
358
|
/*
|
|
298
359
|
* Minimap.
|
|
299
360
|
*
|
|
300
|
-
* A small copy of the diagram
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
361
|
+
* A small copy of the diagram with a rectangle marking the visible part. The copy is laid out
|
|
362
|
+
* at the content's own width and scaled down as one block, so it keeps exactly the geometry of
|
|
363
|
+
* the real canvas and the rectangle's coordinates can be multiplied straight through.
|
|
364
|
+
*
|
|
365
|
+
* It floats over the picture's bottom-left corner — it is a child of `.stageBody`, so that is
|
|
366
|
+
* the box it is anchored in, and it is the one control positioned that way. A control the
|
|
367
|
+
* reader has explicitly opened may cover the picture; the toolbar, which is simply always
|
|
368
|
+
* there, may not. The corner is deliberately the opposite one from the toolbar that opens it:
|
|
369
|
+
* a map painted under its own control would be half hidden by it.
|
|
304
370
|
*/
|
|
305
|
-
.minimapBar {
|
|
306
|
-
position: absolute;
|
|
307
|
-
bottom: 0.5rem;
|
|
308
|
-
left: 0.5rem;
|
|
309
|
-
display: flex;
|
|
310
|
-
padding: 0.25rem;
|
|
311
|
-
border: 1px solid var(--ifm-color-emphasis-300);
|
|
312
|
-
border-radius: var(--ifm-global-radius);
|
|
313
|
-
background: var(--ifm-background-surface-color);
|
|
314
|
-
opacity: 0.92;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
371
|
.minimap {
|
|
318
372
|
position: absolute;
|
|
319
|
-
bottom:
|
|
373
|
+
bottom: 0.5rem;
|
|
320
374
|
left: 0.5rem;
|
|
321
375
|
/*
|
|
322
376
|
* The close control sits *beside* the map, not floating over it: a wide, short diagram
|
|
@@ -383,7 +437,6 @@
|
|
|
383
437
|
@media (forced-colors: active) {
|
|
384
438
|
.toolbar,
|
|
385
439
|
.searchBar,
|
|
386
|
-
.minimapBar,
|
|
387
440
|
.minimap {
|
|
388
441
|
border-color: ButtonBorder;
|
|
389
442
|
}
|
|
@@ -563,8 +616,8 @@
|
|
|
563
616
|
align-items: center;
|
|
564
617
|
flex-wrap: wrap;
|
|
565
618
|
gap: 0.5rem;
|
|
566
|
-
/*
|
|
567
|
-
padding: 0.5rem
|
|
619
|
+
/* No clearance needed: the toolbar has a row above the frame, not a corner inside it. */
|
|
620
|
+
padding: 0.5rem 0.75rem 0.25rem;
|
|
568
621
|
}
|
|
569
622
|
|
|
570
623
|
.sourceViewTitle {
|
|
@@ -27,9 +27,9 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
27
27
|
* Layout sizes, never `getBoundingClientRect()`: the layer's rect includes the transform we
|
|
28
28
|
* are about to change — and, mid-easing, the transition — which would feed back into the
|
|
29
29
|
* next measurement. The layer is `width: fit-content`, so its layout size *is* the rendered
|
|
30
|
-
* picture's, not the full-width frame's; that is what lets fitting fill a maximized screen
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* picture's, not the full-width frame's; that is what lets fitting fill a maximized screen,
|
|
31
|
+
* keeps the minimap honest about where the diagram ends, and tells the clamp how much empty
|
|
32
|
+
* space a fitted diagram may be dragged into.
|
|
33
33
|
*/
|
|
34
34
|
const measure = useCallback(() => {
|
|
35
35
|
const viewport = viewportRef.current;
|
|
@@ -41,20 +41,6 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
41
41
|
contentHeight: layer?.offsetHeight ?? 0,
|
|
42
42
|
};
|
|
43
43
|
}, []);
|
|
44
|
-
/**
|
|
45
|
-
* The bounds the clamp runs against: the picture or the viewport, whichever is *wider*.
|
|
46
|
-
*
|
|
47
|
-
* Fitting wants the true picture size; the clamp deliberately does not. Focal zoom must
|
|
48
|
-
* hold the point under the pointer even when that slides the picture's right edge inside
|
|
49
|
-
* the viewport — clamping to the picture itself yanks the diagram sideways mid-gesture the
|
|
50
|
-
* moment that edge is reached. Flooring the width at the viewport's restores exactly the
|
|
51
|
-
* geometry the full-width layer always gave. Height is not floored: the layer never
|
|
52
|
-
* stretched vertically, so the height clamp has always run against the real picture.
|
|
53
|
-
*/
|
|
54
|
-
const clampBounds = useCallback(() => {
|
|
55
|
-
const bounds = measure();
|
|
56
|
-
return { ...bounds, contentWidth: Math.max(bounds.contentWidth, bounds.viewportWidth) };
|
|
57
|
-
}, [measure]);
|
|
58
44
|
const write = useCallback((next) => {
|
|
59
45
|
transformRef.current = next;
|
|
60
46
|
const layer = layerRef.current;
|
|
@@ -76,20 +62,26 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
76
62
|
};
|
|
77
63
|
}, []);
|
|
78
64
|
const getTransform = useCallback(() => transformRef.current, []);
|
|
65
|
+
/**
|
|
66
|
+
* Every gesture ends here, so the clamp is the single place that decides where a diagram may
|
|
67
|
+
* sit. It is handed the measured picture: the floor that keeps focal zoom honest lives inside
|
|
68
|
+
* {@link clampTransform}, applied to that end of the range only, so the other end can offer a
|
|
69
|
+
* fitted diagram the real empty space beside it.
|
|
70
|
+
*/
|
|
79
71
|
const apply = useCallback((next) => {
|
|
80
|
-
write(clampTransform(next,
|
|
81
|
-
}, [
|
|
72
|
+
write(clampTransform(next, measure()));
|
|
73
|
+
}, [measure, write]);
|
|
82
74
|
const reset = useCallback(() => {
|
|
83
75
|
apply(IDENTITY);
|
|
84
76
|
}, [apply]);
|
|
85
77
|
/**
|
|
86
78
|
* Zooms about the viewport's top-left corner.
|
|
87
79
|
*
|
|
88
|
-
* Not the centre: a diagram rarely fills its viewport, and
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
80
|
+
* Not the centre: a diagram rarely fills its viewport, and one that fits opens at the origin,
|
|
81
|
+
* so the empty space sits to its right and below. Zooming about the centre scales that empty
|
|
82
|
+
* space too and pushes the diagram off the top and left edges — most visibly when maximized.
|
|
83
|
+
* Anchoring at the top-left leaves the diagram exactly where it is and grows it into the
|
|
84
|
+
* empty space, keeping it visible as long as possible.
|
|
93
85
|
*
|
|
94
86
|
* Wheel zoom still tracks the pointer, which is what direct manipulation should do.
|
|
95
87
|
*/
|
|
@@ -104,8 +96,8 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
104
96
|
*
|
|
105
97
|
* Only offered while maximized. Inline it would duplicate Reset, because the inline frame
|
|
106
98
|
* grows with the diagram — at 100% everything is already visible, so there is nothing for
|
|
107
|
-
* a fit to add. Translation returns to the origin
|
|
108
|
-
*
|
|
99
|
+
* a fit to add. Translation returns to the origin: fitting is "show me all of it from the
|
|
100
|
+
* top", and the reader can drag it wherever they like from there.
|
|
109
101
|
*/
|
|
110
102
|
const fit = useCallback(() => {
|
|
111
103
|
apply({ ...IDENTITY, k: fitScale(measure()) });
|
|
@@ -42,12 +42,18 @@ export declare function clamp(value: number, min: number, max: number): number;
|
|
|
42
42
|
export declare function zoomAbout(transform: Transform, factor: number, focalX: number, focalY: number, min?: number, max?: number): Transform;
|
|
43
43
|
export declare function panBy(transform: Transform, dx: number, dy: number): Transform;
|
|
44
44
|
/**
|
|
45
|
-
* Keeps the diagram reachable
|
|
46
|
-
* that fits is left-aligned — matching the `justify-content: flex-start` behaviour diagrams
|
|
47
|
-
* had before they became zoomable.
|
|
45
|
+
* Keeps the diagram reachable, in both directions, one axis at a time.
|
|
48
46
|
*
|
|
49
|
-
* The
|
|
50
|
-
*
|
|
47
|
+
* The two ends of the range answer different questions, and are deliberately measured against
|
|
48
|
+
* different things — see {@link clampAxis}. Forwards, the limit is the *empty space* beside the
|
|
49
|
+
* picture: a diagram that fits can be moved through it but never out of the viewport. Content
|
|
50
|
+
* that fits used to be pinned at `0` outright, which left the reader with a grab cursor that
|
|
51
|
+
* moved nothing — most obviously while maximized, where a fitted diagram sits in a corner of a
|
|
52
|
+
* screen with room to spare. Backwards, the limit keeps a magnified diagram from being dragged
|
|
53
|
+
* until an edge comes inside the viewport.
|
|
54
|
+
*
|
|
55
|
+
* The axes are clamped independently, so a wide, short diagram pans horizontally while moving
|
|
56
|
+
* only through the little vertical space it has.
|
|
51
57
|
*/
|
|
52
58
|
export declare function clampTransform(transform: Transform, bounds: Bounds): Transform;
|
|
53
59
|
/**
|
|
@@ -39,23 +39,45 @@ export function panBy(transform, dx, dy) {
|
|
|
39
39
|
return { k: transform.k, x: transform.x + dx, y: transform.y + dy };
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
-
* Keeps the diagram reachable
|
|
43
|
-
* that fits is left-aligned — matching the `justify-content: flex-start` behaviour diagrams
|
|
44
|
-
* had before they became zoomable.
|
|
42
|
+
* Keeps the diagram reachable, in both directions, one axis at a time.
|
|
45
43
|
*
|
|
46
|
-
* The
|
|
47
|
-
*
|
|
44
|
+
* The two ends of the range answer different questions, and are deliberately measured against
|
|
45
|
+
* different things — see {@link clampAxis}. Forwards, the limit is the *empty space* beside the
|
|
46
|
+
* picture: a diagram that fits can be moved through it but never out of the viewport. Content
|
|
47
|
+
* that fits used to be pinned at `0` outright, which left the reader with a grab cursor that
|
|
48
|
+
* moved nothing — most obviously while maximized, where a fitted diagram sits in a corner of a
|
|
49
|
+
* screen with room to spare. Backwards, the limit keeps a magnified diagram from being dragged
|
|
50
|
+
* until an edge comes inside the viewport.
|
|
51
|
+
*
|
|
52
|
+
* The axes are clamped independently, so a wide, short diagram pans horizontally while moving
|
|
53
|
+
* only through the little vertical space it has.
|
|
48
54
|
*/
|
|
49
55
|
export function clampTransform(transform, bounds) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
return {
|
|
57
|
+
k: transform.k,
|
|
58
|
+
x: clampAxis(transform.x, bounds.viewportWidth, bounds.contentWidth, transform.k),
|
|
59
|
+
y: clampAxis(transform.y, bounds.viewportHeight, bounds.contentHeight, transform.k),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One axis of the clamp.
|
|
64
|
+
*
|
|
65
|
+
* **Forwards** (positive translation) the picture may travel exactly as far as the empty space
|
|
66
|
+
* beside it, `viewport - content * k`, and no further — so it never leaves the viewport. Once
|
|
67
|
+
* the content overflows there is no empty space and the limit is `0`.
|
|
68
|
+
*
|
|
69
|
+
* **Backwards** the content size is floored at the viewport's *before* scaling. That looks odd
|
|
70
|
+
* until you zoom: focal zoom must hold the point under the pointer, and a picture narrower than
|
|
71
|
+
* its frame reaches a scale where it overflows while the pointer is still asking it to travel
|
|
72
|
+
* further left than a flush right edge allows. Clamping to the picture there yanks it sideways
|
|
73
|
+
* mid-gesture. The floor buys that fidelity for the price of some empty space at the right
|
|
74
|
+
* while zoomed, which is the trade this component has always made — it is only the *forward*
|
|
75
|
+
* limit, above, that had to stop being measured this way for a fitted diagram to move at all.
|
|
76
|
+
*/
|
|
77
|
+
function clampAxis(value, viewport, content, k) {
|
|
78
|
+
const forward = Math.max(0, viewport - content * k);
|
|
79
|
+
const backward = Math.min(0, viewport - Math.max(content, viewport) * k);
|
|
80
|
+
return clamp(value, backward, forward);
|
|
59
81
|
}
|
|
60
82
|
/** `WheelEvent.deltaMode` constants, which jsdom does not define. */
|
|
61
83
|
const DELTA_PIXEL = 0;
|
package/package.json
CHANGED