@plannotator/ui 0.40.0 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/HANDOFF.md +110 -5
  2. package/README.md +6 -2
  3. package/components/DiagramBlock.tsx +363 -0
  4. package/components/GraphvizBlock.tsx +22 -597
  5. package/components/MermaidBlock.tsx +17 -664
  6. package/components/Viewer.tsx +60 -5
  7. package/components/diagram/DiagramCanvas.tsx +427 -0
  8. package/components/diagram/DiagramComposer.tsx +135 -0
  9. package/components/diagram/DiagramOverlay.tsx +215 -0
  10. package/components/diagram/DiagramPending.tsx +60 -0
  11. package/components/diagram/DiagramPopout.tsx +72 -0
  12. package/components/diagram/DiagramSourcePane.tsx +244 -0
  13. package/components/diagram/DiagramViewer.tsx +285 -0
  14. package/components/diagram/anchorClaims.ts +71 -0
  15. package/components/diagram/diagramControls.ts +41 -0
  16. package/components/diagram/index.ts +39 -0
  17. package/components/diagram/svgContentSize.ts +47 -0
  18. package/components/diagram/useDiagramComments.ts +341 -0
  19. package/components/diagram/useDiagramRender.ts +91 -0
  20. package/components/diagram/useDiagramSourceDraft.ts +143 -0
  21. package/components/diagram/useDiagramViewport.ts +156 -0
  22. package/components/html-viewer/bridge-script.asset.js +25 -0
  23. package/components/html-viewer/bridge-script.lite.ts +1 -1
  24. package/components/html-viewer/bridge-script.ts +37 -0
  25. package/hooks/useAnnotationHighlighter.ts +5 -0
  26. package/package.json +5 -3
  27. package/styles.css +1 -1
  28. package/types.ts +5 -0
  29. package/utils/diagram-anchor-graphviz.ts +143 -0
  30. package/utils/diagram-anchor.ts +401 -0
  31. package/utils/diagram-projection.ts +66 -0
  32. package/utils/diagram-render.ts +668 -0
  33. package/utils/graphviz.ts +93 -0
  34. package/utils/parser.ts +15 -1
  35. package/components/mermaidSvg.ts +0 -33
