@matfsw/docusaurus-plantuml-plugin 1.1.2 → 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 CHANGED
@@ -7,6 +7,43 @@ 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
+
10
47
  ## [1.1.2]
11
48
 
12
49
  ### Fixed
@@ -247,7 +284,8 @@ Initial release.
247
284
  opt-out.
248
285
  - Plugin option validation that rejects unknown keys and out-of-range values at build time.
249
286
 
250
- [Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.1.2...HEAD
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
251
289
  [1.1.2]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.1.1...v1.1.2
252
290
  [1.1.1]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.1.0...v1.1.1
253
291
  [1.1.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.0.3...v1.1.0
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
- | `graphviz` | `object` | see below | Graphviz/DOT support. See [Graphviz options](#graphviz-options). |
246
- | `id` | `string` | `'default'` | Docusaurus plugin instance id; only relevant if you register the plugin more than once. |
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
@@ -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' } : {}), children: [state.status === 'ready' && state.svg !== null && !interactive && canvas, state.status === 'ready' && state.svg !== null && interactive && (_jsxs("div", { ref: zoom.stageRef, className: zoom.maximized ? `${styles.stage} ${styles.maximized}` : styles.stage, children: [_jsx("div", { ref: zoom.viewportRef, className: styles.viewport, tabIndex: 0, "aria-describedby": hintId, "aria-keyshortcuts": "Plus Minus 0 ArrowUp ArrowDown ArrowLeft ArrowRight", onKeyDown: zoom.onKeyDown, [DATA_ATTR.zoom]: '1', children: _jsx("div", { ref: zoom.layerRef, className: styles.layer, children: canvas }) }), _jsxs("div", { className: styles.toolbar, role: "group", "aria-label": `${label} zoom controls`, children: [_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom out", onClick: zoom.zoomOut, children: _jsx("span", { "aria-hidden": "true", children: "\u2212" }) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom in", onClick: zoom.zoomIn, children: _jsx("span", { "aria-hidden": "true", children: "+" }) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Reset zoom", onClick: zoom.reset, children: _jsx("span", { "aria-hidden": "true", children: "\u27F2" }) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Maximize diagram", "aria-pressed": zoom.maximized, onClick: zoom.toggleMaximize, children: _jsx("span", { "aria-hidden": "true", children: zoom.maximized ? '✕' : '⛶' }) }), _jsx("span", { ref: zoom.readoutRef, className: styles.readout, "aria-hidden": "true", children: "100%" })] })] })), state.status === 'ready' && state.svg !== null && interactive && (_jsx("p", { id: hintId, className: styles.visuallyHidden, children: "Zoomable diagram. Use the zoom controls, or press plus and minus to zoom, the arrow keys to pan, and zero to reset." })), state.status !== 'ready' && state.status !== 'error' && (
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.1.2",
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",