@matfsw/docusaurus-plantuml-plugin 1.1.1 → 1.2.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 +70 -1
- package/README.md +67 -14
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/index.cjs +4 -1
- 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 +1 -1
- package/dist/theme/PlantUmlDiagram/index.d.ts +6 -1
- package/dist/theme/PlantUmlDiagram/index.js +51 -4
- package/dist/theme/PlantUmlDiagram/styles.module.css +117 -0
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.0]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **A source view for every rendered diagram.** A `</>` control in the toolbar, beside the zoom
|
|
15
|
+
buttons, **flips the frame**: the source takes the diagram's place, in the same box. A
|
|
16
|
+
**Copy** button joins the same toolbar while the source is shown, so every control on a
|
|
17
|
+
diagram lives in one place. Enabled by default; turn it off with
|
|
18
|
+
`showSource: false`, or per fence with `showSource=false` in the metastring.
|
|
19
|
+
|
|
20
|
+
Four details are deliberate:
|
|
21
|
+
- **It shares the diagram's frame rather than sitting below it.** Below the diagram it had two
|
|
22
|
+
failure modes: a diagram taller than the window pushed it off-screen, and while maximized it
|
|
23
|
+
was painted behind the full-screen overlay. Sharing the frame removes both by construction.
|
|
24
|
+
- **The diagram is hidden, not unmounted**, so the frame keeps its height — nothing on the page
|
|
25
|
+
moves when you flip — and the reader's zoom and pan survive the round trip.
|
|
26
|
+
- The copy result is announced through a `role="status"` region rather than by renaming the
|
|
27
|
+
button, because a control whose accessible name changes is announced as a new control.
|
|
28
|
+
- A failed copy says so. `navigator.clipboard` is undefined outside a secure context, which a
|
|
29
|
+
docs site on plain HTTP genuinely is; the source is on screen either way, so the reader can
|
|
30
|
+
select the text.
|
|
31
|
+
|
|
32
|
+
- `showSource` plugin option and the matching `showSource` / `showSource=false` fence flag.
|
|
33
|
+
- `data-plantuml-source-open="true"` on the `<figure>` while the source is shown, part of the
|
|
34
|
+
documented `data-*` contract.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- The zoom controls are hidden while the source is shown — they would act on a picture nobody
|
|
39
|
+
can see. **Maximize stays**, because it sizes the frame the source is read in and removing it
|
|
40
|
+
while maximized would leave Escape as the only way back out.
|
|
41
|
+
|
|
42
|
+
- With `zoom: false` the source control gets its own row after the diagram, since there is no
|
|
43
|
+
zoom toolbar to join. `figure > div[role="img"]` remains the first child, so the shape the
|
|
44
|
+
pre-zoom markup has always had is unchanged. Setting both `zoom: false` and
|
|
45
|
+
`showSource: false` restores the bare markup exactly.
|
|
46
|
+
|
|
47
|
+
## [1.1.2]
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- De-flaked the zoom test suite. `data-plantuml-status="ready"` is written during the render
|
|
52
|
+
commit, but `useZoomPan` attaches its listeners and writes the initial transform from
|
|
53
|
+
*passive* effects that React flushes afterwards. The shared `renderReady()` helper returned on
|
|
54
|
+
the attribute alone, so a test could act on the viewport in between — a dispatched wheel event
|
|
55
|
+
found no listener, and a zoom set before the reset effect ran was immediately overwritten.
|
|
56
|
+
Both surfaced on CI as a scale stuck at 1, and one of them failed the `1.1.1` release build.
|
|
57
|
+
The helper now also waits for the hook's inline transform, which only the hook ever writes.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- Development dependencies: `vitest` and `@vitest/coverage-v8` `3` → `4`, and
|
|
62
|
+
`@testing-library/jest-dom` `6` → `7`. No product code changed; all 433 tests and the
|
|
63
|
+
coverage report pass unchanged.
|
|
64
|
+
|
|
65
|
+
- `.github/dependabot.yml` now ignores the majors that are blocked upstream rather than merely
|
|
66
|
+
unreviewed, each with the reason and the condition for revisiting. Left un-ignored they
|
|
67
|
+
produced a grouped pull request that could never go green, which hid the updates that *were*
|
|
68
|
+
takeable:
|
|
69
|
+
- **TypeScript 7** — typescript-eslint refuses to run against it
|
|
70
|
+
([typescript-eslint#10940](https://github.com/typescript-eslint/typescript-eslint/issues/10940)).
|
|
71
|
+
- **ESLint 10** — `eslint-plugin-react@7.37.5`, the latest release, still caps its peer range
|
|
72
|
+
at `^9.7`.
|
|
73
|
+
- **eslint-plugin-react-hooks 7** — its React Compiler rules flag 15 places in `useZoomPan`
|
|
74
|
+
that are the deliberate design recorded in
|
|
75
|
+
[ADR 0003](docs/adr/0003-zoom-container-transform.md).
|
|
76
|
+
|
|
10
77
|
## [1.1.1]
|
|
11
78
|
|
|
12
79
|
### Security
|
|
@@ -217,7 +284,9 @@ Initial release.
|
|
|
217
284
|
opt-out.
|
|
218
285
|
- Plugin option validation that rejects unknown keys and out-of-range values at build time.
|
|
219
286
|
|
|
220
|
-
[Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.
|
|
287
|
+
[Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.2.0...HEAD
|
|
288
|
+
[1.2.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.1.2...v1.2.0
|
|
289
|
+
[1.1.2]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.1.1...v1.1.2
|
|
221
290
|
[1.1.1]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.1.0...v1.1.1
|
|
222
291
|
[1.1.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.0.3...v1.1.0
|
|
223
292
|
[1.0.3]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.0.2...v1.0.3
|
package/README.md
CHANGED
|
@@ -231,19 +231,20 @@ props untouched. Existing code-block behaviour (highlighting, line numbers, titl
|
|
|
231
231
|
|
|
232
232
|
## Options
|
|
233
233
|
|
|
234
|
-
| Option | Type | Default | Description
|
|
235
|
-
| ------------------- | --------------------------------- | ---------------------- |
|
|
236
|
-
| `languages` | `string[]` | `['plantuml', 'puml']` | Fence languages treated as PlantUML. Matched case-insensitively; must contain at least one non-empty, non-duplicate entry.
|
|
237
|
-
| `theme` | `'auto' \| 'light' \| 'dark'` | `'auto'` | Diagram colour scheme. `auto` follows the Docusaurus colour mode; `light`/`dark` pin it.
|
|
238
|
-
| `lazy` | `boolean` | `true` | Render a diagram only once it scrolls near the viewport (300 px root margin).
|
|
239
|
-
| `cache` | `'none' \| 'memory' \| 'session'` | `'memory'` | Where rendered SVG is cached.
|
|
240
|
-
| `sanitizeSvg` | `boolean` | `true` | Run rendered SVG through DOMPurify before inserting it. See [Security model](#svg-sanitization-and-security-model).
|
|
241
|
-
| `showSourceOnError` | `boolean` | `true` | Include the diagram source in a `<details>` block on the error panel.
|
|
242
|
-
| `renderTimeoutMs` | `number` | `20000` | Abort a single render (and the runtime load) after this many milliseconds. Integer, `100`–`600000`.
|
|
243
|
-
| `cacheMaxEntries` | `number` | `50` | Upper bound on cached SVG entries. Positive integer.
|
|
244
|
-
| `zoom` | `boolean` | `true` | Let readers zoom and pan diagrams. Adds a control toolbar and a focusable viewport. Override per fence with `zoom=false`.
|
|
245
|
-
| `
|
|
246
|
-
| `
|
|
234
|
+
| Option | Type | Default | Description |
|
|
235
|
+
| ------------------- | --------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
236
|
+
| `languages` | `string[]` | `['plantuml', 'puml']` | Fence languages treated as PlantUML. Matched case-insensitively; must contain at least one non-empty, non-duplicate entry. |
|
|
237
|
+
| `theme` | `'auto' \| 'light' \| 'dark'` | `'auto'` | Diagram colour scheme. `auto` follows the Docusaurus colour mode; `light`/`dark` pin it. |
|
|
238
|
+
| `lazy` | `boolean` | `true` | Render a diagram only once it scrolls near the viewport (300 px root margin). |
|
|
239
|
+
| `cache` | `'none' \| 'memory' \| 'session'` | `'memory'` | Where rendered SVG is cached. |
|
|
240
|
+
| `sanitizeSvg` | `boolean` | `true` | Run rendered SVG through DOMPurify before inserting it. See [Security model](#svg-sanitization-and-security-model). |
|
|
241
|
+
| `showSourceOnError` | `boolean` | `true` | Include the diagram source in a `<details>` block on the error panel. |
|
|
242
|
+
| `renderTimeoutMs` | `number` | `20000` | Abort a single render (and the runtime load) after this many milliseconds. Integer, `100`–`600000`. |
|
|
243
|
+
| `cacheMaxEntries` | `number` | `50` | Upper bound on cached SVG entries. Positive integer. |
|
|
244
|
+
| `zoom` | `boolean` | `true` | Let readers zoom and pan diagrams. Adds a control toolbar and a focusable viewport. Override per fence with `zoom=false`. |
|
|
245
|
+
| `showSource` | `boolean` | `true` | Offer a toolbar control that reveals the diagram source and copies it to the clipboard. Override per fence with `showSource=false`. |
|
|
246
|
+
| `graphviz` | `object` | see below | Graphviz/DOT support. See [Graphviz options](#graphviz-options). |
|
|
247
|
+
| `id` | `string` | `'default'` | Docusaurus plugin instance id; only relevant if you register the plugin more than once. |
|
|
247
248
|
|
|
248
249
|
Every option above except `graphviz` applies to **both** engines: `lazy`, `cache`,
|
|
249
250
|
`sanitizeSvg`, `showSourceOnError`, `renderTimeoutMs`, `cacheMaxEntries` and `zoom` behave
|
|
@@ -257,7 +258,7 @@ would otherwise silently disable the option you meant to set:
|
|
|
257
258
|
```text
|
|
258
259
|
[docusaurus-plugin-plantuml-client] Unknown option 'sanitiseSvg'. Supported options:
|
|
259
260
|
'languages', 'theme', 'lazy', 'cache', 'sanitizeSvg', 'showSourceOnError',
|
|
260
|
-
'renderTimeoutMs', 'cacheMaxEntries', 'zoom', 'graphviz'.
|
|
261
|
+
'renderTimeoutMs', 'cacheMaxEntries', 'zoom', 'showSource', 'graphviz'.
|
|
261
262
|
```
|
|
262
263
|
|
|
263
264
|
The same is true one level deeper, so `graphviz: {enigne: 'neato'}` fails the build rather than
|
|
@@ -499,6 +500,58 @@ would mean most readers never discover it. The costs are real and worth knowing:
|
|
|
499
500
|
|
|
500
501
|
Setting `zoom: false` restores the previous markup exactly.
|
|
501
502
|
|
|
503
|
+
## Source view
|
|
504
|
+
|
|
505
|
+
Every rendered diagram offers its own source. The `</>` control in the toolbar **flips the
|
|
506
|
+
frame**: the source takes the diagram's place, in the same box. A **Copy** button appears beside
|
|
507
|
+
it in the same toolbar while the source is shown. Press `</>` again to flip back.
|
|
508
|
+
|
|
509
|
+
````markdown
|
|
510
|
+
```plantuml title="Readers can copy this"
|
|
511
|
+
@startuml
|
|
512
|
+
Alice -> Bob : Hello
|
|
513
|
+
@enduml
|
|
514
|
+
```
|
|
515
|
+
````
|
|
516
|
+
|
|
517
|
+
Details worth knowing:
|
|
518
|
+
|
|
519
|
+
- **The source shares the diagram's frame.** It was previously rendered _below_ the diagram,
|
|
520
|
+
which had two failure modes: a diagram taller than the window pushed it off-screen, so the
|
|
521
|
+
control looked broken; and while maximized it was painted behind the full-screen overlay, so
|
|
522
|
+
it was invisible however far you scrolled. In the frame it appears exactly where the picture
|
|
523
|
+
was.
|
|
524
|
+
- **The diagram is hidden, not unmounted.** The frame keeps its height, so nothing on the page
|
|
525
|
+
moves when you flip, and your zoom and pan survive the round trip.
|
|
526
|
+
- **The zoom controls step aside while the source is shown** — they would act on a picture
|
|
527
|
+
nobody can see. Maximize stays, because it sizes the frame the source is read in, and taking
|
|
528
|
+
it away while maximized would leave Escape as the only way out.
|
|
529
|
+
- The source stays outside the `role="img"` subtree, which is opaque to assistive technology —
|
|
530
|
+
content inside it would be unreachable for screen-reader users.
|
|
531
|
+
- **The copy result is announced through a `role="status"` region**, not by renaming the button.
|
|
532
|
+
A control whose accessible name changes is announced as a new control every time.
|
|
533
|
+
- **A failed copy says so.** `navigator.clipboard` is undefined outside a secure context, which
|
|
534
|
+
a docs site served over plain HTTP genuinely is. The panel is open either way, so the reader
|
|
535
|
+
can select the text; the control never silently does nothing.
|
|
536
|
+
- The panel shows the **authored source**, never the rendered SVG.
|
|
537
|
+
|
|
538
|
+
Turn it off for a whole site with `showSource: false`, or for one fence:
|
|
539
|
+
|
|
540
|
+
````markdown
|
|
541
|
+
```plantuml showSource=false
|
|
542
|
+
@startuml
|
|
543
|
+
Alice -> Bob : Hello
|
|
544
|
+
@enduml
|
|
545
|
+
```
|
|
546
|
+
````
|
|
547
|
+
|
|
548
|
+
With `zoom: false` there is no zoom toolbar, so the control gets its own row after the diagram.
|
|
549
|
+
Setting both `zoom: false` and `showSource: false` restores the bare markup the plugin produced
|
|
550
|
+
before either feature existed.
|
|
551
|
+
|
|
552
|
+
This is separate from `showSourceOnError`, which offers the source in the error panel when a
|
|
553
|
+
diagram _fails_ to render.
|
|
554
|
+
|
|
502
555
|
## Lazy loading
|
|
503
556
|
|
|
504
557
|
With `lazy: true` (the default), a diagram is not rendered until it scrolls within 300 px of
|
package/dist/constants.d.ts
CHANGED
|
@@ -39,4 +39,6 @@ export declare const DATA_ATTR: {
|
|
|
39
39
|
readonly zoom: "data-plantuml-zoom";
|
|
40
40
|
/** `"true"` on the figure while the diagram fills the browser viewport. */
|
|
41
41
|
readonly maximized: "data-plantuml-maximized";
|
|
42
|
+
/** `"true"` on the figure while the source panel is open. */
|
|
43
|
+
readonly sourceOpen: "data-plantuml-source-open";
|
|
42
44
|
};
|
package/dist/constants.js
CHANGED
|
@@ -41,4 +41,6 @@ export const DATA_ATTR = {
|
|
|
41
41
|
zoom: 'data-plantuml-zoom',
|
|
42
42
|
/** `"true"` on the figure while the diagram fills the browser viewport. */
|
|
43
43
|
maximized: 'data-plantuml-maximized',
|
|
44
|
+
/** `"true"` on the figure while the source panel is open. */
|
|
45
|
+
sourceOpen: 'data-plantuml-source-open',
|
|
44
46
|
};
|
package/dist/index.cjs
CHANGED
|
@@ -126,6 +126,7 @@ var DEFAULT_OPTIONS = {
|
|
|
126
126
|
renderTimeoutMs: 2e4,
|
|
127
127
|
cacheMaxEntries: 50,
|
|
128
128
|
zoom: true,
|
|
129
|
+
showSource: true,
|
|
129
130
|
graphviz: DEFAULT_GRAPHVIZ_OPTIONS
|
|
130
131
|
};
|
|
131
132
|
var CACHE_MODES = ["none", "memory", "session"];
|
|
@@ -276,6 +277,7 @@ function resolveOptions(rawOptions) {
|
|
|
276
277
|
"renderTimeoutMs",
|
|
277
278
|
"cacheMaxEntries",
|
|
278
279
|
"zoom",
|
|
280
|
+
"showSource",
|
|
279
281
|
"graphviz",
|
|
280
282
|
// Docusaurus injects `id` for multi-instance plugins.
|
|
281
283
|
"id"
|
|
@@ -323,7 +325,8 @@ function resolveOptions(rawOptions) {
|
|
|
323
325
|
),
|
|
324
326
|
renderTimeoutMs: validateRenderTimeout(rawOptions.renderTimeoutMs),
|
|
325
327
|
cacheMaxEntries: validateCacheMaxEntries(rawOptions.cacheMaxEntries),
|
|
326
|
-
zoom: validateBoolean(rawOptions.zoom, "zoom", DEFAULT_OPTIONS.zoom)
|
|
328
|
+
zoom: validateBoolean(rawOptions.zoom, "zoom", DEFAULT_OPTIONS.zoom),
|
|
329
|
+
showSource: validateBoolean(rawOptions.showSource, "showSource", DEFAULT_OPTIONS.showSource)
|
|
327
330
|
};
|
|
328
331
|
}
|
|
329
332
|
|
package/dist/index.d.cts
CHANGED
|
@@ -69,6 +69,11 @@ interface PlantUmlPluginOptions {
|
|
|
69
69
|
* Override for a single fence with `zoom` or `zoom=false` in its metastring.
|
|
70
70
|
*/
|
|
71
71
|
zoom?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Offer a control that reveals the diagram's source and copies it to the clipboard.
|
|
74
|
+
* Override for a single fence with `showSource` or `showSource=false` in its metastring.
|
|
75
|
+
*/
|
|
76
|
+
showSource?: boolean;
|
|
72
77
|
/** Graphviz/DOT diagram support. See {@link GraphvizOptions}. */
|
|
73
78
|
graphviz?: GraphvizOptions;
|
|
74
79
|
/** Docusaurus plugin instance id, used when the plugin is registered more than once. */
|
package/dist/options.d.ts
CHANGED
|
@@ -66,6 +66,11 @@ export interface PlantUmlPluginOptions {
|
|
|
66
66
|
* Override for a single fence with `zoom` or `zoom=false` in its metastring.
|
|
67
67
|
*/
|
|
68
68
|
zoom?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Offer a control that reveals the diagram's source and copies it to the clipboard.
|
|
71
|
+
* Override for a single fence with `showSource` or `showSource=false` in its metastring.
|
|
72
|
+
*/
|
|
73
|
+
showSource?: boolean;
|
|
69
74
|
/** Graphviz/DOT diagram support. See {@link GraphvizOptions}. */
|
|
70
75
|
graphviz?: GraphvizOptions;
|
|
71
76
|
/** Docusaurus plugin instance id, used when the plugin is registered more than once. */
|
package/dist/options.js
CHANGED
|
@@ -37,6 +37,7 @@ export const DEFAULT_OPTIONS = {
|
|
|
37
37
|
renderTimeoutMs: 20_000,
|
|
38
38
|
cacheMaxEntries: 50,
|
|
39
39
|
zoom: true,
|
|
40
|
+
showSource: true,
|
|
40
41
|
graphviz: DEFAULT_GRAPHVIZ_OPTIONS,
|
|
41
42
|
};
|
|
42
43
|
const CACHE_MODES = ['none', 'memory', 'session'];
|
|
@@ -189,6 +190,7 @@ export function resolveOptions(rawOptions) {
|
|
|
189
190
|
'renderTimeoutMs',
|
|
190
191
|
'cacheMaxEntries',
|
|
191
192
|
'zoom',
|
|
193
|
+
'showSource',
|
|
192
194
|
'graphviz',
|
|
193
195
|
// Docusaurus injects `id` for multi-instance plugins.
|
|
194
196
|
'id',
|
|
@@ -227,5 +229,6 @@ export function resolveOptions(rawOptions) {
|
|
|
227
229
|
renderTimeoutMs: validateRenderTimeout(rawOptions.renderTimeoutMs),
|
|
228
230
|
cacheMaxEntries: validateCacheMaxEntries(rawOptions.cacheMaxEntries),
|
|
229
231
|
zoom: validateBoolean(rawOptions.zoom, 'zoom', DEFAULT_OPTIONS.zoom),
|
|
232
|
+
showSource: validateBoolean(rawOptions.showSource, 'showSource', DEFAULT_OPTIONS.showSource),
|
|
230
233
|
};
|
|
231
234
|
}
|
|
@@ -29,7 +29,7 @@ export default function Code(props) {
|
|
|
29
29
|
if (engine === null || source === null) {
|
|
30
30
|
return _jsx(OriginalCode, { ...props });
|
|
31
31
|
}
|
|
32
|
-
return (_jsx(PlantUmlDiagram, { source: source, title: parseTitle(props), language: language, engine: engine, layout: engine === 'graphviz' ? parseStringMeta(props, 'engine') : undefined, zoom: parseBooleanMeta(props, 'zoom') }));
|
|
32
|
+
return (_jsx(PlantUmlDiagram, { source: source, title: parseTitle(props), language: language, engine: engine, layout: engine === 'graphviz' ? parseStringMeta(props, 'engine') : undefined, zoom: parseBooleanMeta(props, 'zoom'), showSource: parseBooleanMeta(props, 'showSource') }));
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* Decides which engine — if either — owns this fence.
|
|
@@ -19,6 +19,11 @@ export interface PlantUmlDiagramProps {
|
|
|
19
19
|
* Set from the fence metastring: `zoom` or `zoom=false`.
|
|
20
20
|
*/
|
|
21
21
|
zoom?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Overrides the `showSource` plugin option for this diagram. `undefined` follows the option.
|
|
24
|
+
* Set from the fence metastring: `showSource` or `showSource=false`.
|
|
25
|
+
*/
|
|
26
|
+
showSource?: boolean;
|
|
22
27
|
}
|
|
23
28
|
/**
|
|
24
29
|
* Renders one diagram in the browser, with either engine.
|
|
@@ -30,4 +35,4 @@ export interface PlantUmlDiagramProps {
|
|
|
30
35
|
* Nothing happens during server-side rendering: the markup emitted on the server is the
|
|
31
36
|
* same deferred placeholder the client renders first, so hydration never mismatches.
|
|
32
37
|
*/
|
|
33
|
-
export default function PlantUmlDiagram({ source, title, language, engine, layout, zoom: zoomProp, }: PlantUmlDiagramProps): ReactNode;
|
|
38
|
+
export default function PlantUmlDiagram({ source, title, language, engine, layout, zoom: zoomProp, showSource: showSourceProp, }: PlantUmlDiagramProps): ReactNode;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import { useCallback, 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';
|
|
@@ -19,6 +19,8 @@ const ENGINE_LABEL = {
|
|
|
19
19
|
};
|
|
20
20
|
/** Margin large enough that a diagram is usually ready by the time it is scrolled into view. */
|
|
21
21
|
const LAZY_ROOT_MARGIN = '300px';
|
|
22
|
+
/** How long the copy result stays on screen before the control goes quiet again. */
|
|
23
|
+
const COPY_FEEDBACK_MS = 2_000;
|
|
22
24
|
const INITIAL_STATE = { status: 'idle', svg: null, error: null };
|
|
23
25
|
function escapeHtml(value) {
|
|
24
26
|
return value
|
|
@@ -44,11 +46,12 @@ function messageFor(error) {
|
|
|
44
46
|
* Nothing happens during server-side rendering: the markup emitted on the server is the
|
|
45
47
|
* same deferred placeholder the client renders first, so hydration never mismatches.
|
|
46
48
|
*/
|
|
47
|
-
export default function PlantUmlDiagram({ source, title, language, engine = 'plantuml', layout, zoom: zoomProp, }) {
|
|
49
|
+
export default function PlantUmlDiagram({ source, title, language, engine = 'plantuml', layout, zoom: zoomProp, showSource: showSourceProp, }) {
|
|
48
50
|
const config = usePlantUmlConfig();
|
|
49
51
|
const { colorMode } = useColorMode();
|
|
50
52
|
const containerRef = useRef(null);
|
|
51
53
|
const hintId = useId();
|
|
54
|
+
const sourcePanelId = useId();
|
|
52
55
|
const themeOption = config?.options.theme ?? 'auto';
|
|
53
56
|
const dark = themeOption === 'auto' ? colorMode === 'dark' : themeOption === 'dark';
|
|
54
57
|
const lazy = config?.options.lazy ?? true;
|
|
@@ -62,8 +65,39 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
62
65
|
const renderDark = engine === 'graphviz' ? false : dark;
|
|
63
66
|
// A fence flag wins over the plugin option, which in turn wins over the built-in default.
|
|
64
67
|
const interactive = zoomProp ?? config?.options.zoom ?? true;
|
|
68
|
+
const sourceAvailable = showSourceProp ?? config?.options.showSource ?? true;
|
|
65
69
|
const [state, setState] = useState(INITIAL_STATE);
|
|
66
70
|
const [inView, setInView] = useState(!lazy);
|
|
71
|
+
const [sourceOpen, setSourceOpen] = useState(false);
|
|
72
|
+
const [copyState, setCopyState] = useState('idle');
|
|
73
|
+
const copyTimer = useRef(undefined);
|
|
74
|
+
// A pending "Copied" message must not fire into an unmounted component.
|
|
75
|
+
useEffect(() => () => clearTimeout(copyTimer.current), []);
|
|
76
|
+
/**
|
|
77
|
+
* Copies the source, and says whether it worked.
|
|
78
|
+
*
|
|
79
|
+
* `navigator.clipboard` is undefined outside a secure context, which a documentation site
|
|
80
|
+
* served over plain HTTP genuinely is. Reporting that plainly is better than a control that
|
|
81
|
+
* silently does nothing — the panel is open, so the reader can still select the text.
|
|
82
|
+
*/
|
|
83
|
+
const copySource = useCallback(() => {
|
|
84
|
+
const settle = (next) => {
|
|
85
|
+
setCopyState(next);
|
|
86
|
+
clearTimeout(copyTimer.current);
|
|
87
|
+
copyTimer.current = setTimeout(() => setCopyState('idle'), COPY_FEEDBACK_MS);
|
|
88
|
+
};
|
|
89
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
|
|
90
|
+
if (typeof clipboard?.writeText !== 'function') {
|
|
91
|
+
settle('failed');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
void clipboard.writeText(source).then(() => settle('copied'), () => settle('failed'));
|
|
95
|
+
}, [source]);
|
|
96
|
+
// A diagram whose source changed is a different diagram: an open panel would otherwise keep
|
|
97
|
+
// showing the old text's copy result, and the reader would have no idea it was stale.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
setCopyState('idle');
|
|
100
|
+
}, [source]);
|
|
67
101
|
// Called unconditionally, as hooks must be; it attaches nothing when not interactive.
|
|
68
102
|
const zoom = useZoomPan({
|
|
69
103
|
enabled: interactive && state.status === 'ready',
|
|
@@ -167,16 +201,29 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
167
201
|
const busy = state.status === 'loading' || state.status === 'rendering';
|
|
168
202
|
// The sanitized SVG always lives in this element, zoomable or not, so `role="img" > svg`
|
|
169
203
|
// stays a stable contract for tests and for author CSS.
|
|
170
|
-
const canvas = state.svg !== null && (_jsx("div", { className: styles.canvas, role: "img", "aria-label": label,
|
|
204
|
+
const canvas = state.svg !== null && (_jsx("div", { className: !interactive && sourceOpen ? `${styles.canvas} ${styles.hiddenView}` : styles.canvas, role: "img", "aria-label": label,
|
|
171
205
|
// Sanitized above unless `sanitizeSvg: false` was explicitly opted into.
|
|
172
206
|
dangerouslySetInnerHTML: { __html: state.svg } }));
|
|
207
|
+
const copyControl = sourceAvailable && sourceOpen && state.status === 'ready' && (_jsx("button", { type: "button", className: `${styles.toolbarButton} ${styles.toolbarTextButton}`, "aria-label": `Copy ${engineName} source to clipboard`, onClick: copySource, children: "Copy" }));
|
|
208
|
+
const sourceToggle = sourceAvailable && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": sourceOpen ? 'Hide diagram source' : 'Show diagram source', "aria-expanded": sourceOpen, "aria-controls": sourcePanelId, onClick: () => setSourceOpen((open) => !open), children: _jsx("span", { "aria-hidden": "true", children: '</>' }) }));
|
|
209
|
+
/**
|
|
210
|
+
* The source *replaces the diagram in its own frame* rather than appearing beneath it.
|
|
211
|
+
*
|
|
212
|
+
* Below the diagram it had two failure modes that the frame removes by construction: a
|
|
213
|
+
* diagram taller than the window pushed the panel off-screen, so the control looked broken;
|
|
214
|
+
* and while maximized the panel was painted behind the `position: fixed` overlay, so it was
|
|
215
|
+
* invisible however far you scrolled. Living in the same box as the picture, it is visible
|
|
216
|
+
* exactly when and where the picture was.
|
|
217
|
+
*/
|
|
218
|
+
const sourceView = sourceAvailable && sourceOpen && state.status === 'ready' && (_jsxs("div", { className: styles.sourceView, id: sourcePanelId, children: [_jsxs("div", { className: styles.sourceViewBar, children: [_jsxs("span", { className: styles.sourceViewTitle, children: [engineName, " source"] }), _jsxs("span", { className: styles.copyStatus, role: "status", children: [copyState === 'copied' ? 'Copied to clipboard' : null, copyState === 'failed' ? 'Could not copy — select the text instead' : null] })] }), _jsx("pre", { className: styles.sourceCode, children: source })] }));
|
|
173
219
|
return (_jsxs("figure", { ref: containerRef, className: styles.figure, "aria-busy": busy || undefined, [DATA_ATTR.diagram]: language ?? engine,
|
|
174
220
|
[DATA_ATTR.engine]: engine,
|
|
175
221
|
...(engine === 'graphviz' ? { [DATA_ATTR.layout]: resolvedLayout } : {}),
|
|
176
222
|
[DATA_ATTR.status]: state.status,
|
|
177
223
|
[DATA_ATTR.theme]: dark ? 'dark' : 'light',
|
|
178
224
|
...(interactive ? { [DATA_ATTR.interactive]: 'true' } : {}),
|
|
179
|
-
...(zoom.maximized ? { [DATA_ATTR.maximized]: 'true' } : {}),
|
|
225
|
+
...(zoom.maximized ? { [DATA_ATTR.maximized]: 'true' } : {}),
|
|
226
|
+
...(sourceOpen ? { [DATA_ATTR.sourceOpen]: '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.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 }) }), sourceView] }), _jsxs("div", { className: styles.toolbar, role: "group", "aria-label": `${label} zoom controls`, children: [!sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom out", onClick: zoom.zoomOut, children: _jsx("span", { "aria-hidden": "true", children: "\u2212" }) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom in", onClick: zoom.zoomIn, children: _jsx("span", { "aria-hidden": "true", children: "+" }) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Reset zoom", onClick: zoom.reset, children: _jsx("span", { "aria-hidden": "true", children: "\u27F2" }) })), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Maximize diagram", "aria-pressed": zoom.maximized, onClick: zoom.toggleMaximize, children: _jsx("span", { "aria-hidden": "true", children: zoom.maximized ? '✕' : '⛶' }) }), sourceToggle, copyControl, _jsx("span", { ref: zoom.readoutRef, className: sourceOpen ? `${styles.readout} ${styles.hiddenView}` : 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' && (
|
|
180
227
|
// `aria-busy` on the figure already conveys progress; announcing every phase change
|
|
181
228
|
// would make a page with several diagrams unusable with a screen reader.
|
|
182
229
|
_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: {
|
|
@@ -291,3 +291,120 @@
|
|
|
291
291
|
overflow-x: auto;
|
|
292
292
|
font-size: 0.8125rem;
|
|
293
293
|
}
|
|
294
|
+
|
|
295
|
+
/*
|
|
296
|
+
* Source view.
|
|
297
|
+
*
|
|
298
|
+
* The source takes the diagram's place inside the same frame rather than appearing beneath it.
|
|
299
|
+
* Below the diagram it had two failure modes this removes by construction: a diagram taller
|
|
300
|
+
* than the window pushed it off-screen, and while maximized it was painted behind the
|
|
301
|
+
* `position: fixed` overlay. In the frame it is visible exactly where the picture was.
|
|
302
|
+
*/
|
|
303
|
+
|
|
304
|
+
/* One grid cell shared by the viewport and the source view, so they occupy the same box. */
|
|
305
|
+
.stageBody {
|
|
306
|
+
position: relative;
|
|
307
|
+
display: grid;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.stageBody > * {
|
|
311
|
+
grid-area: 1 / 1;
|
|
312
|
+
/* Without this a wide <pre> would stretch the grid column instead of scrolling. */
|
|
313
|
+
min-width: 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/*
|
|
317
|
+
* `visibility`, not `display`: the hidden viewport keeps contributing its height, so the frame
|
|
318
|
+
* does not resize when flipping, and the zoom hook's layout measurements stay valid for the
|
|
319
|
+
* flip back. A `visibility: hidden` subtree is also not focusable and takes no pointer events,
|
|
320
|
+
* so the invisible diagram cannot be tabbed into or dragged.
|
|
321
|
+
*/
|
|
322
|
+
.invisibleView {
|
|
323
|
+
visibility: hidden;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* Collapses entirely — used where there is no frame height worth preserving. */
|
|
327
|
+
.hiddenView {
|
|
328
|
+
display: none;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/*
|
|
332
|
+
* Inside a zoom frame the source is taken out of flow so that it cannot size the grid row: the
|
|
333
|
+
* row stays exactly as tall as the (hidden but still laid-out) viewport, and a long source
|
|
334
|
+
* scrolls within it. Left in flow it grew the frame from the diagram's height to the source's —
|
|
335
|
+
* on a long diagram that pushed the text back off the bottom of the window, which is the very
|
|
336
|
+
* problem moving it into the frame was meant to solve.
|
|
337
|
+
*/
|
|
338
|
+
.stageBody .sourceView {
|
|
339
|
+
position: absolute;
|
|
340
|
+
inset: 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.sourceView {
|
|
344
|
+
display: flex;
|
|
345
|
+
flex-direction: column;
|
|
346
|
+
min-height: 0;
|
|
347
|
+
border: 1px solid var(--ifm-color-emphasis-300);
|
|
348
|
+
border-radius: var(--ifm-global-radius);
|
|
349
|
+
background: var(--ifm-background-surface-color);
|
|
350
|
+
overflow: hidden;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.sourceViewBar {
|
|
354
|
+
display: flex;
|
|
355
|
+
align-items: center;
|
|
356
|
+
flex-wrap: wrap;
|
|
357
|
+
gap: 0.5rem;
|
|
358
|
+
/* Clears the toolbar, which floats over the top-right of the same frame. */
|
|
359
|
+
padding: 0.5rem 12rem 0.25rem 0.75rem;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.sourceViewTitle {
|
|
363
|
+
color: var(--ifm-color-emphasis-700);
|
|
364
|
+
font-size: 0.8125rem;
|
|
365
|
+
font-weight: 600;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.copyStatus {
|
|
369
|
+
flex: 1;
|
|
370
|
+
color: var(--ifm-color-emphasis-700);
|
|
371
|
+
font-size: 0.75rem;
|
|
372
|
+
text-align: right;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/*
|
|
376
|
+
* The copy control is a toolbar button like the rest, just carrying a word instead of a glyph,
|
|
377
|
+
* so it needs width where the icon buttons are square.
|
|
378
|
+
*/
|
|
379
|
+
.toolbarTextButton {
|
|
380
|
+
width: auto;
|
|
381
|
+
padding: 0 0.5rem;
|
|
382
|
+
font-size: 0.8125rem;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.sourceCode {
|
|
386
|
+
flex: 1;
|
|
387
|
+
min-height: 0;
|
|
388
|
+
margin: 0;
|
|
389
|
+
padding: 0.75rem;
|
|
390
|
+
overflow: auto;
|
|
391
|
+
background: transparent;
|
|
392
|
+
font-size: 0.8125rem;
|
|
393
|
+
/* Wraps rather than scrolls sideways: diagram source is read, not just copied. */
|
|
394
|
+
white-space: pre-wrap;
|
|
395
|
+
word-break: break-word;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/*
|
|
399
|
+
* Without a zoom frame the source has no box to fill, so it needs a floor of its own — and a
|
|
400
|
+
* ceiling, so a long diagram cannot produce an endless wall of text.
|
|
401
|
+
*/
|
|
402
|
+
.figure:not([data-plantuml-interactive]) .sourceView {
|
|
403
|
+
max-height: 30rem;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.plainToolbar {
|
|
407
|
+
display: flex;
|
|
408
|
+
justify-content: flex-end;
|
|
409
|
+
margin-top: 0.25rem;
|
|
410
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matfsw/docusaurus-plantuml-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Render PlantUML diagrams in Docusaurus 3 entirely in the browser - no Java, no PlantUML server, no CDN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"docusaurus",
|
|
@@ -85,17 +85,17 @@
|
|
|
85
85
|
"@docusaurus/theme-classic": "^3.9.2",
|
|
86
86
|
"@docusaurus/tsconfig": "^3.9.2",
|
|
87
87
|
"@docusaurus/types": "^3.9.2",
|
|
88
|
-
"@eslint/js": "^9.39.
|
|
88
|
+
"@eslint/js": "^9.39.5",
|
|
89
89
|
"@playwright/test": "^1.57.0",
|
|
90
90
|
"@testing-library/dom": "^10.4.1",
|
|
91
|
-
"@testing-library/jest-dom": "^
|
|
91
|
+
"@testing-library/jest-dom": "^7.0.0",
|
|
92
92
|
"@testing-library/react": "^16.3.0",
|
|
93
93
|
"@testing-library/user-event": "^14.6.1",
|
|
94
94
|
"@types/node": "^26.1.2",
|
|
95
95
|
"@types/react": "^19.2.18",
|
|
96
96
|
"@types/react-dom": "^19.2.4",
|
|
97
|
-
"@vitest/coverage-v8": "^
|
|
98
|
-
"eslint": "^9.39.
|
|
97
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
98
|
+
"eslint": "^9.39.5",
|
|
99
99
|
"eslint-config-prettier": "^10.1.8",
|
|
100
100
|
"eslint-plugin-react": "^7.37.5",
|
|
101
101
|
"eslint-plugin-react-hooks": "^5.2.0",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"tsup": "^8.5.0",
|
|
108
108
|
"typescript": "~5.9.3",
|
|
109
109
|
"typescript-eslint": "^8.46.4",
|
|
110
|
-
"vitest": "^
|
|
110
|
+
"vitest": "^4.1.10"
|
|
111
111
|
},
|
|
112
112
|
"overrides": {
|
|
113
113
|
"serialize-javascript": "^7.0.7",
|