@matfsw/docusaurus-plantuml-plugin 1.3.1 → 1.4.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 +71 -1
- package/README.md +106 -13
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +10 -0
- package/dist/runtime/sanitize.js +5 -1
- package/dist/theme/PlantUmlDiagram/Minimap.d.ts +9 -0
- package/dist/theme/PlantUmlDiagram/Minimap.js +137 -0
- package/dist/theme/PlantUmlDiagram/deeplink.d.ts +55 -0
- package/dist/theme/PlantUmlDiagram/deeplink.js +149 -0
- package/dist/theme/PlantUmlDiagram/icons.d.ts +13 -0
- package/dist/theme/PlantUmlDiagram/icons.js +23 -0
- package/dist/theme/PlantUmlDiagram/index.js +71 -6
- package/dist/theme/PlantUmlDiagram/styles.module.css +180 -2
- package/dist/theme/PlantUmlDiagram/useDiagramDeeplink.d.ts +32 -0
- package/dist/theme/PlantUmlDiagram/useDiagramDeeplink.js +54 -0
- package/dist/theme/PlantUmlDiagram/useDiagramSearch.d.ts +37 -0
- package/dist/theme/PlantUmlDiagram/useDiagramSearch.js +107 -0
- package/dist/theme/PlantUmlDiagram/useZoomPan.d.ts +17 -1
- package/dist/theme/PlantUmlDiagram/useZoomPan.js +66 -6
- package/dist/theme/PlantUmlDiagram/zoomMath.d.ts +28 -0
- package/dist/theme/PlantUmlDiagram/zoomMath.js +42 -0
- package/package.json +1 -1
|
@@ -24,10 +24,23 @@ export declare function ZoomOutIcon(): ReactElement;
|
|
|
24
24
|
export declare function ZoomInIcon(): ReactElement;
|
|
25
25
|
/** Replaces `⟲` U+27F2: a circular arrow, open at the top right where the arrowhead sits. */
|
|
26
26
|
export declare function ResetZoomIcon(): ReactElement;
|
|
27
|
+
/**
|
|
28
|
+
* Fit to view: the maximize corners with the diagram drawn inside them, so the two controls
|
|
29
|
+
* read as siblings — both are about the frame, one keeps you in the page.
|
|
30
|
+
*/
|
|
31
|
+
export declare function FitIcon(): ReactElement;
|
|
27
32
|
/** Replaces `⛶` U+26F6, the character that had no glyph. */
|
|
28
33
|
export declare function MaximizeIcon(): ReactElement;
|
|
29
34
|
/** Replaces `✕` U+2715, shown in place of maximize while the diagram fills the viewport. */
|
|
30
35
|
export declare function CloseIcon(): ReactElement;
|
|
36
|
+
/** The search toggle: a lens. */
|
|
37
|
+
export declare function SearchIcon(): ReactElement;
|
|
38
|
+
/** Previous search match. */
|
|
39
|
+
export declare function ChevronUpIcon(): ReactElement;
|
|
40
|
+
/** Next search match. */
|
|
41
|
+
export declare function ChevronDownIcon(): ReactElement;
|
|
42
|
+
/** The minimap toggle: a map frame with the viewport rectangle marked inside it. */
|
|
43
|
+
export declare function MinimapIcon(): ReactElement;
|
|
31
44
|
/**
|
|
32
45
|
* Replaces the literal `</>` of the source toggle.
|
|
33
46
|
*
|
|
@@ -24,6 +24,13 @@ export function ZoomInIcon() {
|
|
|
24
24
|
export function ResetZoomIcon() {
|
|
25
25
|
return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M13 8A5 5 0 1 1 11.4 4.3" }), _jsx("path", { d: "M13 2v3h-3" })] }));
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Fit to view: the maximize corners with the diagram drawn inside them, so the two controls
|
|
29
|
+
* read as siblings — both are about the frame, one keeps you in the page.
|
|
30
|
+
*/
|
|
31
|
+
export function FitIcon() {
|
|
32
|
+
return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" }), _jsx("rect", { x: "5.5", y: "5.5", width: "5", height: "5", rx: "1" })] }));
|
|
33
|
+
}
|
|
27
34
|
/** Replaces `⛶` U+26F6, the character that had no glyph. */
|
|
28
35
|
export function MaximizeIcon() {
|
|
29
36
|
return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" }) }));
|
|
@@ -32,6 +39,22 @@ export function MaximizeIcon() {
|
|
|
32
39
|
export function CloseIcon() {
|
|
33
40
|
return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) }));
|
|
34
41
|
}
|
|
42
|
+
/** The search toggle: a lens. */
|
|
43
|
+
export function SearchIcon() {
|
|
44
|
+
return (_jsxs("svg", { ...iconProps, children: [_jsx("circle", { cx: "7", cy: "7", r: "4" }), _jsx("path", { d: "M10 10l4 4" })] }));
|
|
45
|
+
}
|
|
46
|
+
/** Previous search match. */
|
|
47
|
+
export function ChevronUpIcon() {
|
|
48
|
+
return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M4 10l4-4 4 4" }) }));
|
|
49
|
+
}
|
|
50
|
+
/** Next search match. */
|
|
51
|
+
export function ChevronDownIcon() {
|
|
52
|
+
return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M4 6l4 4 4-4" }) }));
|
|
53
|
+
}
|
|
54
|
+
/** The minimap toggle: a map frame with the viewport rectangle marked inside it. */
|
|
55
|
+
export function MinimapIcon() {
|
|
56
|
+
return (_jsxs("svg", { ...iconProps, children: [_jsx("rect", { x: "2", y: "3", width: "12", height: "10", rx: "1" }), _jsx("rect", { x: "4.5", y: "7.5", width: "4", height: "3", fill: "currentColor", stroke: "none" })] }));
|
|
57
|
+
}
|
|
35
58
|
/**
|
|
36
59
|
* Replaces the literal `</>` of the source toggle.
|
|
37
60
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
import { useLocation } from '@docusaurus/router';
|
|
3
4
|
import { useColorMode } from '@docusaurus/theme-common';
|
|
4
5
|
import { DATA_ATTR } from '../../constants.js';
|
|
5
6
|
import { getSharedCache } from '../../runtime/cache.js';
|
|
@@ -7,7 +8,11 @@ import { PlantUmlError } from '../../runtime/errors.js';
|
|
|
7
8
|
import { renderGraphvizDiagram } from '../../runtime/graphvizRenderer.js';
|
|
8
9
|
import { renderDiagram } from '../../runtime/renderer.js';
|
|
9
10
|
import { usePlantUmlConfig } from '../usePlantUmlConfig.js';
|
|
10
|
-
import { CloseIcon, MaximizeIcon, ResetZoomIcon, SourceIcon, ZoomInIcon, ZoomOutIcon, } from './icons.js';
|
|
11
|
+
import { ChevronDownIcon, ChevronUpIcon, CloseIcon, FitIcon, MaximizeIcon, MinimapIcon, ResetZoomIcon, SearchIcon, SourceIcon, ZoomInIcon, ZoomOutIcon, } from './icons.js';
|
|
12
|
+
import { parseDiagramHash } from './deeplink.js';
|
|
13
|
+
import Minimap from './Minimap.js';
|
|
14
|
+
import { useDiagramDeeplink } from './useDiagramDeeplink.js';
|
|
15
|
+
import { useDiagramSearch } from './useDiagramSearch.js';
|
|
11
16
|
import { useZoomPan } from './useZoomPan.js';
|
|
12
17
|
import styles from './styles.module.css';
|
|
13
18
|
/**
|
|
@@ -50,6 +55,9 @@ function messageFor(error) {
|
|
|
50
55
|
export default function PlantUmlDiagram({ source, title, language, engine = 'plantuml', layout, zoom: zoomProp, showSource: showSourceProp, }) {
|
|
51
56
|
const config = usePlantUmlConfig();
|
|
52
57
|
const { colorMode } = useColorMode();
|
|
58
|
+
// The router, not `window.location`: `<Link>` navigations are `history.pushState` calls
|
|
59
|
+
// that fire no DOM event, so only the router sees every way the hash can change.
|
|
60
|
+
const routerLocation = useLocation();
|
|
53
61
|
const containerRef = useRef(null);
|
|
54
62
|
const hintId = useId();
|
|
55
63
|
const sourcePanelId = useId();
|
|
@@ -68,8 +76,12 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
68
76
|
const interactive = zoomProp ?? config?.options.zoom ?? true;
|
|
69
77
|
const sourceAvailable = showSourceProp ?? config?.options.showSource ?? true;
|
|
70
78
|
const [state, setState] = useState(INITIAL_STATE);
|
|
71
|
-
|
|
79
|
+
// A diagram deep link must be able to reach a diagram below the fold, so a `#graph?…`
|
|
80
|
+
// hash defeats lazy rendering. The router's server-side location has no hash, and the
|
|
81
|
+
// first paint is the placeholder either way, so hydration cannot mismatch.
|
|
82
|
+
const [inView, setInView] = useState(() => !lazy || parseDiagramHash(routerLocation.hash) !== null);
|
|
72
83
|
const [sourceOpen, setSourceOpen] = useState(false);
|
|
84
|
+
const [minimapOpen, setMinimapOpen] = useState(false);
|
|
73
85
|
const [copyState, setCopyState] = useState('idle');
|
|
74
86
|
const copyTimer = useRef(undefined);
|
|
75
87
|
// A pending "Copied" message must not fire into an unmounted component.
|
|
@@ -107,6 +119,48 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
107
119
|
// gratuitous jump.
|
|
108
120
|
resetKey: `${source}|${engine}|${resolvedLayout}|${renderDark ? 'dark' : 'light'}`,
|
|
109
121
|
});
|
|
122
|
+
const search = useDiagramSearch({
|
|
123
|
+
enabled: interactive && state.status === 'ready',
|
|
124
|
+
zoom,
|
|
125
|
+
svg: state.svg,
|
|
126
|
+
});
|
|
127
|
+
useDiagramDeeplink({
|
|
128
|
+
ready: state.status === 'ready' && state.svg !== null,
|
|
129
|
+
svg: state.svg,
|
|
130
|
+
interactive,
|
|
131
|
+
zoom,
|
|
132
|
+
containerRef,
|
|
133
|
+
});
|
|
134
|
+
// A hash arriving *after* mount must defeat lazy rendering too — a deep link followed
|
|
135
|
+
// from another diagram on the same page may point below the fold. Driven by the router
|
|
136
|
+
// location rather than `hashchange`, which `<Link>` navigations never fire.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!inView && parseDiagramHash(routerLocation.hash) !== null)
|
|
139
|
+
setInView(true);
|
|
140
|
+
}, [inView, routerLocation.hash]);
|
|
141
|
+
const searchToggleRef = useRef(null);
|
|
142
|
+
// Escape and the ✕ hand focus back to the toggle, so the keyboard is not left stranded on
|
|
143
|
+
// a control that just left the page.
|
|
144
|
+
const closeSearch = useCallback(() => {
|
|
145
|
+
search.close();
|
|
146
|
+
searchToggleRef.current?.focus();
|
|
147
|
+
}, [search]);
|
|
148
|
+
const onSearchKeyDown = useCallback((event) => {
|
|
149
|
+
if (event.key === 'Enter') {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
if (event.shiftKey)
|
|
152
|
+
search.previous();
|
|
153
|
+
else
|
|
154
|
+
search.next();
|
|
155
|
+
}
|
|
156
|
+
else if (event.key === 'Escape') {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
// Escape in the search closes the search. Without this, the document-level listener
|
|
159
|
+
// would also un-maximize the diagram in the same keystroke.
|
|
160
|
+
event.stopPropagation();
|
|
161
|
+
closeSearch();
|
|
162
|
+
}
|
|
163
|
+
}, [closeSearch, search]);
|
|
110
164
|
useEffect(() => {
|
|
111
165
|
if (!lazy || inView)
|
|
112
166
|
return undefined;
|
|
@@ -201,11 +255,18 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
201
255
|
]);
|
|
202
256
|
const label = title ?? `${engineName} diagram`;
|
|
203
257
|
const busy = state.status === 'loading' || state.status === 'rendering';
|
|
258
|
+
/**
|
|
259
|
+
* One wrapper object per SVG string, not a fresh literal in the JSX: React 19 compares
|
|
260
|
+
* `dangerouslySetInnerHTML` by the wrapper's identity, not by `__html`, so an inline
|
|
261
|
+
* literal makes every re-render of the figure re-parse the whole SVG subtree — visibly
|
|
262
|
+
* so once anything marks the rendered elements, as the search's highlights do.
|
|
263
|
+
*/
|
|
264
|
+
const svgHtml = useMemo(() => (state.svg === null ? null : { __html: state.svg }), [state.svg]);
|
|
204
265
|
// The sanitized SVG always lives in this element, zoomable or not, so `role="img" > svg`
|
|
205
266
|
// stays a stable contract for tests and for author CSS.
|
|
206
|
-
const canvas = state.svg !== null && (_jsx("div", { className: !interactive && sourceOpen ? `${styles.canvas} ${styles.hiddenView}` : styles.canvas, role: "img", "aria-label": label,
|
|
267
|
+
const canvas = state.svg !== null && svgHtml !== null && (_jsx("div", { className: !interactive && sourceOpen ? `${styles.canvas} ${styles.hiddenView}` : styles.canvas, role: "img", "aria-label": label,
|
|
207
268
|
// Sanitized above unless `sanitizeSvg: false` was explicitly opted into.
|
|
208
|
-
dangerouslySetInnerHTML:
|
|
269
|
+
dangerouslySetInnerHTML: svgHtml }));
|
|
209
270
|
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" }));
|
|
210
271
|
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(SourceIcon, {}) }));
|
|
211
272
|
/**
|
|
@@ -225,7 +286,11 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
|
|
|
225
286
|
[DATA_ATTR.theme]: dark ? 'dark' : 'light',
|
|
226
287
|
...(interactive ? { [DATA_ATTR.interactive]: 'true' } : {}),
|
|
227
288
|
...(zoom.maximized ? { [DATA_ATTR.maximized]: 'true' } : {}),
|
|
228
|
-
...(sourceOpen ? { [DATA_ATTR.sourceOpen]: 'true' } : {}),
|
|
289
|
+
...(sourceOpen ? { [DATA_ATTR.sourceOpen]: 'true' } : {}),
|
|
290
|
+
...(minimapOpen ? { [DATA_ATTR.minimapOpen]: 'true' } : {}),
|
|
291
|
+
...(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.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.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
|
|
292
|
+
? '0/0'
|
|
293
|
+
: `${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%" })] })] }), !sourceOpen && minimapOpen && (_jsx(Minimap, { svg: state.svg, zoom: zoom, onClose: () => setMinimapOpen(false) })), !sourceOpen && (_jsx("div", { className: styles.minimapBar, children: _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": minimapOpen ? 'Hide minimap' : 'Show minimap', "aria-expanded": minimapOpen, onClick: () => setMinimapOpen((open) => !open), children: _jsx(MinimapIcon, {}) }) }))] })), 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' && (
|
|
229
294
|
// `aria-busy` on the figure already conveys progress; announcing every phase change
|
|
230
295
|
// would make a page with several diagrams unusable with a screen reader.
|
|
231
296
|
_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: {
|
|
@@ -82,6 +82,20 @@
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
.layer {
|
|
85
|
+
/*
|
|
86
|
+
* Shrinks to the rendered picture instead of stretching across the viewport. The zoom
|
|
87
|
+
* hook measures content through `offsetWidth`/`offsetHeight` — layout sizes, immune to
|
|
88
|
+
* both the transform and its transition — so this is what makes those measurements mean
|
|
89
|
+
* "the diagram" rather than "the full-width frame". Fitting (which fills the screen when
|
|
90
|
+
* maximized) and the minimap both read that measurement.
|
|
91
|
+
*
|
|
92
|
+
* `max-width` is load-bearing, not decoration: in intrinsic (`fit-content`) sizing the
|
|
93
|
+
* SVG's own `max-width: 100%` resolves against an indefinite width and is ignored, so
|
|
94
|
+
* without this cap a diagram wider than the column would blow the layer out to the SVG's
|
|
95
|
+
* intrinsic width and overflow the frame.
|
|
96
|
+
*/
|
|
97
|
+
width: fit-content;
|
|
98
|
+
max-width: 100%;
|
|
85
99
|
transform-origin: 0 0;
|
|
86
100
|
transition: transform var(--plantuml-zoom-transition, 150ms) ease-out;
|
|
87
101
|
}
|
|
@@ -91,10 +105,31 @@
|
|
|
91
105
|
transition: none;
|
|
92
106
|
}
|
|
93
107
|
|
|
94
|
-
|
|
108
|
+
/*
|
|
109
|
+
* One flex row holds the search bar and the toolbar, so opening the search never needs to
|
|
110
|
+
* know how wide the toolbar happens to be — it simply takes the space to its left.
|
|
111
|
+
*/
|
|
112
|
+
.controls {
|
|
95
113
|
position: absolute;
|
|
96
114
|
top: 0.5rem;
|
|
97
115
|
right: 0.5rem;
|
|
116
|
+
display: flex;
|
|
117
|
+
gap: 0.5rem;
|
|
118
|
+
align-items: flex-start;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.toolbar {
|
|
122
|
+
display: flex;
|
|
123
|
+
gap: 0.25rem;
|
|
124
|
+
align-items: center;
|
|
125
|
+
padding: 0.25rem;
|
|
126
|
+
border: 1px solid var(--ifm-color-emphasis-300);
|
|
127
|
+
border-radius: var(--ifm-global-radius);
|
|
128
|
+
background: var(--ifm-background-surface-color);
|
|
129
|
+
opacity: 0.92;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.searchBar {
|
|
98
133
|
display: flex;
|
|
99
134
|
gap: 0.25rem;
|
|
100
135
|
align-items: center;
|
|
@@ -105,6 +140,66 @@
|
|
|
105
140
|
opacity: 0.92;
|
|
106
141
|
}
|
|
107
142
|
|
|
143
|
+
.searchInput {
|
|
144
|
+
width: 10rem;
|
|
145
|
+
max-width: 40vw;
|
|
146
|
+
height: 1.75rem;
|
|
147
|
+
padding: 0 0.5rem;
|
|
148
|
+
border: 1px solid var(--ifm-color-emphasis-300);
|
|
149
|
+
border-radius: var(--ifm-global-radius);
|
|
150
|
+
/* Not `--ifm-background-color`: Infima defines it as `#0000` in light mode. */
|
|
151
|
+
background: var(--ifm-background-surface-color);
|
|
152
|
+
color: var(--ifm-font-color-base);
|
|
153
|
+
font-size: 0.8125rem;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.searchInput:focus-visible {
|
|
157
|
+
outline: 2px solid var(--ifm-color-primary);
|
|
158
|
+
outline-offset: 1px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.searchCount {
|
|
162
|
+
min-width: 2.25rem;
|
|
163
|
+
color: var(--ifm-color-emphasis-700);
|
|
164
|
+
font-size: 0.75rem;
|
|
165
|
+
font-variant-numeric: tabular-nums;
|
|
166
|
+
text-align: center;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/*
|
|
170
|
+
* Search highlights, keyed on the attributes the search hook sets. Attribute + CSS rather
|
|
171
|
+
* than mutating the SVG string: the string is shared through the render cache, and a
|
|
172
|
+
* highlight must never leak into another figure showing the same diagram.
|
|
173
|
+
*
|
|
174
|
+
* These rules sit after the Graphviz `currentColor` retargeting above and tie on
|
|
175
|
+
* specificity, so source order is what makes the highlight win there too.
|
|
176
|
+
*/
|
|
177
|
+
.layer .canvas svg text[data-plantuml-search-match] {
|
|
178
|
+
fill: var(--ifm-color-primary);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.layer .canvas svg text[data-plantuml-search-current] {
|
|
182
|
+
fill: var(--ifm-color-danger-dark);
|
|
183
|
+
text-decoration: underline;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/*
|
|
187
|
+
* The node a `#graph?highlight-node=…` deep link resolved to.
|
|
188
|
+
*
|
|
189
|
+
* The attribute may sit on a text line itself, or on the group/anchor wrapping a whole node
|
|
190
|
+
* (Graphviz `g.node`, a PlantUML self-anchor), so both spellings are styled. Scoped to
|
|
191
|
+
* `.figure` rather than `.layer`: a diagram that opted out of zoom still highlights, it
|
|
192
|
+
* just cannot pan.
|
|
193
|
+
*
|
|
194
|
+
* Placed after the Graphviz `currentColor` retargeting, whose specificity these tie —
|
|
195
|
+
* source order is what makes the highlight win there.
|
|
196
|
+
*/
|
|
197
|
+
.figure .canvas svg text[data-plantuml-focused-node],
|
|
198
|
+
.figure .canvas svg [data-plantuml-focused-node] text {
|
|
199
|
+
fill: var(--ifm-color-danger-dark);
|
|
200
|
+
text-decoration: underline;
|
|
201
|
+
}
|
|
202
|
+
|
|
108
203
|
.toolbarButton {
|
|
109
204
|
display: flex;
|
|
110
205
|
align-items: center;
|
|
@@ -169,6 +264,80 @@
|
|
|
169
264
|
max-height: none;
|
|
170
265
|
}
|
|
171
266
|
|
|
267
|
+
/*
|
|
268
|
+
* Minimap.
|
|
269
|
+
*
|
|
270
|
+
* A small copy of the diagram, bottom-left where the toolbar is top-right, with a rectangle
|
|
271
|
+
* marking the visible part. The copy is laid out at the content's own width and scaled down
|
|
272
|
+
* as one block, so it keeps exactly the geometry of the real canvas and the rectangle's
|
|
273
|
+
* coordinates can be multiplied straight through.
|
|
274
|
+
*/
|
|
275
|
+
.minimapBar {
|
|
276
|
+
position: absolute;
|
|
277
|
+
bottom: 0.5rem;
|
|
278
|
+
left: 0.5rem;
|
|
279
|
+
display: flex;
|
|
280
|
+
padding: 0.25rem;
|
|
281
|
+
border: 1px solid var(--ifm-color-emphasis-300);
|
|
282
|
+
border-radius: var(--ifm-global-radius);
|
|
283
|
+
background: var(--ifm-background-surface-color);
|
|
284
|
+
opacity: 0.92;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.minimap {
|
|
288
|
+
position: absolute;
|
|
289
|
+
bottom: 3.25rem;
|
|
290
|
+
left: 0.5rem;
|
|
291
|
+
/*
|
|
292
|
+
* The close control sits *beside* the map, not floating over it: a wide, short diagram
|
|
293
|
+
* makes a map only a couple of buttons tall, and an overlaid control would cover — and
|
|
294
|
+
* steal presses from — the map's whole right end.
|
|
295
|
+
*/
|
|
296
|
+
display: flex;
|
|
297
|
+
gap: 0.25rem;
|
|
298
|
+
align-items: flex-start;
|
|
299
|
+
max-width: calc(100% - 1rem);
|
|
300
|
+
padding: 0.25rem;
|
|
301
|
+
border: 1px solid var(--ifm-color-emphasis-300);
|
|
302
|
+
border-radius: var(--ifm-global-radius);
|
|
303
|
+
background: var(--ifm-background-surface-color);
|
|
304
|
+
opacity: 0.95;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.minimapCanvas {
|
|
308
|
+
position: relative;
|
|
309
|
+
max-width: 100%;
|
|
310
|
+
overflow: hidden;
|
|
311
|
+
cursor: crosshair;
|
|
312
|
+
/* The map pans the diagram; a drag on it must never scroll the page instead. */
|
|
313
|
+
touch-action: none;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.minimapLayer {
|
|
317
|
+
transform-origin: 0 0;
|
|
318
|
+
/* The map is one press-or-drag surface; its copy of the diagram must not eat the events. */
|
|
319
|
+
pointer-events: none;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.minimapLayer svg {
|
|
323
|
+
display: block;
|
|
324
|
+
max-width: 100%;
|
|
325
|
+
height: auto;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.minimapRect {
|
|
329
|
+
position: absolute;
|
|
330
|
+
border: 2px solid var(--ifm-color-primary);
|
|
331
|
+
background: color-mix(in srgb, var(--ifm-color-primary) 20%, transparent);
|
|
332
|
+
pointer-events: none;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.minimapClose {
|
|
336
|
+
flex: none;
|
|
337
|
+
width: 1.5rem;
|
|
338
|
+
height: 1.5rem;
|
|
339
|
+
}
|
|
340
|
+
|
|
172
341
|
.visuallyHidden {
|
|
173
342
|
position: absolute;
|
|
174
343
|
width: 1px;
|
|
@@ -182,7 +351,10 @@
|
|
|
182
351
|
}
|
|
183
352
|
|
|
184
353
|
@media (forced-colors: active) {
|
|
185
|
-
.toolbar
|
|
354
|
+
.toolbar,
|
|
355
|
+
.searchBar,
|
|
356
|
+
.minimapBar,
|
|
357
|
+
.minimap {
|
|
186
358
|
border-color: ButtonBorder;
|
|
187
359
|
}
|
|
188
360
|
|
|
@@ -190,6 +362,12 @@
|
|
|
190
362
|
border-color: ButtonBorder;
|
|
191
363
|
color: ButtonText;
|
|
192
364
|
}
|
|
365
|
+
|
|
366
|
+
/* `color-mix` resolves to nothing useful here; the outline alone must carry the meaning. */
|
|
367
|
+
.minimapRect {
|
|
368
|
+
border-color: Highlight;
|
|
369
|
+
background: transparent;
|
|
370
|
+
}
|
|
193
371
|
}
|
|
194
372
|
|
|
195
373
|
.placeholder {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type MutableRefObject } from 'react';
|
|
2
|
+
import type { ZoomPanHandle } from './useZoomPan.js';
|
|
3
|
+
/**
|
|
4
|
+
* Makes one diagram react to `#graph?highlight-node=…` deep links.
|
|
5
|
+
*
|
|
6
|
+
* On a matching hash the target node is marked (`data-plantuml-focused-node`, styled from
|
|
7
|
+
* the stylesheet), the page scrolls to the first matching figure, and — when the diagram is
|
|
8
|
+
* zoomable — the view snaps to 100% centred on the node. A diagram without the node does
|
|
9
|
+
* nothing, which is what lets every diagram on the page react without any of them carrying
|
|
10
|
+
* an id.
|
|
11
|
+
*
|
|
12
|
+
* The hash comes from the router, not from a `hashchange` listener: Docusaurus `<Link>`
|
|
13
|
+
* navigations are `history.pushState` calls, which fire no `hashchange` — so a listener
|
|
14
|
+
* kept a stale hash whenever a link changed or dropped it while the page (and this
|
|
15
|
+
* component) stayed mounted, leaving the old node highlighted. `useLocation` re-renders on
|
|
16
|
+
* push, replace and pop alike, and native `#…` anchor clicks reach it too, via the
|
|
17
|
+
* `popstate` the browser fires for hash navigations.
|
|
18
|
+
*
|
|
19
|
+
* Highlighting is imperative for the same reason the search's is: the marked elements live
|
|
20
|
+
* in a `dangerouslySetInnerHTML` subtree React cannot render into.
|
|
21
|
+
*/
|
|
22
|
+
export interface UseDiagramDeeplinkParams {
|
|
23
|
+
/** Whether the diagram is rendered and its SVG is in the DOM. */
|
|
24
|
+
ready: boolean;
|
|
25
|
+
/** The rendered SVG string — a dependency, so a re-rendered picture is re-matched. */
|
|
26
|
+
svg: string | null;
|
|
27
|
+
/** Whether the diagram is zoomable; centring and the 100% snap need the viewport. */
|
|
28
|
+
interactive: boolean;
|
|
29
|
+
zoom: ZoomPanHandle;
|
|
30
|
+
containerRef: MutableRefObject<HTMLElement | null>;
|
|
31
|
+
}
|
|
32
|
+
export declare function useDiagramDeeplink({ ready, svg, interactive, zoom, containerRef, }: UseDiagramDeeplinkParams): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useLocation } from '@docusaurus/router';
|
|
3
|
+
import { DATA_ATTR } from '../../constants.js';
|
|
4
|
+
import { claimDeeplinkScroll, findDeeplinkTarget, parseDiagramHash } from './deeplink.js';
|
|
5
|
+
import { centerViewportOn } from './zoomMath.js';
|
|
6
|
+
export function useDiagramDeeplink({ ready, svg, interactive, zoom, containerRef, }) {
|
|
7
|
+
const location = useLocation();
|
|
8
|
+
const target = parseDiagramHash(location.hash);
|
|
9
|
+
// One value per history entry. Undefined on entries the router did not create itself
|
|
10
|
+
// (native hash clicks, the initial load); those still differ in `target`, which the
|
|
11
|
+
// scroll claim includes.
|
|
12
|
+
const navigationKey = location.key ?? 'pop';
|
|
13
|
+
const pathname = location.pathname;
|
|
14
|
+
/**
|
|
15
|
+
* Marks, scrolls and centres. The cleanup unmarks, so a changed hash, a navigation that
|
|
16
|
+
* drops the hash and an unmounting diagram all sweep up through the same code path; a
|
|
17
|
+
* replaced SVG re-runs the effect (via `svg`) against the new elements.
|
|
18
|
+
*
|
|
19
|
+
* `navigationKey` is a dependency on purpose: following the same deep link again is a new
|
|
20
|
+
* history entry with an unchanged `target`, and it should scroll and re-centre again.
|
|
21
|
+
*/
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!ready || target === null)
|
|
24
|
+
return undefined;
|
|
25
|
+
const figure = containerRef.current;
|
|
26
|
+
const canvas = figure?.querySelector('[role="img"]');
|
|
27
|
+
if (!figure || !canvas)
|
|
28
|
+
return undefined;
|
|
29
|
+
const match = findDeeplinkTarget(canvas, target);
|
|
30
|
+
if (!match)
|
|
31
|
+
return undefined;
|
|
32
|
+
for (const element of match.elements) {
|
|
33
|
+
element.setAttribute(DATA_ATTR.focusedNode, 'true');
|
|
34
|
+
}
|
|
35
|
+
claimDeeplinkScroll(`${navigationKey}|${pathname}|${target}`, figure);
|
|
36
|
+
if (interactive) {
|
|
37
|
+
const layer = zoom.layerRef.current;
|
|
38
|
+
if (layer) {
|
|
39
|
+
// The node's offset inside the layer, divided back by the current scale, is its
|
|
40
|
+
// position in content coordinates whatever the zoom was; the view then snaps to
|
|
41
|
+
// 100% centred there — the zoom level a deep link promises.
|
|
42
|
+
const transform = zoom.getTransform();
|
|
43
|
+
const layerBox = layer.getBoundingClientRect();
|
|
44
|
+
const box = match.anchor.getBoundingClientRect();
|
|
45
|
+
zoom.applyTransform(centerViewportOn({ k: 1, x: 0, y: 0 }, (box.left + box.width / 2 - layerBox.left) / transform.k, (box.top + box.height / 2 - layerBox.top) / transform.k, zoom.measure()));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return () => {
|
|
49
|
+
for (const element of match.elements) {
|
|
50
|
+
element.removeAttribute(DATA_ATTR.focusedNode);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}, [containerRef, interactive, navigationKey, pathname, ready, svg, target, zoom]);
|
|
54
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type MutableRefObject } from 'react';
|
|
2
|
+
import type { ZoomPanHandle } from './useZoomPan.js';
|
|
3
|
+
/**
|
|
4
|
+
* Text search over one rendered diagram.
|
|
5
|
+
*
|
|
6
|
+
* The search runs against the *rendered SVG's* `<text>` elements rather than the diagram
|
|
7
|
+
* source: what the reader sees is what they are searching, and a match can be highlighted and
|
|
8
|
+
* panned to because it is a real element with a position. Matches are marked with data
|
|
9
|
+
* attributes and coloured from the stylesheet, so the SVG string itself — which the cache
|
|
10
|
+
* shares between diagrams — is never mutated.
|
|
11
|
+
*
|
|
12
|
+
* Highlighting is imperative for the same reason the zoom transform is: the elements live in
|
|
13
|
+
* a `dangerouslySetInnerHTML` subtree that React cannot render into. React state carries only
|
|
14
|
+
* what drives markup — the query, the match count and the current position.
|
|
15
|
+
*/
|
|
16
|
+
export interface DiagramSearchHandle {
|
|
17
|
+
/** Whether the search bar is shown. */
|
|
18
|
+
open: boolean;
|
|
19
|
+
query: string;
|
|
20
|
+
/** Number of `<text>` elements matching the query; 0 when the query is empty. */
|
|
21
|
+
matchCount: number;
|
|
22
|
+
/** Zero-based position of the current match, or -1 when there is none. */
|
|
23
|
+
currentIndex: number;
|
|
24
|
+
inputRef: MutableRefObject<HTMLInputElement | null>;
|
|
25
|
+
toggle: () => void;
|
|
26
|
+
close: () => void;
|
|
27
|
+
setQuery: (value: string) => void;
|
|
28
|
+
next: () => void;
|
|
29
|
+
previous: () => void;
|
|
30
|
+
}
|
|
31
|
+
export interface UseDiagramSearchParams {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
zoom: ZoomPanHandle;
|
|
34
|
+
/** The rendered SVG string — a dependency, so a re-rendered picture is re-searched. */
|
|
35
|
+
svg: string | null;
|
|
36
|
+
}
|
|
37
|
+
export declare function useDiagramSearch({ enabled, zoom, svg, }: UseDiagramSearchParams): DiagramSearchHandle;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { DATA_ATTR } from '../../constants.js';
|
|
3
|
+
import { centerViewportOn } from './zoomMath.js';
|
|
4
|
+
export function useDiagramSearch({ enabled, zoom, svg, }) {
|
|
5
|
+
const [open, setOpen] = useState(false);
|
|
6
|
+
const [query, setQuery] = useState('');
|
|
7
|
+
const [matchCount, setMatchCount] = useState(0);
|
|
8
|
+
const [currentIndex, setCurrentIndex] = useState(-1);
|
|
9
|
+
const matchesRef = useRef([]);
|
|
10
|
+
const inputRef = useRef(null);
|
|
11
|
+
const toggle = useCallback(() => setOpen((value) => !value), []);
|
|
12
|
+
const close = useCallback(() => setOpen(false), []);
|
|
13
|
+
const next = useCallback(() => {
|
|
14
|
+
setCurrentIndex((index) => {
|
|
15
|
+
const total = matchesRef.current.length;
|
|
16
|
+
return total === 0 ? -1 : (index + 1) % total;
|
|
17
|
+
});
|
|
18
|
+
}, []);
|
|
19
|
+
const previous = useCallback(() => {
|
|
20
|
+
setCurrentIndex((index) => {
|
|
21
|
+
const total = matchesRef.current.length;
|
|
22
|
+
return total === 0 ? -1 : (index - 1 + total) % total;
|
|
23
|
+
});
|
|
24
|
+
}, []);
|
|
25
|
+
// Typing should not require a second click after pressing the toggle.
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (open)
|
|
28
|
+
inputRef.current?.focus();
|
|
29
|
+
}, [open]);
|
|
30
|
+
// A diagram that lost its zoom lost the viewport the search pans; the bar goes with it.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!enabled)
|
|
33
|
+
setOpen(false);
|
|
34
|
+
}, [enabled]);
|
|
35
|
+
/**
|
|
36
|
+
* Finds and marks the matches. The effect's cleanup unmarks them, so closing the bar,
|
|
37
|
+
* changing the query and unmounting all sweep up through the same code path. A replaced
|
|
38
|
+
* SVG needs no sweeping — its marked elements left the document with it — but re-runs the
|
|
39
|
+
* effect (via `svg`) so the new elements are searched.
|
|
40
|
+
*/
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!enabled || !open)
|
|
43
|
+
return undefined;
|
|
44
|
+
const layer = zoom.layerRef.current;
|
|
45
|
+
const needle = query.trim().toLowerCase();
|
|
46
|
+
if (!layer || needle === '') {
|
|
47
|
+
matchesRef.current = [];
|
|
48
|
+
setMatchCount(0);
|
|
49
|
+
setCurrentIndex(-1);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
// `<text>` elements, not their tspans: a PlantUML label is one text element, and marking
|
|
53
|
+
// the whole label keeps the highlight readable instead of colouring a fragment.
|
|
54
|
+
const matches = Array.from(layer.querySelectorAll('svg text')).filter((element) => (element.textContent ?? '').toLowerCase().includes(needle));
|
|
55
|
+
matchesRef.current = matches;
|
|
56
|
+
for (const match of matches)
|
|
57
|
+
match.setAttribute(DATA_ATTR.searchMatch, 'true');
|
|
58
|
+
setMatchCount(matches.length);
|
|
59
|
+
// Every new query starts at its first match, exactly like the browser's own find bar.
|
|
60
|
+
setCurrentIndex(matches.length > 0 ? 0 : -1);
|
|
61
|
+
return () => {
|
|
62
|
+
matchesRef.current = [];
|
|
63
|
+
for (const match of matches)
|
|
64
|
+
match.removeAttribute(DATA_ATTR.searchMatch);
|
|
65
|
+
};
|
|
66
|
+
}, [enabled, open, query, svg, zoom]);
|
|
67
|
+
/**
|
|
68
|
+
* Marks the current match and centres the viewport on it.
|
|
69
|
+
*
|
|
70
|
+
* Declared after the match effect on purpose: effects run in order, so by the time this
|
|
71
|
+
* one reads `matchesRef` the list belongs to the current query. `query` and `svg` are
|
|
72
|
+
* dependencies because a new list can leave the index at 0 — unchanged — while element 0
|
|
73
|
+
* is a different match.
|
|
74
|
+
*/
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!enabled || !open)
|
|
77
|
+
return undefined;
|
|
78
|
+
const target = currentIndex >= 0 ? matchesRef.current[currentIndex] : undefined;
|
|
79
|
+
if (!target)
|
|
80
|
+
return undefined;
|
|
81
|
+
target.setAttribute(DATA_ATTR.searchCurrent, 'true');
|
|
82
|
+
const layer = zoom.layerRef.current;
|
|
83
|
+
if (layer) {
|
|
84
|
+
// The match's offset inside the layer, both measured in the same transformed space,
|
|
85
|
+
// divided back by the scale — which yields content coordinates whatever the zoom is.
|
|
86
|
+
const transform = zoom.getTransform();
|
|
87
|
+
const layerBox = layer.getBoundingClientRect();
|
|
88
|
+
const box = target.getBoundingClientRect();
|
|
89
|
+
zoom.applyTransform(centerViewportOn(transform, (box.left + box.width / 2 - layerBox.left) / transform.k, (box.top + box.height / 2 - layerBox.top) / transform.k, zoom.measure()));
|
|
90
|
+
}
|
|
91
|
+
return () => target.removeAttribute(DATA_ATTR.searchCurrent);
|
|
92
|
+
}, [currentIndex, enabled, open, query, svg, zoom]);
|
|
93
|
+
// Stable per set of values, like the zoom handle: consumers may hang callbacks and
|
|
94
|
+
// effects off the handle, and those must not churn on unrelated re-renders.
|
|
95
|
+
return useMemo(() => ({
|
|
96
|
+
open,
|
|
97
|
+
query,
|
|
98
|
+
matchCount,
|
|
99
|
+
currentIndex,
|
|
100
|
+
inputRef,
|
|
101
|
+
toggle,
|
|
102
|
+
close,
|
|
103
|
+
setQuery,
|
|
104
|
+
next,
|
|
105
|
+
previous,
|
|
106
|
+
}), [open, query, matchCount, currentIndex, toggle, close, next, previous]);
|
|
107
|
+
}
|