@@ -0,0 +1,285 @@
1
+ import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
2
+ import type { DiagramKind } from '@plannotator/core/diagram-anchor';
3
+ import { cn } from '../../lib/utils';
4
+ import { diagramFamilyOf } from '../../utils/diagram-anchor';
5
+ import { diagramFinder, type DiagramTheme } from '../../utils/diagram-render';
6
+ import { DiagramCanvas, type DiagramCanvasHandle, type DiagramEscapeOutcome } from './DiagramCanvas';
7
+ import { DiagramComposer } from './DiagramComposer';
8
+ import { DiagramOverlay } from './DiagramOverlay';
9
+ import { useDiagramComments, type DiagramComment, type DiagramCreateComment } from './useDiagramComments';
10
+ import { useDiagramRender, type DiagramRenderState } from './useDiagramRender';
11
+ import { useDiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft';
12
+
13
+ /**
14
+ * The one diagram viewer: the canvas with its overlay, the Source pane on
15
+ * its LEFT (stacked under it on the phone) when the host can save, the
16
+ * parse-error strip over a dimmed last-good render. It takes the diagram
17
+ * source and the host's comments on it, and reports a new comment through
18
+ * `onCreateComment`; the comments rail, storage and identity are the
19
+ * host's. Plannotator's fence blocks render through it in the document
20
+ * (`components/DiagramBlock`) and again at full size in the popout; a host
21
+ * with its own document store renders it wherever a diagram lives.
22
+ */
23
+ /**
24
+ * The Source pane is CodeMirror, and a viewer that can never open one (every
25
+ * fence in a Plannotator document today: no host passes `onSave`) must not
26
+ * carry it. It loads on the first open of the pane and never before, so the
27
+ * editor stays out of the document-read closure of a chunked host.
28
+ */
29
+ const DiagramSourcePane = lazy(async () => ({ default: (await import('./DiagramSourcePane')).DiagramSourcePane }));
30
+
31
+ /** The pane's box while its chunk loads: same class list, so the split it
32
+ * opens into is already the right size and nothing jumps when it lands.
33
+ * Never `null` here — that would collapse the row back onto the canvas. */
34
+ const PANE_CLASS = 'order-last min-h-0 shrink-0 basis-2/5 border-t border-border md:order-first md:w-80 md:basis-auto md:border-r md:border-t-0';
35
+
36
+ export interface DiagramViewerProps {
37
+ readonly kind: DiagramKind;
38
+ /** The diagram text. With `onSave` this is the saved baseline the pane's
39
+ * draft starts from; without it, what the canvas renders. */
40
+ readonly source: string;
41
+ readonly theme: DiagramTheme;
42
+ readonly comments: readonly DiagramComment[];
43
+ /** A comment composed on a part. Absent: clicks open nothing. */
44
+ readonly onCreateComment?: DiagramCreateComment;
45
+ /** Save the pane's text. Absent: there is no Source pane. */
46
+ readonly onSave?: (source: string) => Promise<SaveResult>;
47
+ /** With `onSave`: show the pane read-only (no edit access). */
48
+ readonly readOnlySource?: boolean;
49
+ /** Whether the Source pane is open (the host owns the toggle). */
50
+ readonly sourceOpen?: boolean;
51
+ readonly selectedCommentId?: string | null;
52
+ readonly onSelectComment?: (id: string | null) => void;
53
+ /** The comments whose part is gone from the current render, once per
54
+ * membership change, after every render. */
55
+ readonly onUnanchoredChange?: (ids: ReadonlySet<string>) => void;
56
+ /** Every comment's verdict (true: its part is in this render), whenever
57
+ * a verdict or the comment list changes. */
58
+ readonly onResolutionChange?: (resolution: ReadonlyMap<string, boolean>) => void;
59
+ /** Escape with no composer open and nothing selected: a popout closes. */
60
+ readonly onDismiss?: () => void;
61
+ /** A stable prefix for the rendered element ids; two viewers over one
62
+ * document need two. */
63
+ readonly renderId?: string;
64
+ /** Lines to add so a comment's `sourceLine` names DOCUMENT lines: 0 when
65
+ * the document is the diagram, the fence's opening line for a fence. */
66
+ readonly sourceLineOffset?: number;
67
+ /** Shift-click targets one comment may also cover. Default 0: a comment
68
+ * covers one part. */
69
+ readonly maxAdditionalTargets?: number;
70
+ /** Commenting is off, and this is why (shown in place of the composer). */
71
+ readonly commentingDisabledReason?: string;
72
+ /** Bumped by the host's Retry after a failed engine load. */
73
+ readonly retryToken?: number;
74
+ /** Every render state change (the host sizes itself, shows its own
75
+ * pending or error chrome). */
76
+ readonly onRenderState?: (state: DiagramRenderState) => void;
77
+ /** What to show while there is no svg yet (the first render pending, or
78
+ * the first render failed). Default: a quiet status line. */
79
+ readonly renderFallback?: (state: DiagramRenderState) => ReactNode;
80
+ /** Take the keyboard on mount (a popout). */
81
+ readonly autoFocus?: boolean;
82
+ readonly className?: string;
83
+ /** Classes for the canvas host. The canvas lets a finger scroll the page
84
+ * past it (`touch-action: pan-y`); a viewer that owns the whole screen
85
+ * passes `touch-none`. */
86
+ readonly canvasClassName?: string;
87
+ }
88
+
89
+ export function DiagramViewer({
90
+ kind,
91
+ source,
92
+ theme,
93
+ comments,
94
+ onCreateComment,
95
+ onSave,
96
+ readOnlySource = false,
97
+ sourceOpen = false,
98
+ selectedCommentId = null,
99
+ onSelectComment,
100
+ onUnanchoredChange,
101
+ onResolutionChange,
102
+ onDismiss,
103
+ renderId = 'diagram',
104
+ sourceLineOffset = 0,
105
+ maxAdditionalTargets = 0,
106
+ commentingDisabledReason,
107
+ retryToken,
108
+ onRenderState,
109
+ renderFallback,
110
+ autoFocus,
111
+ className,
112
+ canvasClassName,
113
+ }: DiagramViewerProps) {
114
+ const finder = diagramFinder(kind);
115
+ const familyOf = useCallback((svg: Element) => (kind === 'graphviz' ? ('graphviz' as const) : diagramFamilyOf(svg)), [kind]);
116
+ const hasPane = onSave !== undefined;
117
+ const editable = hasPane && !readOnlySource;
118
+
119
+ const draft = useDiagramSourceDraft({ source, editable, onSave });
120
+ const rendered = hasPane ? draft.preview : source;
121
+ const savedSource = hasPane ? draft.baseline : source;
122
+ const sourceDirty = hasPane && draft.dirty;
123
+
124
+ const render = useDiagramRender(kind, renderId, rendered, theme, { retryToken });
125
+ useEffect(() => {
126
+ onRenderState?.(render);
127
+ }, [onRenderState, render]);
128
+
129
+ const [svgRoot, setSvgRoot] = useState<SVGSVGElement | null>(null);
130
+ const onSvgRoot = useCallback((root: SVGSVGElement | null) => setSvgRoot(root), []);
131
+
132
+ const canCreate = onCreateComment !== undefined || commentingDisabledReason !== undefined;
133
+ const commentsState = useDiagramComments({
134
+ finder,
135
+ svgRoot,
136
+ renderId: render.renderId,
137
+ renderVersion: render.renderVersion,
138
+ comments,
139
+ savedSource,
140
+ sourceLineOffset,
141
+ sourceDirty,
142
+ maxAdditionalTargets,
143
+ canCreate,
144
+ onCreateComment,
145
+ onSelectComment,
146
+ onUnanchoredChange,
147
+ onResolutionChange,
148
+ familyOf,
149
+ });
150
+
151
+ // The selected comment's source line for the pane's gutter mark. The
152
+ // anchor names DOCUMENT lines; the pane shows the diagram text.
153
+ const markedLines = useMemo(() => {
154
+ if (selectedCommentId === null) return null;
155
+ const line = comments.find((entry) => entry.id === selectedCommentId)?.anchor.sourceLine;
156
+ if (line === null || line === undefined) return null;
157
+ return [line[0] - sourceLineOffset, line[1] - sourceLineOffset] as const;
158
+ }, [comments, selectedCommentId, sourceLineOffset]);
159
+
160
+ const onEscape = useCallback((): DiagramEscapeOutcome => {
161
+ if (commentsState.composer !== null) {
162
+ commentsState.cancel();
163
+ return 'consumed';
164
+ }
165
+ if (selectedCommentId !== null && onSelectComment !== undefined) {
166
+ onSelectComment(null);
167
+ return 'consumed';
168
+ }
169
+ onDismiss?.();
170
+ return 'pass';
171
+ }, [commentsState, onDismiss, onSelectComment, selectedCommentId]);
172
+
173
+ // Closing the pane only closes an open composer so the layout shift does
174
+ // not strand it; the draft survives a reopen.
175
+ const cancelComposer = commentsState.cancel;
176
+ useEffect(() => {
177
+ if (!sourceOpen) cancelComposer();
178
+ }, [cancelComposer, sourceOpen]);
179
+
180
+ const overlay = useCallback(
181
+ (handle: DiagramCanvasHandle) => (
182
+ <DiagramOverlay
183
+ handle={handle}
184
+ resolved={commentsState.resolved}
185
+ hover={commentsState.hover}
186
+ composer={commentsState.composer}
187
+ selectedCommentId={selectedCommentId}
188
+ onSelectComment={onSelectComment}
189
+ renderComposer={(anchorRect) =>
190
+ commentsState.composer === null ? null : (
191
+ <DiagramComposer
192
+ key={`${commentsState.composer.primary.target.id ?? ''}:${commentsState.composer.primary.target.from ?? ''}:${commentsState.composer.primary.target.to ?? ''}`}
193
+ draft={commentsState.composer}
194
+ anchorRect={anchorRect}
195
+ hostWidth={handle.hostRef.current?.getBoundingClientRect().width ?? 0}
196
+ sourceDirty={sourceDirty}
197
+ submitting={commentsState.submitting}
198
+ error={commentsState.submitError}
199
+ disabledReason={commentingDisabledReason}
200
+ onSubmit={(text) => void commentsState.submit(text)}
201
+ onCancel={commentsState.cancel}
202
+ />
203
+ )
204
+ }
205
+ />
206
+ ),
207
+ [commentingDisabledReason, commentsState, onSelectComment, selectedCommentId, sourceDirty],
208
+ );
209
+
210
+ const showFallback = render.svgNode === null;
211
+
212
+ return (
213
+ // `annotation-exclude`: the document's text highlighter never enters a
214
+ // diagram, so a text restore can never wrap a <mark> inside the svg.
215
+ <div data-diagram-viewer="" className={cn('annotation-exclude flex h-full min-h-0 w-full flex-col md:flex-row', className)}>
216
+ {/* The pane comes FIRST in the row (owner ruling: the source sits on
217
+ the left of the diagram while editing). On the phone the row is a
218
+ column and the pane stays stacked UNDER the canvas, which
219
+ `order-last` keeps while `md:order-first` puts it left from `md`. */}
220
+ {hasPane && sourceOpen && (
221
+ <Suspense fallback={<div data-diagram-source-pane-pending="" aria-hidden="true" className={PANE_CLASS} />}>
222
+ <DiagramSourcePane draft={draft} editable={editable} markedLines={markedLines} className={PANE_CLASS} />
223
+ </Suspense>
224
+ )}
225
+ <div className="relative min-h-0 min-w-0 flex-1">
226
+ {showFallback ? (
227
+ <div data-diagram-fallback="" className="h-full min-h-0 w-full">
228
+ {renderFallback !== undefined ? (
229
+ renderFallback(render)
230
+ ) : (
231
+ <div className="flex h-full items-center justify-center p-4 text-xs text-muted-foreground" aria-live="polite">
232
+ {render.error !== null
233
+ ? `The diagram does not parse${render.error.line === null ? '' : ` (line ${render.error.line})`}: ${render.error.message}`
234
+ : render.pending
235
+ ? 'Rendering diagram…'
236
+ : 'Nothing to render yet.'}
237
+ </div>
238
+ )}
239
+ </div>
240
+ ) : (
241
+ <DiagramCanvas
242
+ svgNode={render.svgNode}
243
+ targetSelector={finder.targetSelector}
244
+ pickTarget={commentsState.pickTarget}
245
+ className={canvasClassName}
246
+ dimmed={render.error !== null}
247
+ onSvgRoot={onSvgRoot}
248
+ onHoverElement={commentsState.setHoverElement}
249
+ onClickElement={commentsState.clickElement}
250
+ onEscape={onEscape}
251
+ overlay={overlay}
252
+ autoFocus={autoFocus}
253
+ >
254
+ {render.error !== null && (
255
+ <div
256
+ data-diagram-parse-error=""
257
+ role="alert"
258
+ className="absolute inset-x-3 top-3 z-10 rounded-md border border-warning/40 bg-card/95 px-3 py-2 text-xs shadow-lg backdrop-blur"
259
+ >
260
+ <div className="font-medium text-foreground">
261
+ {render.error.line === null ? 'The diagram does not parse' : `The diagram does not parse (line ${render.error.line})`}
262
+ </div>
263
+ <div className="mt-0.5 text-muted-foreground">
264
+ {render.error.message}
265
+ {' The last good render stays below.'}
266
+ </div>
267
+ </div>
268
+ )}
269
+ {/* The chip earns its place only while the pane is closed (the
270
+ draft survives a close); with the pane open its header carries
271
+ the one "Draft · unsaved". */}
272
+ {editable && draft.dirty && !sourceOpen && (
273
+ <span
274
+ data-diagram-preview-state=""
275
+ className="absolute left-3 top-3 z-10 rounded-sm border border-border bg-card/85 px-1.5 py-0.5 text-[10px] text-muted-foreground backdrop-blur"
276
+ >
277
+ Draft preview · unsaved
278
+ </span>
279
+ )}
280
+ </DiagramCanvas>
281
+ )}
282
+ </div>
283
+ </div>
284
+ );
285
+ }
@@ -0,0 +1,71 @@
1
+ import { createContext } from 'react';
2
+
3
+ /**
4
+ * Who shows a diagram comment that names no diagram block of the document.
5
+ *
6
+ * A comment composed in a block carries that block's `blockId`. One that
7
+ * arrives through `POST /api/external-annotations` carries `blockId:
8
+ * "external"`, and one whose fence was deleted carries an id that no longer
9
+ * exists; neither says which diagram it belongs to, but its anchor does.
10
+ * Every diagram block tries such a comment against its own render and
11
+ * reports the verdict here; the FIRST block in document order whose finder
12
+ * resolves it shows it, and when every block has answered and none did, it
13
+ * is unanchored (listed in the rail with the chip, never silently dropped).
14
+ * A block that has not answered yet keeps the verdict pending, so nothing is
15
+ * called unanchored while a diagram is still rendering.
16
+ */
17
+ export class DiagramAnchorClaims {
18
+ private readonly reports = new Map<string, Map<string, boolean>>();
19
+ private readonly listeners = new Set<() => void>();
20
+ private version = 0;
21
+
22
+ /** The diagram blocks of the document, in document order. */
23
+ constructor(readonly blockIds: readonly string[]) {}
24
+
25
+ /** A block's verdicts for the unowned comments it tried. Verdicts for ids
26
+ * it no longer tries are kept: a block stops trying a comment once another
27
+ * block owns it, and that must not read as "could not resolve". */
28
+ report(blockId: string, results: ReadonlyMap<string, boolean>): void {
29
+ let mine = this.reports.get(blockId);
30
+ let changed = false;
31
+ if (mine === undefined) {
32
+ mine = new Map();
33
+ this.reports.set(blockId, mine);
34
+ changed = true;
35
+ }
36
+ for (const [id, resolved] of results) {
37
+ if (mine.get(id) !== resolved) {
38
+ mine.set(id, resolved);
39
+ changed = true;
40
+ }
41
+ }
42
+ if (!changed) return;
43
+ this.version += 1;
44
+ for (const listener of [...this.listeners]) listener();
45
+ }
46
+
47
+ /** The block that shows the comment; `null` when every block answered and
48
+ * none resolved it (unanchored); `undefined` while an earlier block has
49
+ * not answered yet. */
50
+ owner(annotationId: string): string | null | undefined {
51
+ for (const blockId of this.blockIds) {
52
+ const verdict = this.reports.get(blockId)?.get(annotationId);
53
+ if (verdict === undefined) return undefined;
54
+ if (verdict) return blockId;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ readonly subscribe = (listener: () => void): (() => void) => {
60
+ this.listeners.add(listener);
61
+ return () => {
62
+ this.listeners.delete(listener);
63
+ };
64
+ };
65
+
66
+ readonly getVersion = (): number => this.version;
67
+ }
68
+
69
+ /** Provided by `Viewer` with the document's diagram block ids; a diagram
70
+ * block rendered on its own makes a one-block coordinator for itself. */
71
+ export const DiagramAnchorClaimsContext = createContext<DiagramAnchorClaims | null>(null);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The canvas's own chrome, and why a pointer event on it must stop there.
3
+ *
4
+ * `DiagramCanvas` resolves what a click means over EVERYTHING under the
5
+ * pointer (`elementsFromPoint`, node → edge → cluster), because the edge hit
6
+ * layer sits above the nodes. The controls painted over the canvas — the
7
+ * zoom strip, the comment composer, the popout's own chrome — are not in the
8
+ * svg, so that walk skipped straight past them to whatever part happened to
9
+ * be underneath: pressing Zoom out over a node opened the composer on that
10
+ * node, and a press on the strip could start a pan.
11
+ *
12
+ * So a pointer event whose composed path contains a control surface never
13
+ * resolves a target, never opens the composer, and never starts a pan. It is
14
+ * the path rather than the point: a control knows it is a control, while a
15
+ * rectangle test would have to be kept in step with the layout.
16
+ *
17
+ * Mark new chrome with `data-diagram-control`. Buttons and toolbars count
18
+ * without marking, since anything the pointer can press is chrome by
19
+ * definition.
20
+ */
21
+ export const DIAGRAM_CONTROL_SELECTOR =
22
+ '[data-diagram-control],[data-diagram-composer],[data-diagram-source-pane],[data-diagram-popout-chrome],button,[role="toolbar"],[role="button"],input,textarea,select,a[href]';
23
+
24
+ function isElement(value: unknown): value is Element {
25
+ return typeof (value as Element | null)?.matches === 'function';
26
+ }
27
+
28
+ /** Whether this pointer event was aimed at the canvas's chrome rather than
29
+ * at the diagram. */
30
+ export function isDiagramControlEvent(event: { target: EventTarget | null; nativeEvent?: Event }): boolean {
31
+ const native = event.nativeEvent ?? (event as unknown as Event);
32
+ const path = typeof native?.composedPath === 'function' ? native.composedPath() : [];
33
+ for (const entry of path) {
34
+ if (isElement(entry) && entry.matches(DIAGRAM_CONTROL_SELECTOR)) return true;
35
+ }
36
+ // `composedPath()` is empty once dispatch has finished, and jsdom-class
37
+ // DOMs may not implement it at all: the target's own ancestry is the same
38
+ // answer for everything but a shadow root.
39
+ const target = event.target;
40
+ return isElement(target) && target.closest(DIAGRAM_CONTROL_SELECTOR) !== null;
41
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The diagram engine's host surface (0.41.0): the one viewer over the
3
+ * renderer slot, its canvas, overlay, composer and Source pane, and the
4
+ * hooks and types a host wires them with. The renderer slot itself, the
5
+ * runtime slots and the anchor codecs live under `utils/` (`diagram-render`,
6
+ * `graphviz`, `mermaid`, `diagram-anchor`, `diagram-anchor-graphviz`,
7
+ * `diagram-projection`).
8
+ */
9
+ export { DiagramViewer, type DiagramViewerProps } from './DiagramViewer';
10
+ export { DiagramPopout } from './DiagramPopout';
11
+ export { DiagramPending, DiagramInlineSource, DiagramBlockPending } from './DiagramPending';
12
+ export { isDiagramControlEvent, DIAGRAM_CONTROL_SELECTOR } from './diagramControls';
13
+ export { svgContentSize } from './svgContentSize';
14
+ export { DiagramCanvas, KEY_PAN_PX, type DiagramCanvasHandle, type DiagramEscapeOutcome } from './DiagramCanvas';
15
+ export { DiagramOverlay } from './DiagramOverlay';
16
+ export { DiagramComposer } from './DiagramComposer';
17
+ export { DiagramSourcePane } from './DiagramSourcePane';
18
+ export {
19
+ useDiagramComments,
20
+ type DiagramComment,
21
+ type DiagramComposerDraft,
22
+ type DiagramCreateComment,
23
+ type DiagramHover,
24
+ type ResolvedDiagramComment,
25
+ } from './useDiagramComments';
26
+ export { useDiagramRender, type DiagramRenderState } from './useDiagramRender';
27
+ export { useDiagramSourceDraft, PREVIEW_DEBOUNCE_MS, type DiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft';
28
+ export {
29
+ useDiagramViewport,
30
+ DRAG_THRESHOLD_PX,
31
+ TOUCH_DRAG_THRESHOLD_PX,
32
+ FIT_PADDING_PX,
33
+ WHEEL_ZOOM_SENSITIVITY,
34
+ ZOOM_MAX,
35
+ ZOOM_MIN,
36
+ ZOOM_STEP,
37
+ type ContentSize,
38
+ type Viewport,
39
+ } from './useDiagramViewport';
@@ -0,0 +1,47 @@
1
+ import type { ContentSize } from './useDiagramViewport';
2
+
3
+ /**
4
+ * The rendered diagram's intrinsic size — a pure read of two svg attributes.
5
+ *
6
+ * It lives apart from `DiagramCanvas` (which re-exports it, so every
7
+ * published path still resolves) because the document's fence block needs
8
+ * it to size its inline box and nothing else of the canvas: importing it
9
+ * from the canvas dragged the whole zoom/pan surface, its viewport hook and
10
+ * lucide into any closure that touched a diagram block. The type import is
11
+ * erased, so this file's runtime dependencies are none.
12
+ */
13
+
14
+ /** A numeric svg length: `206`, `206pt`, `206px`. */
15
+ function svgLength(value: string | null): number {
16
+ if (value === null) return Number.NaN;
17
+ return Number.parseFloat(value);
18
+ }
19
+
20
+ /** The svg's intrinsic size, from its viewBox (Mermaid and Graphviz always
21
+ * write one), else its `width` / `height` attributes (a `pt` or `px` suffix
22
+ * is accepted, as Graphviz writes them). */
23
+ export function svgContentSize(svg: SVGSVGElement): ContentSize | null {
24
+ const viewBox = svg.getAttribute('viewBox');
25
+ if (viewBox !== null) {
26
+ const parts = viewBox
27
+ .trim()
28
+ .split(/[\s,]+/u)
29
+ .map(Number);
30
+ const width = parts[2];
31
+ const height = parts[3];
32
+ if (
33
+ parts.length === 4 &&
34
+ width !== undefined &&
35
+ height !== undefined &&
36
+ Number.isFinite(width) &&
37
+ Number.isFinite(height) &&
38
+ width > 0 &&
39
+ height > 0
40
+ ) {
41
+ return { width, height };
42
+ }
43
+ }
44
+ const width = svgLength(svg.getAttribute('width'));
45
+ const height = svgLength(svg.getAttribute('height'));
46
+ return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? { width, height } : null;
47
+ }