@plannotator/ui 0.41.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.
- package/HANDOFF.md +65 -1
- package/components/DiagramBlock.tsx +20 -33
- package/components/Viewer.tsx +38 -23
- package/components/diagram/DiagramCanvas.tsx +30 -34
- package/components/diagram/DiagramPending.tsx +60 -0
- package/components/diagram/DiagramPopout.tsx +3 -1
- package/components/diagram/DiagramViewer.tsx +17 -8
- package/components/diagram/diagramControls.ts +41 -0
- package/components/diagram/index.ts +4 -1
- package/components/diagram/svgContentSize.ts +47 -0
- package/package.json +1 -1
package/HANDOFF.md
CHANGED
|
@@ -800,13 +800,77 @@ Three class-level deltas, none of which breaks an id- or class-based selector: `
|
|
|
800
800
|
|
|
801
801
|
**A click never does nothing: the `diagram` kind.** A click that resolves no part opens the composer on the WHOLE diagram (with a draft already open it closes the draft instead): `{ kind: 'diagram', family, label: <the diagram's first source line>, sourceLine: <the source's full range, offset into the document> }`, no `id`. This covers gitGraph, pie, and any family the codec does not address. Its ring is the svg's content bounds and its badge sits top-left; it is never unanchored while the diagram renders (`findTarget` returns the svg root). The export line reads `Diagram (<family>), lines a–b`.
|
|
802
802
|
|
|
803
|
+
### 0.41.1 — the engine is lazy, and the controls are not part of the diagram
|
|
804
|
+
|
|
805
|
+
Two fixes, no API change: every export named in this section still resolves
|
|
806
|
+
from the same path, and a host upgrading from 0.41.0 changes nothing.
|
|
807
|
+
|
|
808
|
+
**Lazy.** 0.41.0 reached the whole engine through STATIC imports from
|
|
809
|
+
`components/Viewer`: `Viewer` → `MermaidBlock` / `GraphvizBlock` →
|
|
810
|
+
`DiagramBlock` → `DiagramPopout` and `DiagramViewer` → `DiagramSourcePane` →
|
|
811
|
+
CodeMirror. Any host that statically imports `Viewer` therefore shipped the
|
|
812
|
+
canvas, the overlay, the finders, the popout and the editor on EVERY document
|
|
813
|
+
read, including a markdown document with no diagram and — since no host passes
|
|
814
|
+
`onSave` for a fence — an editor that could never open. Three edges are now
|
|
815
|
+
`React.lazy`: the two block wrappers in `Viewer` (one chunk each over a shared
|
|
816
|
+
`DiagramBlock` chunk), `DiagramPopout` in `DiagramBlock` (loaded when Expand
|
|
817
|
+
is pressed, fallback `null`), and `DiagramSourcePane` in `DiagramViewer`
|
|
818
|
+
(loaded when the pane first opens, fallback a box with the pane's own class
|
|
819
|
+
list so the split never collapses). A host that imports `DiagramViewer`
|
|
820
|
+
directly still gets a working viewer; its pane simply arrives one chunk later.
|
|
821
|
+
`svgContentSize` moved to its own dependency-free module and is re-exported
|
|
822
|
+
from `DiagramCanvas` and the barrel, so both published paths are unchanged.
|
|
823
|
+
The Suspense fallback for a fence is the block's own pending state
|
|
824
|
+
(`components/diagram/DiagramPending`, new, exported for hosts that render
|
|
825
|
+
their own fence chrome), so the source fence under "Rendering diagram…" paints
|
|
826
|
+
once and neither wait flashes. Measured on `Viewer`'s own document-read
|
|
827
|
+
closure (minified, gzip): 980.3 KB → 829.4 KB, -150.9 KB. Single-file builds
|
|
828
|
+
inline everything and are unchanged, which is why
|
|
829
|
+
`components/Viewer.diagramClosure.test.ts` bundles the entry and walks its
|
|
830
|
+
static imports.
|
|
831
|
+
|
|
832
|
+
**Controls.** The canvas resolves a click over everything under the pointer
|
|
833
|
+
(`elementsFromPoint`, node → edge → cluster) because the edge hit layer sits
|
|
834
|
+
above the nodes. The chrome painted over the canvas is not in the svg, so that
|
|
835
|
+
walk stepped past it to the part behind: pressing Zoom out over a node opened
|
|
836
|
+
the composer on that node, and a press on the strip could start a pan. A
|
|
837
|
+
pointer event whose composed path contains a control surface now resolves no
|
|
838
|
+
target, opens no composer and starts no pan. Mark host chrome inside the
|
|
839
|
+
canvas with `data-diagram-control`; `button`, `[role=toolbar]`, inputs, the
|
|
840
|
+
composer and the source pane count without marking
|
|
841
|
+
(`components/diagram/diagramControls`).
|
|
842
|
+
|
|
843
|
+
**A diagram comment restored before its diagram mounts.** Making the two
|
|
844
|
+
block wrappers lazy opens a window in which the document has painted and a
|
|
845
|
+
draft has restored but no diagram exists in the DOM yet. That window was
|
|
846
|
+
investigated after a report of diagram comments being lost across a reload;
|
|
847
|
+
the report did not hold (the probe behind it never answered the "Draft
|
|
848
|
+
Recovered" modal and then counted an un-restored session), and the three
|
|
849
|
+
properties that make the window safe were already in place. They are now
|
|
850
|
+
pinned, because every one of them is a way to lose a comment that has no text
|
|
851
|
+
to fall back on:
|
|
852
|
+
|
|
853
|
+
- the highlighter skips a row carrying `diagramAnchor` outright — it is
|
|
854
|
+
neither painted, attempted nor reported unanchored — including the shape
|
|
855
|
+
with no quote and no `blockId` that a whole-diagram or label-less anchor
|
|
856
|
+
produces (`hooks/useAnnotationHighlighter.diagramSkip.test.tsx`);
|
|
857
|
+
- `Viewer`'s "this document has no diagram, so nobody can resolve this row"
|
|
858
|
+
report keys on the PARSE, never on what has mounted, so a lazy load does
|
|
859
|
+
not flash the "Unanchored" chip on a comment that restores fine;
|
|
860
|
+
- the row stays listed either way, and the block claims it and paints its
|
|
861
|
+
badge whenever it mounts — no second restore pass, no reload
|
|
862
|
+
(`components/Viewer.diagramLazyRestore.test.tsx`, which holds the engine
|
|
863
|
+
open on a gated runtime loader and asserts the panel row, the absent chip,
|
|
864
|
+
then the badge).
|
|
865
|
+
|
|
803
866
|
**Migration for a host that carried the copies.** `useDiagramRender(kind, documentId, source, theme, { retryToken })` now takes the `{ colorTheme, mode }` theme and reports `error.runtimeUnavailable`; `useDiagramAnnotations` becomes the host's projection of its rows onto `comments` plus its mutation behind `onCreateComment` (the viewer half is `useDiagramComments`); `useDiagramDraft`'s `preview`/`dirty`/`stale`/`reload` semantics live in `useDiagramSourceDraft` behind `onSave` (the PATCH, `If-Match`, the query cache and the fence slice stay host-side; answer `stale` on a 412); `DiagramComposer` takes `disabledReason`/`error` instead of a `CommentingPolicy`; the canvas's `onEscape` returns `'consumed' | 'pass'` so a popout can walk the Escape ladder; arrow keys pan (`KEY_PAN_PX`, Shift ×5) in addition to `+` `-` `0`. Icons come from `lucide-react` (already a dependency).
|
|
804
867
|
|
|
805
868
|
---
|
|
806
869
|
|
|
807
870
|
## Publishing & versioning
|
|
808
871
|
|
|
809
|
-
- **The current pair is `@plannotator/ui` `0.41.
|
|
872
|
+
- **The current pair is `@plannotator/ui` `0.41.1` on `@plannotator/core` `0.25.4`.** Core is UNCHANGED from 0.41.0, so 0.41.1 publishes alone (`ui` only; core 0.25.4 must already be published). 0.41.1 is two fixes over 0.41.0 with no API change — the diagram engine is loaded lazily by the first diagram fence instead of riding every document read, and a press on the canvas's own controls no longer comments on the part behind them; see "0.41.1 — the engine is lazy, and the controls are not part of the diagram". The 0.41.0 notes below still describe the engine itself.
|
|
873
|
+
- **The pair 0.41.0 shipped as was `@plannotator/ui` `0.41.0` on `@plannotator/core` `0.25.4`. Publish `core` 0.25.4 first, then `ui` 0.41.0** (both by hand from `main` after merge; CI never publishes these packages). 0.41.0 is the diagram engine (see "Diagram engine (0.41.0)"): one renderer slot and one canvas behind `MermaidBlock` / `GraphvizBlock`, the `components/diagram` surface, the Graphviz runtime slot with `@viz-js/viz` pinned `3.30.0`, and `Annotation.diagramAnchor`; core 0.25.4 adds the `diagram-anchor` subpath ui imports, so a ui 0.41.0 on a published core 0.25.3 would fail to compile in a consumer.
|
|
810
874
|
- The previous pair was `@plannotator/ui` `0.40.0` on `@plannotator/core` `0.25.3` (publish order the same). Three things shipped in 0.40.0: (1) **Mermaid 12.0.0**, pinned exactly (was `^11.17.2`): ELK layout by default for flowchart/state/class/ER/requirement, Safari 17.4+ / ES2024 floor, SVG ids byte-identical to 11 but `g.edgePaths` children now in declaration order, and the plan editor no longer imports `utils/mermaid-eager` (the lazy path is the default for everyone; hosts that want startup registration import the eager entry themselves) — see "Mermaid 12 (0.40.0)"; (2) **theme-aware Mermaid diagrams**: New additive exports `utils/mermaidTheme` (`buildMermaidThemeVariables`, `readThemeTokens`, `applyMermaidTheme`, `mermaidThemeKey`, `buildMermaidConfig`, `ensureContrast`, `isDarkBackground`, `MERMAID_THEME_TOKEN_NAMES`) and `utils/cssColor` (parser + OKLab/contrast toolkit). `MermaidBlock` now calls `useTheme()` and `applyMermaidTheme` before each render; `MERMAID_CONFIG`, `loadMermaidRuntime`, `mermaid-eager` and `securityLevel: 'strict'` are unchanged. A host whose document carries no theme tokens renders diagrams byte-identically to 0.39.0; a host that mounts `ThemeProvider` with `theme.css` gets diagrams in its palette and mode with no configuration. No new peer dependencies; core unchanged. See "Theme-aware Mermaid diagrams (0.40.0)".; (3) **element context through the host seam (#1521, #1549), which is what moves `core` to 0.25.3:** `@plannotator/core/html-anchor` gains `parseHtmlElementContext`, `MAX_ELEMENT_CONTEXT_BYTES` and `MAX_PAGE_URL_LENGTH`; `PersistedHtmlAnchor.elementContext?` and `HtmlAnnotationTarget.context?` now round-trip through `buildPersistedHtmlAnchor` and `projectHostThreads`. **Core changes here, so bump and publish `core` first** and update UI's exact core dependency before packing ui — a ui build that imports these from an older published core fails to compile in a consumer, the 0.38.0 failure mode. `@plannotator/ui/components/html-viewer` re-exports the validator, so 0.39.0's import site is unchanged, and rows without context stay byte-identical on the wire. `utils/parser` gains `includeOutline` on `elementContextExportBlock` / `exportAnnotationEntry`, and `exportAnnotationEntry`'s `includeRoute` now defaults to true per field. See "Element context through the host seam".
|
|
811
875
|
- The previous pair was `@plannotator/ui` `0.39.0` on `@plannotator/core` `0.25.2` (core unchanged; nothing under `packages/core` moved). UI 0.39.0 adds **element context** to raw-HTML and live-app pinpoint annotations (#1517, #1520): a new optional `Annotation.elementContext` (`HtmlElementContext` in `@plannotator/ui/types`) and `HtmlAnnotationTarget.context`, captured by the bridge at click time (tag, id, author classes, ancestor `path`, `role`, accessible `name`, an allowlisted `attrs` set with href/src scrubbed of query and fragment, rendered `text`, an adaptive collapsed HTML `outline`, child count, viewport `rect`, nearest `landmark` and `heading`, a `component` hint, and in live-app sessions `page`), hard-capped at 2 KiB serialized per primary and 1 KiB per extra target, and re-validated at the parent trust boundary by the new `parseHtmlElementContext` export of `@plannotator/ui/components/html-viewer`. New helpers on `@plannotator/ui/utils/parser`: `elementContextExportBlock(ann, { includeRoute })` (the fenced skeleton plus selector/path/role/name/attrs/text/box/near lines the full export now prints under a context-bearing comment) and `exportAnnotationEntry(ann, { includeRoute })` (one annotation as a standalone feedback entry, a pure helper for hosts; `AnnotationPanel`'s card chrome is unchanged from 0.38.2). The field is purely descriptive: `HtmlElementAnchor` and restore are untouched, no `BRIDGE_PROTOCOL_VERSION` bump, share links drop it like anchors, annotations without it export byte-identically, and the repaint path posts only anchors to the bridge. **Host persistence gap in 0.39.0 itself, closed in the next publish (#1521, #1549)**: as shipped, 0.39.0's `@plannotator/core/html-anchor` (`buildPersistedHtmlAnchor`, `projectHostThreads`) does not carry `elementContext`, so a host pinned to 0.39.0 that persists through those helpers drops it on save and must persist and project the field itself. The next publish carries it end to end — see "Element context through the host seam". Peer ranges are unchanged from 0.38.2: `react` / `react-dom` `^19.2.3`, `tailwindcss` as before, and `@codemirror/state ^6.7.2` beside `@codemirror/view ^6.43.10`. Decision-control change in the same window (#1516): the header primary reads `Send Feedback` / `Post Comments` with no inline count (`DecisionPrimary.count` removed; internal, not host-supported surface).
|
|
812
876
|
- Before that, `@plannotator/ui` `0.38.2` on `@plannotator/core` `0.25.2`. UI 0.38.2 keeps the type word in a titled alert's accessible name through a visually hidden `sr-only` span before the title instead of an `aria-label` on the title row (naming a generic `div` is prohibited by ARIA and WebKit drops it, so VoiceOver on Safari read only the bold title in 0.38.1), and loosens the React peer back to `^19.2.3` (0.38.1 declared `^19.2.8` only because the dependency batch moved it; nothing in the package needs a newer API). **Do not consume ui 0.38.0**: it imports `@plannotator/core/token-hover` (the hover-card trigger settings, #1462) but pins core 0.25.1, which never exported that subpath, so it fails to compile in any consumer; 0.38.1 is the same UI pinning core 0.25.2, which publishes `./token-hover`, the rotated `guide-viewer-manifest` pin, and the `config-types` hover fields (core 0.25.2 is the first core publish since 0.25.1 even though those changes landed over several releases; the package smoke now diffs the UI's core imports against the registry so an unpublished core subpath fails preflight instead of the consumer). UI 0.38.1 also aligns `@codemirror/state` to `^6.7.2` beside `@codemirror/view ^6.43.10`, so a consumer can no longer resolve two state copies. UI 0.38.0 also renders a GitHub alert's bold-only first body line as its title on the icon row (an emoji on that line becomes the icon; `<!-- icon: name -->` is stripped and resolved through the new `alertIconRenderer` seam, null by default; grammar in `utils/alertTitle`, importable by a host editor so it writes the bytes the reader parses; a fenced code block inside an alert body still renders as text, deferred because nesting a `CodeBlock` inside a block interacts with the positional annotation anchors and needs its own design). UI 0.38.0 carries the whole unified decision-control stack: the internal primitives (`DecisionControl`, `utils/decisionSpec`, `hooks/useDismissablePopover` — not host-supported surface, see the unsupported list; `useDismissablePopover` also replaced the hand-rolled dismissal inside `ActionMenu`/`ApproveDropdown`, both likewise unsupported) plus one blessed-barrel addition: `decisionControlShortcuts` on `@plannotator/ui/shortcuts` (pure scope data, fetch-free, same contract as the other scopes). The removal of `ToolbarButtons`' platform-mode `muted` prop is internal — `ToolbarButtons` is not host-supported surface. UI 0.37.0 added the Viewer-owned document-header seam (a new public API, hence the minor bump; 0.36.1 was reserved for it but never published) while retaining the `hideQuickLabel` and `StickyHeaderLane` seams from the 0.35.x and 0.36.0 releases; core 0.25.1 publishes the `annotation-threads` subpath already used by `AnnotationPanel` and `utils/parser`, and UI pins that corrected core exactly.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
1
|
+
import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
2
|
import { diagramTargetText, type DiagramKind } from '@plannotator/core/diagram-anchor';
|
|
3
3
|
import type { AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
|
|
4
4
|
import { AnnotationType, type Annotation, type Block } from '../types';
|
|
@@ -6,9 +6,9 @@ import type { DiagramTheme } from '../utils/diagram-render';
|
|
|
6
6
|
import { getIdentity } from '../utils/identity';
|
|
7
7
|
import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
|
|
8
8
|
import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
|
|
9
|
-
import {
|
|
10
|
-
import { DiagramPopout } from './diagram/DiagramPopout';
|
|
9
|
+
import { DiagramPending, DiagramInlineSource } from './diagram/DiagramPending';
|
|
11
10
|
import { DiagramViewer } from './diagram/DiagramViewer';
|
|
11
|
+
import { svgContentSize } from './diagram/svgContentSize';
|
|
12
12
|
import type { DiagramComment, DiagramCreateComment } from './diagram/useDiagramComments';
|
|
13
13
|
import type { DiagramRenderState } from './diagram/useDiagramRender';
|
|
14
14
|
import { useTheme } from './ThemeProvider';
|
|
@@ -35,6 +35,11 @@ const RETRY_EPOCHS: Record<DiagramKind, ReturnType<typeof createRuntimeRetryEpoc
|
|
|
35
35
|
|
|
36
36
|
const LABELS: Record<DiagramKind, string> = { mermaid: 'Mermaid', graphviz: 'Graphviz' };
|
|
37
37
|
|
|
38
|
+
/** The full-size popout is only ever reached by pressing Expand, so it loads
|
|
39
|
+
* then and not with the document. Its fallback is null: the overlay simply
|
|
40
|
+
* has not opened yet, and nothing in the document flow moves. */
|
|
41
|
+
const DiagramPopout = lazy(async () => ({ default: (await import('./diagram/DiagramPopout')).DiagramPopout }));
|
|
42
|
+
|
|
38
43
|
/** The inline box height from the diagram's aspect at a nominal width, so
|
|
39
44
|
* a wide flowchart is not letterboxed in a tall box and a tall state
|
|
40
45
|
* diagram is not squeezed into a short one; clamped so neither extreme
|
|
@@ -260,21 +265,7 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
|
|
|
260
265
|
// then the render itself): the source stays readable under a quiet
|
|
261
266
|
// status line. A re-render for a theme change keeps the previous SVG,
|
|
262
267
|
// so this shows only before the first diagram lands.
|
|
263
|
-
return
|
|
264
|
-
<>
|
|
265
|
-
<div
|
|
266
|
-
role="status"
|
|
267
|
-
aria-live="polite"
|
|
268
|
-
data-diagram-pending=""
|
|
269
|
-
{...(kind === 'mermaid' ? { 'data-mermaid-pending': '' } : {})}
|
|
270
|
-
className="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground"
|
|
271
|
-
>
|
|
272
|
-
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/70" aria-hidden="true" />
|
|
273
|
-
Rendering diagram…
|
|
274
|
-
</div>
|
|
275
|
-
<InlineSource block={block} kind={kind} />
|
|
276
|
-
</>
|
|
277
|
-
);
|
|
268
|
+
return <DiagramPending block={block} kind={kind} />;
|
|
278
269
|
},
|
|
279
270
|
[block, kind, label],
|
|
280
271
|
);
|
|
@@ -337,7 +328,7 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
|
|
|
337
328
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
338
329
|
</svg>
|
|
339
330
|
</button>
|
|
340
|
-
<
|
|
331
|
+
<DiagramInlineSource block={block} kind={kind} />
|
|
341
332
|
</div>
|
|
342
333
|
) : (
|
|
343
334
|
<div
|
|
@@ -356,21 +347,17 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
|
|
|
356
347
|
)}
|
|
357
348
|
</div>
|
|
358
349
|
{isExpanded && svgReady && typeof document !== 'undefined' && (
|
|
359
|
-
<
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
350
|
+
<Suspense fallback={null}>
|
|
351
|
+
<DiagramPopout
|
|
352
|
+
{...viewerProps}
|
|
353
|
+
open
|
|
354
|
+
onClose={() => setIsExpanded(false)}
|
|
355
|
+
title={`${label} diagram`}
|
|
356
|
+
renderId={`${kind}-${block.id}-popout`}
|
|
357
|
+
dataAttributes={{ 'data-block-id': block.id }}
|
|
358
|
+
/>
|
|
359
|
+
</Suspense>
|
|
367
360
|
)}
|
|
368
361
|
</>
|
|
369
362
|
);
|
|
370
363
|
};
|
|
371
|
-
|
|
372
|
-
const InlineSource: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => (
|
|
373
|
-
<pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30 p-4">
|
|
374
|
-
<code className={`pn-code font-mono language-${block.language?.trim().split(/\s+/, 1)[0] ?? kind}`}>{block.content}</code>
|
|
375
|
-
</pre>
|
|
376
|
-
);
|
package/components/Viewer.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateId } from '../utils/generateId';
|
|
2
|
-
import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
|
|
2
|
+
import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback, lazy, Suspense } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
4
|
import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
|
|
5
5
|
import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
|
|
@@ -18,6 +18,20 @@ import { useValidatedCodePaths } from '../hooks/useValidatedCodePaths';
|
|
|
18
18
|
import { AnnotationToolbar } from './AnnotationToolbar';
|
|
19
19
|
import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The diagram engine — the renderer slot, the canvas, the comment overlay,
|
|
23
|
+
* the popout, and (through the viewer) CodeMirror — is loaded by the first
|
|
24
|
+
* diagram fence in the document and by nothing else. A markdown document
|
|
25
|
+
* with no diagram never reaches for it; a chunked host that statically
|
|
26
|
+
* imports this Viewer pays none of it on a plain document read.
|
|
27
|
+
*
|
|
28
|
+
* The Suspense fallback is the SAME pending state the block itself shows
|
|
29
|
+
* while its engine loads (`DiagramPending`), inside the same boxes, so the
|
|
30
|
+
* source fence paints once and the two waits read as one.
|
|
31
|
+
*/
|
|
32
|
+
const MermaidBlock = lazy(async () => ({ default: (await import('./MermaidBlock')).MermaidBlock }));
|
|
33
|
+
const GraphvizBlock = lazy(async () => ({ default: (await import('./GraphvizBlock')).GraphvizBlock }));
|
|
34
|
+
|
|
21
35
|
// Debug error boundary to catch silent toolbar crashes
|
|
22
36
|
class ToolbarErrorBoundary extends React.Component<
|
|
23
37
|
{ children: React.ReactNode },
|
|
@@ -41,8 +55,7 @@ import { TaterSpriteSitting } from './TaterSpriteSitting';
|
|
|
41
55
|
import { AttachmentsButton } from './AttachmentsButton';
|
|
42
56
|
import { MessagesIcon } from './icons/MessagesIcon';
|
|
43
57
|
import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
|
|
44
|
-
import {
|
|
45
|
-
import { MermaidBlock } from './MermaidBlock';
|
|
58
|
+
import { DiagramBlockPending } from './diagram/DiagramPending';
|
|
46
59
|
import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
|
|
47
60
|
import { getIdentity } from '../utils/identity';
|
|
48
61
|
import { type QuickLabel } from '../utils/quickLabels';
|
|
@@ -1168,27 +1181,29 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1168
1181
|
);
|
|
1169
1182
|
})()
|
|
1170
1183
|
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
|
|
1171
|
-
<
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1184
|
+
<Suspense key={group.block.id} fallback={<DiagramBlockPending block={group.block} kind="mermaid" />}>
|
|
1185
|
+
<MermaidBlock
|
|
1186
|
+
block={group.block}
|
|
1187
|
+
annotations={annotations}
|
|
1188
|
+
selectedAnnotationId={selectedAnnotationId}
|
|
1189
|
+
onSelectAnnotation={onSelectAnnotation}
|
|
1190
|
+
onAddAnnotation={readOnly ? undefined : onAddAnnotation}
|
|
1191
|
+
readOnly={readOnly}
|
|
1192
|
+
onRestoreReport={onRestoreReport}
|
|
1193
|
+
/>
|
|
1194
|
+
</Suspense>
|
|
1181
1195
|
) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
|
|
1182
|
-
<
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1196
|
+
<Suspense key={group.block.id} fallback={<DiagramBlockPending block={group.block} kind="graphviz" />}>
|
|
1197
|
+
<GraphvizBlock
|
|
1198
|
+
block={group.block}
|
|
1199
|
+
annotations={annotations}
|
|
1200
|
+
selectedAnnotationId={selectedAnnotationId}
|
|
1201
|
+
onSelectAnnotation={onSelectAnnotation}
|
|
1202
|
+
onAddAnnotation={readOnly ? undefined : onAddAnnotation}
|
|
1203
|
+
readOnly={readOnly}
|
|
1204
|
+
onRestoreReport={onRestoreReport}
|
|
1205
|
+
/>
|
|
1206
|
+
</Suspense>
|
|
1192
1207
|
) : group.block.type === 'table' ? (
|
|
1193
1208
|
<TableBlock
|
|
1194
1209
|
key={group.block.id}
|
|
@@ -14,6 +14,8 @@ import { cn } from '../../lib/utils';
|
|
|
14
14
|
import { diagramHitSource } from '../../utils/diagram-render';
|
|
15
15
|
import { isMac, isModKeyHeld } from '../../utils/platform';
|
|
16
16
|
import { Button } from '../ui/button';
|
|
17
|
+
import { isDiagramControlEvent } from './diagramControls';
|
|
18
|
+
import { svgContentSize } from './svgContentSize';
|
|
17
19
|
import {
|
|
18
20
|
DRAG_THRESHOLD_PX,
|
|
19
21
|
TOUCH_DRAG_THRESHOLD_PX,
|
|
@@ -50,6 +52,13 @@ import {
|
|
|
50
52
|
* `<a href>` that the render slot's sanitizer strips, so the canvas owns
|
|
51
53
|
* every click and there is no armed switch.
|
|
52
54
|
*
|
|
55
|
+
* Chrome painted OVER the canvas (the zoom strip, the composer, the source
|
|
56
|
+
* pane, the popout's header) is never a diagram target: a pointer event
|
|
57
|
+
* whose composed path contains a control resolves nothing, opens no
|
|
58
|
+
* composer and starts no pan (`diagramControls.ts`). Without that the
|
|
59
|
+
* `elementsFromPoint` walk stepped past the control to the part behind it,
|
|
60
|
+
* so pressing Zoom out over a node opened the composer on that node.
|
|
61
|
+
*
|
|
53
62
|
* The render slot hands over a sanitized svg NODE, not markup: the wrapper
|
|
54
63
|
* mounts it with `replaceChildren` once per render, so no html string ever
|
|
55
64
|
* crosses into the app DOM here. The overlay (a sibling of the wrapper,
|
|
@@ -57,40 +66,10 @@ import {
|
|
|
57
66
|
* viewport so rings reproject on every change.
|
|
58
67
|
*/
|
|
59
68
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** The svg's intrinsic size, from its viewBox (Mermaid and Graphviz always
|
|
67
|
-
* write one), else its `width` / `height` attributes (a `pt` or `px` suffix
|
|
68
|
-
* is accepted, as Graphviz writes them). */
|
|
69
|
-
export function svgContentSize(svg: SVGSVGElement): ContentSize | null {
|
|
70
|
-
const viewBox = svg.getAttribute('viewBox');
|
|
71
|
-
if (viewBox !== null) {
|
|
72
|
-
const parts = viewBox
|
|
73
|
-
.trim()
|
|
74
|
-
.split(/[\s,]+/u)
|
|
75
|
-
.map(Number);
|
|
76
|
-
const width = parts[2];
|
|
77
|
-
const height = parts[3];
|
|
78
|
-
if (
|
|
79
|
-
parts.length === 4 &&
|
|
80
|
-
width !== undefined &&
|
|
81
|
-
height !== undefined &&
|
|
82
|
-
Number.isFinite(width) &&
|
|
83
|
-
Number.isFinite(height) &&
|
|
84
|
-
width > 0 &&
|
|
85
|
-
height > 0
|
|
86
|
-
) {
|
|
87
|
-
return { width, height };
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
const width = svgLength(svg.getAttribute('width'));
|
|
91
|
-
const height = svgLength(svg.getAttribute('height'));
|
|
92
|
-
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? { width, height } : null;
|
|
93
|
-
}
|
|
69
|
+
// `svgContentSize` is the canvas's, but the document's fence block needs it
|
|
70
|
+
// without the canvas: it lives in its own dependency-free module and is
|
|
71
|
+
// re-exported here so every published path keeps resolving.
|
|
72
|
+
export { svgContentSize } from './svgContentSize';
|
|
94
73
|
|
|
95
74
|
/** One arrow-key press pans this far (the diagram moves WITH the arrow, as
|
|
96
75
|
* a scroll would); Shift multiplies it by five. */
|
|
@@ -200,6 +179,10 @@ export function DiagramCanvas({
|
|
|
200
179
|
(event: ReactPointerEvent): Element | null => {
|
|
201
180
|
const wrapper = wrapperRef.current;
|
|
202
181
|
if (wrapper === null) return null;
|
|
182
|
+
// The zoom strip and the composer are painted OVER the canvas and are
|
|
183
|
+
// not in the svg, so the walk below would step past them to whatever
|
|
184
|
+
// part sits underneath. A press on chrome addresses no part.
|
|
185
|
+
if (isDiagramControlEvent(event)) return null;
|
|
203
186
|
// Everything under the pointer, topmost first. Without a layout
|
|
204
187
|
// engine (happy-dom) the event's own target is all there is.
|
|
205
188
|
const doc = wrapper.ownerDocument;
|
|
@@ -259,6 +242,11 @@ export function DiagramCanvas({
|
|
|
259
242
|
|
|
260
243
|
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
|
261
244
|
if (event.button !== 0) return;
|
|
245
|
+
// Pressing a control is not the start of a pan either.
|
|
246
|
+
if (isDiagramControlEvent(event)) {
|
|
247
|
+
pressRef.current = null;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
262
250
|
pressRef.current = {
|
|
263
251
|
id: event.pointerId,
|
|
264
252
|
x: event.clientX,
|
|
@@ -302,6 +290,13 @@ export function DiagramCanvas({
|
|
|
302
290
|
const press = pressRef.current;
|
|
303
291
|
if (press === null || press.id !== event.pointerId) return;
|
|
304
292
|
pressRef.current = null;
|
|
293
|
+
// Released over a control (the press began on the canvas): not a click
|
|
294
|
+
// on the diagram, and `targetUnder` answering null would otherwise
|
|
295
|
+
// read as "comment on the whole diagram".
|
|
296
|
+
if (isDiagramControlEvent(event)) {
|
|
297
|
+
if (press.panning) setPanning(false);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
305
300
|
if (press.panning) {
|
|
306
301
|
setPanning(false);
|
|
307
302
|
const target = event.currentTarget;
|
|
@@ -395,6 +390,7 @@ export function DiagramCanvas({
|
|
|
395
390
|
// comments). On a narrow screen the strip takes the left edge so it
|
|
396
391
|
// never stacks under a host's own bottom-right controls.
|
|
397
392
|
data-print-hide=""
|
|
393
|
+
data-diagram-control=""
|
|
398
394
|
data-diagram-zoom-strip=""
|
|
399
395
|
className="absolute bottom-3 right-3 z-10 flex items-center gap-0.5 rounded-md border border-border bg-card/85 p-0.5 backdrop-blur max-md:bottom-4 max-md:left-4 max-md:right-auto"
|
|
400
396
|
role="group"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { DiagramKind } from '@plannotator/core/diagram-anchor';
|
|
3
|
+
import type { Block } from '../../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The diagram fence before there is a diagram: the source under a quiet
|
|
7
|
+
* "Rendering diagram…" status.
|
|
8
|
+
*
|
|
9
|
+
* It lives in its own file, importing nothing but React and two types,
|
|
10
|
+
* because it is the ONE pending state two callers must agree on. The block
|
|
11
|
+
* shows it while the engine chunk and the first render are in flight
|
|
12
|
+
* (`DiagramBlock`'s `renderFallback`), and the document shows it as the
|
|
13
|
+
* Suspense fallback while the diagram block's own chunk loads
|
|
14
|
+
* (`Viewer`) — a chunked host reaches the second one first. Same markup in
|
|
15
|
+
* both places, so a document with a diagram paints the source fence once and
|
|
16
|
+
* never flashes or jumps between the two.
|
|
17
|
+
*
|
|
18
|
+
* Keep it dependency-free: anything imported here lands in the closure of
|
|
19
|
+
* every document read, diagram or not.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The fence's own text, as the document would show it unrendered. */
|
|
23
|
+
export const DiagramInlineSource: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => (
|
|
24
|
+
<pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30 p-4">
|
|
25
|
+
<code className={`pn-code font-mono language-${block.language?.trim().split(/\s+/, 1)[0] ?? kind}`}>{block.content}</code>
|
|
26
|
+
</pre>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/** The status line plus the source. `data-mermaid-pending` is the hook the
|
|
30
|
+
* Mermaid tests wait on and is kept exactly as it was. */
|
|
31
|
+
export const DiagramPending: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => (
|
|
32
|
+
<>
|
|
33
|
+
<div
|
|
34
|
+
role="status"
|
|
35
|
+
aria-live="polite"
|
|
36
|
+
data-diagram-pending=""
|
|
37
|
+
{...(kind === 'mermaid' ? { 'data-mermaid-pending': '' } : {})}
|
|
38
|
+
className="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground"
|
|
39
|
+
>
|
|
40
|
+
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/70" aria-hidden="true" />
|
|
41
|
+
Rendering diagram…
|
|
42
|
+
</div>
|
|
43
|
+
<DiagramInlineSource block={block} kind={kind} />
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The same pending state inside the boxes the block itself renders it in
|
|
49
|
+
* (the block wrapper, the inline host, the viewer's fallback slot), so the
|
|
50
|
+
* Suspense fallback occupies the same space the block will.
|
|
51
|
+
*/
|
|
52
|
+
export const DiagramBlockPending: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => (
|
|
53
|
+
<div className="annotation-exclude my-5 group relative" data-block-id={block.id} data-pinpoint-ignore="" data-diagram-block={kind}>
|
|
54
|
+
<div data-diagram-inline="">
|
|
55
|
+
<div data-diagram-fallback="" className="h-full min-h-0 w-full">
|
|
56
|
+
<DiagramPending block={block} kind={kind} />
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
@@ -42,7 +42,9 @@ export function DiagramPopout({
|
|
|
42
42
|
className="h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] max-w-none"
|
|
43
43
|
dataAttributes={{ 'data-diagram-popout': '', ...dataAttributes }}
|
|
44
44
|
>
|
|
45
|
-
|
|
45
|
+
{/* The popout's own chrome: a press here addresses no diagram part
|
|
46
|
+
(see diagram/diagramControls). */}
|
|
47
|
+
<div data-diagram-control="" data-diagram-popout-chrome="" className="flex h-9 shrink-0 items-center gap-2 border-b border-border pl-4 pr-12">
|
|
46
48
|
<span className="truncate text-xs font-medium text-foreground">{title}</span>
|
|
47
49
|
<span className="flex-1" />
|
|
48
50
|
{hasPane && (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
1
|
+
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
2
|
import type { DiagramKind } from '@plannotator/core/diagram-anchor';
|
|
3
3
|
import { cn } from '../../lib/utils';
|
|
4
4
|
import { diagramFamilyOf } from '../../utils/diagram-anchor';
|
|
@@ -6,7 +6,6 @@ import { diagramFinder, type DiagramTheme } from '../../utils/diagram-render';
|
|
|
6
6
|
import { DiagramCanvas, type DiagramCanvasHandle, type DiagramEscapeOutcome } from './DiagramCanvas';
|
|
7
7
|
import { DiagramComposer } from './DiagramComposer';
|
|
8
8
|
import { DiagramOverlay } from './DiagramOverlay';
|
|
9
|
-
import { DiagramSourcePane } from './DiagramSourcePane';
|
|
10
9
|
import { useDiagramComments, type DiagramComment, type DiagramCreateComment } from './useDiagramComments';
|
|
11
10
|
import { useDiagramRender, type DiagramRenderState } from './useDiagramRender';
|
|
12
11
|
import { useDiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft';
|
|
@@ -21,6 +20,19 @@ import { useDiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft'
|
|
|
21
20
|
* (`components/DiagramBlock`) and again at full size in the popout; a host
|
|
22
21
|
* with its own document store renders it wherever a diagram lives.
|
|
23
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
|
+
|
|
24
36
|
export interface DiagramViewerProps {
|
|
25
37
|
readonly kind: DiagramKind;
|
|
26
38
|
/** The diagram text. With `onSave` this is the saved baseline the pane's
|
|
@@ -206,12 +218,9 @@ export function DiagramViewer({
|
|
|
206
218
|
column and the pane stays stacked UNDER the canvas, which
|
|
207
219
|
`order-last` keeps while `md:order-first` puts it left from `md`. */}
|
|
208
220
|
{hasPane && sourceOpen && (
|
|
209
|
-
<
|
|
210
|
-
draft={draft}
|
|
211
|
-
|
|
212
|
-
markedLines={markedLines}
|
|
213
|
-
className="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"
|
|
214
|
-
/>
|
|
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>
|
|
215
224
|
)}
|
|
216
225
|
<div className="relative min-h-0 min-w-0 flex-1">
|
|
217
226
|
{showFallback ? (
|
|
@@ -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
|
+
}
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export { DiagramViewer, type DiagramViewerProps } from './DiagramViewer';
|
|
10
10
|
export { DiagramPopout } from './DiagramPopout';
|
|
11
|
-
export {
|
|
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';
|
|
12
15
|
export { DiagramOverlay } from './DiagramOverlay';
|
|
13
16
|
export { DiagramComposer } from './DiagramComposer';
|
|
14
17
|
export { DiagramSourcePane } from './DiagramSourcePane';
|
|
@@ -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
|
+
}
|