@plannotator/ui 0.39.0 → 0.41.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/HANDOFF.md +140 -10
- package/README.md +9 -5
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +17 -630
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +67 -3
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +121 -9
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +133 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +469 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +6 -4
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +5 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +36 -7
- package/utils/terminalToolsAnnouncement.ts +76 -0
- package/components/mermaidSvg.ts +0 -33
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { diagramTargetText, type DiagramKind } from '@plannotator/core/diagram-anchor';
|
|
3
|
+
import type { AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
|
|
4
|
+
import { AnnotationType, type Annotation, type Block } from '../types';
|
|
5
|
+
import type { DiagramTheme } from '../utils/diagram-render';
|
|
6
|
+
import { getIdentity } from '../utils/identity';
|
|
7
|
+
import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
|
|
8
|
+
import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
|
|
9
|
+
import { svgContentSize } from './diagram/DiagramCanvas';
|
|
10
|
+
import { DiagramPopout } from './diagram/DiagramPopout';
|
|
11
|
+
import { DiagramViewer } from './diagram/DiagramViewer';
|
|
12
|
+
import type { DiagramComment, DiagramCreateComment } from './diagram/useDiagramComments';
|
|
13
|
+
import type { DiagramRenderState } from './diagram/useDiagramRender';
|
|
14
|
+
import { useTheme } from './ThemeProvider';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A diagram fence in the document: the fence's language picks the engine
|
|
18
|
+
* (`MermaidBlock`, `GraphvizBlock`), and everything else is one code path
|
|
19
|
+
* through the renderer slot and `DiagramViewer` — the canvas with zoom, pan
|
|
20
|
+
* and fit, the comment overlay, and the same viewer at full size in the
|
|
21
|
+
* popout. What this block owns is the document side: the source fence under
|
|
22
|
+
* a status until the first render lands (never the error panel as a
|
|
23
|
+
* placeholder), the error panel with the source and a Retry for a failed
|
|
24
|
+
* engine load, the "Show source" toggle, and the bridge between a comment
|
|
25
|
+
* composed on a part and an `Annotation` on the document (`diagramAnchor`
|
|
26
|
+
* plus the fence's document lines), so it lists in the rail beside text
|
|
27
|
+
* comments, exports, drafts and restores after a reload.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** One Retry re-attempts every block whose engine import failed (see utils/runtimeRetry). */
|
|
31
|
+
const RETRY_EPOCHS: Record<DiagramKind, ReturnType<typeof createRuntimeRetryEpoch>> = {
|
|
32
|
+
mermaid: createRuntimeRetryEpoch(),
|
|
33
|
+
graphviz: createRuntimeRetryEpoch(),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const LABELS: Record<DiagramKind, string> = { mermaid: 'Mermaid', graphviz: 'Graphviz' };
|
|
37
|
+
|
|
38
|
+
/** The inline box height from the diagram's aspect at a nominal width, so
|
|
39
|
+
* a wide flowchart is not letterboxed in a tall box and a tall state
|
|
40
|
+
* diagram is not squeezed into a short one; clamped so neither extreme
|
|
41
|
+
* takes the page. The canvas fits the diagram inside whatever it gets. */
|
|
42
|
+
const NOMINAL_WIDTH_PX = 800;
|
|
43
|
+
const MIN_HEIGHT_PX = 16 * 16;
|
|
44
|
+
const MAX_HEIGHT_PX = 36 * 16;
|
|
45
|
+
|
|
46
|
+
function inlineHeight(state: DiagramRenderState | null): string {
|
|
47
|
+
const size = state?.svgNode ? svgContentSize(state.svgNode) : null;
|
|
48
|
+
if (size === null) return 'min(65vh, 24rem)';
|
|
49
|
+
const px = Math.round(size.height * (NOMINAL_WIDTH_PX / size.width)) + 48;
|
|
50
|
+
return `min(65vh, ${Math.min(MAX_HEIGHT_PX, Math.max(MIN_HEIGHT_PX, px))}px)`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function newAnnotationId(): string {
|
|
54
|
+
const c = globalThis.crypto;
|
|
55
|
+
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
|
|
56
|
+
return `ann-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DiagramBlockProps {
|
|
60
|
+
block: Block;
|
|
61
|
+
/** The document's annotations; the block keeps the ones anchored on this
|
|
62
|
+
* fence (`diagramAnchor` + its `blockId`). */
|
|
63
|
+
annotations?: readonly Annotation[];
|
|
64
|
+
selectedAnnotationId?: string | null;
|
|
65
|
+
onSelectAnnotation?: (id: string | null) => void;
|
|
66
|
+
/** A comment composed on a part becomes an annotation on the document. */
|
|
67
|
+
onAddAnnotation?: (annotation: Annotation) => void;
|
|
68
|
+
readOnly?: boolean;
|
|
69
|
+
/** The block's restore verdict after every render: its own comments as
|
|
70
|
+
* `attempted`, the ones whose part is gone as `unanchored`, so the host's
|
|
71
|
+
* panel shows the same "Unanchored" chip a text comment gets. */
|
|
72
|
+
onRestoreReport?: (report: AnnotationRestoreReport) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const NO_ANNOTATIONS: readonly Annotation[] = [];
|
|
76
|
+
|
|
77
|
+
export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> = ({
|
|
78
|
+
kind,
|
|
79
|
+
block,
|
|
80
|
+
annotations = NO_ANNOTATIONS,
|
|
81
|
+
selectedAnnotationId = null,
|
|
82
|
+
onSelectAnnotation,
|
|
83
|
+
onAddAnnotation,
|
|
84
|
+
readOnly = false,
|
|
85
|
+
onRestoreReport,
|
|
86
|
+
}) => {
|
|
87
|
+
const label = LABELS[kind];
|
|
88
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
89
|
+
const [showSource, setShowSource] = useState(false);
|
|
90
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
91
|
+
const [retryToken, setRetryToken] = useState(0);
|
|
92
|
+
const [renderState, setRenderState] = useState<DiagramRenderState | null>(null);
|
|
93
|
+
|
|
94
|
+
// The (palette, mode) the diagram must follow: the same resolution the
|
|
95
|
+
// code fences use (see useFenceTheme). Outside a ThemeProvider the default
|
|
96
|
+
// context yields the Plannotator dark pair, and with no theme tokens on the
|
|
97
|
+
// document the renderer keeps the static config, so a host without the
|
|
98
|
+
// provider renders exactly as before. A key change re-runs the render,
|
|
99
|
+
// which is what re-themes an already rendered diagram.
|
|
100
|
+
const { colorTheme, resolvedMode } = useTheme();
|
|
101
|
+
const theme = useMemo<DiagramTheme>(
|
|
102
|
+
() => ({ colorTheme, mode: resolvedMode === 'light' ? 'light' : 'dark' }),
|
|
103
|
+
[colorTheme, resolvedMode],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// A sibling's Retry re-attempts this block too, but only while its own
|
|
107
|
+
// failure was the shared engine import; a healthy block or a diagram
|
|
108
|
+
// syntax error is left alone.
|
|
109
|
+
const runtimeUnavailableRef = useRef(false);
|
|
110
|
+
runtimeUnavailableRef.current = renderState?.error?.runtimeUnavailable ?? false;
|
|
111
|
+
useEffect(
|
|
112
|
+
() =>
|
|
113
|
+
RETRY_EPOCHS[kind].subscribe(() => {
|
|
114
|
+
if (!runtimeUnavailableRef.current) return;
|
|
115
|
+
setRetryToken((token) => token + 1);
|
|
116
|
+
}),
|
|
117
|
+
[kind],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
setIsExpanded(false);
|
|
122
|
+
}, [block.content]);
|
|
123
|
+
|
|
124
|
+
// Comments that name no diagram block of the document (an external POST
|
|
125
|
+
// carries `blockId: "external"`; a deleted fence leaves a dead id) belong
|
|
126
|
+
// to whichever diagram resolves their anchor first: see anchorClaims.
|
|
127
|
+
const sharedClaims = useContext(DiagramAnchorClaimsContext);
|
|
128
|
+
const ownClaims = useMemo(() => new DiagramAnchorClaims([block.id]), [block.id]);
|
|
129
|
+
const claims = sharedClaims ?? ownClaims;
|
|
130
|
+
const claimsVersion = useSyncExternalStore(claims.subscribe, claims.getVersion, claims.getVersion);
|
|
131
|
+
|
|
132
|
+
const ownAnnotations = useMemo(
|
|
133
|
+
() => annotations.filter((ann) => ann.diagramAnchor !== undefined && ann.blockId === block.id),
|
|
134
|
+
[annotations, block.id],
|
|
135
|
+
);
|
|
136
|
+
const unownedAnnotations = useMemo(
|
|
137
|
+
() =>
|
|
138
|
+
annotations.filter(
|
|
139
|
+
(ann) =>
|
|
140
|
+
ann.diagramAnchor !== undefined &&
|
|
141
|
+
ann.blockId !== block.id &&
|
|
142
|
+
!claims.blockIds.includes(ann.blockId) &&
|
|
143
|
+
// A Graphviz anchor names a DOT part; it is never a Mermaid one.
|
|
144
|
+
(ann.diagramAnchor.family === 'graphviz') === (kind === 'graphviz'),
|
|
145
|
+
),
|
|
146
|
+
[annotations, block.id, claims, kind],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// The comments on this fence, in document order (the badge numbers): its
|
|
150
|
+
// own, then the unowned ones it shows or is still trying.
|
|
151
|
+
const comments = useMemo<readonly DiagramComment[]>(() => {
|
|
152
|
+
void claimsVersion;
|
|
153
|
+
const tried = unownedAnnotations.filter((ann) => {
|
|
154
|
+
const owner = claims.owner(ann.id);
|
|
155
|
+
return owner === undefined || owner === block.id;
|
|
156
|
+
});
|
|
157
|
+
return [...ownAnnotations, ...tried].map((ann) => ({
|
|
158
|
+
id: ann.id,
|
|
159
|
+
anchor: ann.diagramAnchor!,
|
|
160
|
+
text: ann.text ?? '',
|
|
161
|
+
author: ann.author,
|
|
162
|
+
}));
|
|
163
|
+
}, [block.id, claims, claimsVersion, ownAnnotations, unownedAnnotations]);
|
|
164
|
+
|
|
165
|
+
const selectedCommentId = useMemo(
|
|
166
|
+
() => (selectedAnnotationId !== null && comments.some((c) => c.id === selectedAnnotationId) ? selectedAnnotationId : null),
|
|
167
|
+
[comments, selectedAnnotationId],
|
|
168
|
+
);
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (selectedCommentId === null) return;
|
|
171
|
+
rootRef.current?.scrollIntoView?.({ block: 'nearest' });
|
|
172
|
+
}, [selectedCommentId]);
|
|
173
|
+
|
|
174
|
+
const handleCreate = useMemo<DiagramCreateComment | undefined>(() => {
|
|
175
|
+
if (readOnly || onAddAnnotation === undefined) return undefined;
|
|
176
|
+
return (anchor, text) => {
|
|
177
|
+
onAddAnnotation({
|
|
178
|
+
id: newAnnotationId(),
|
|
179
|
+
blockId: block.id,
|
|
180
|
+
startOffset: 0,
|
|
181
|
+
endOffset: 0,
|
|
182
|
+
type: AnnotationType.COMMENT,
|
|
183
|
+
text,
|
|
184
|
+
originalText: diagramTargetText(anchor),
|
|
185
|
+
createdA: Date.now(),
|
|
186
|
+
author: getIdentity(),
|
|
187
|
+
diagramAnchor: anchor,
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
}, [block.id, onAddAnnotation, readOnly]);
|
|
191
|
+
|
|
192
|
+
const [resolution, setResolution] = useState<ReadonlyMap<string, boolean> | null>(null);
|
|
193
|
+
const svgReady = renderState?.svgNode != null;
|
|
194
|
+
const renderFailed = renderState !== null && renderState.error !== null && !svgReady;
|
|
195
|
+
|
|
196
|
+
// This block's verdicts for the unowned comments. A diagram that failed
|
|
197
|
+
// to render resolves nothing, and must say so or the verdict stays
|
|
198
|
+
// pending forever.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (unownedAnnotations.length === 0) return;
|
|
201
|
+
const results = new Map<string, boolean>();
|
|
202
|
+
for (const ann of unownedAnnotations) {
|
|
203
|
+
if (renderFailed) results.set(ann.id, false);
|
|
204
|
+
else if (resolution?.has(ann.id)) results.set(ann.id, resolution.get(ann.id) === true);
|
|
205
|
+
}
|
|
206
|
+
if (results.size > 0) claims.report(block.id, results);
|
|
207
|
+
}, [block.id, claims, renderFailed, resolution, unownedAnnotations]);
|
|
208
|
+
|
|
209
|
+
// The restore verdict the panel's "Unanchored" chip runs on: this block's
|
|
210
|
+
// own comments, the unowned ones it shows, and (from the first diagram
|
|
211
|
+
// block only, so it is said once) the unowned ones nobody resolved.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (onRestoreReport === undefined || (resolution === null && !renderFailed)) return;
|
|
214
|
+
const attempted: string[] = [];
|
|
215
|
+
const unanchored: string[] = [];
|
|
216
|
+
for (const ann of ownAnnotations) {
|
|
217
|
+
attempted.push(ann.id);
|
|
218
|
+
if (renderFailed || resolution?.get(ann.id) === false) unanchored.push(ann.id);
|
|
219
|
+
}
|
|
220
|
+
for (const ann of unownedAnnotations) {
|
|
221
|
+
const owner = claims.owner(ann.id);
|
|
222
|
+
if (owner === block.id) attempted.push(ann.id);
|
|
223
|
+
else if (owner === null && claims.blockIds[0] === block.id) {
|
|
224
|
+
attempted.push(ann.id);
|
|
225
|
+
unanchored.push(ann.id);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (attempted.length > 0) onRestoreReport({ attempted, unanchored });
|
|
229
|
+
}, [block.id, claims, claimsVersion, onRestoreReport, ownAnnotations, renderFailed, resolution, unownedAnnotations]);
|
|
230
|
+
|
|
231
|
+
const renderFallback = useCallback(
|
|
232
|
+
(state: DiagramRenderState) => {
|
|
233
|
+
if (state.error !== null) {
|
|
234
|
+
return (
|
|
235
|
+
<div className="rounded-lg border border-destructive/30 bg-destructive/5 overflow-hidden">
|
|
236
|
+
<div className="px-3 py-2 bg-destructive/10 border-b border-destructive/20 flex items-center gap-2">
|
|
237
|
+
<svg className="w-4 h-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
238
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
239
|
+
</svg>
|
|
240
|
+
<span className="text-xs text-destructive font-medium">{label} Error</span>
|
|
241
|
+
{state.error.runtimeUnavailable && (
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
onClick={() => RETRY_EPOCHS[kind].bump()}
|
|
245
|
+
className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
|
|
246
|
+
title="Retry loading the diagram renderer"
|
|
247
|
+
>
|
|
248
|
+
Retry
|
|
249
|
+
</button>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
<pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{state.error.message}</pre>
|
|
253
|
+
<pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">
|
|
254
|
+
<code>{block.content}</code>
|
|
255
|
+
</pre>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
// First render still in flight (the engine import on the lazy path,
|
|
260
|
+
// then the render itself): the source stays readable under a quiet
|
|
261
|
+
// status line. A re-render for a theme change keeps the previous SVG,
|
|
262
|
+
// 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
|
+
);
|
|
278
|
+
},
|
|
279
|
+
[block, kind, label],
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const viewerProps = {
|
|
283
|
+
kind,
|
|
284
|
+
source: block.content,
|
|
285
|
+
theme,
|
|
286
|
+
comments,
|
|
287
|
+
onCreateComment: handleCreate,
|
|
288
|
+
selectedCommentId,
|
|
289
|
+
onSelectComment: onSelectAnnotation,
|
|
290
|
+
sourceLineOffset: block.startLine,
|
|
291
|
+
retryToken,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<>
|
|
296
|
+
{/* `annotation-exclude`: the text highlighter never enters a diagram,
|
|
297
|
+
so a text restore (a reply that lost its anchor, a quote that also
|
|
298
|
+
appears in a node label) can never wrap a <mark> inside the svg. */}
|
|
299
|
+
<div ref={rootRef} className="annotation-exclude my-5 group relative" data-block-id={block.id} data-pinpoint-ignore="" data-diagram-block={kind}>
|
|
300
|
+
{svgReady && !showSource && (
|
|
301
|
+
<div data-print-hide="" className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
onClick={() => setShowSource(true)}
|
|
305
|
+
className="p-1.5 rounded-md bg-muted/85 hover:bg-muted text-muted-foreground hover:text-foreground"
|
|
306
|
+
title="Show source"
|
|
307
|
+
aria-label="Show source"
|
|
308
|
+
>
|
|
309
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
310
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
|
311
|
+
</svg>
|
|
312
|
+
</button>
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
onClick={() => setIsExpanded(true)}
|
|
316
|
+
className="p-1.5 rounded-md bg-muted/85 hover:bg-muted text-muted-foreground hover:text-foreground"
|
|
317
|
+
title="Expand diagram"
|
|
318
|
+
aria-label="Expand diagram"
|
|
319
|
+
data-diagram-expand=""
|
|
320
|
+
>
|
|
321
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
322
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
|
323
|
+
</svg>
|
|
324
|
+
</button>
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
{showSource ? (
|
|
328
|
+
<div className="relative">
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
onClick={() => setShowSource(false)}
|
|
332
|
+
className="absolute top-2 right-2 z-10 p-1.5 rounded-md bg-muted/85 hover:bg-muted text-muted-foreground hover:text-foreground"
|
|
333
|
+
title="Show diagram"
|
|
334
|
+
aria-label="Show diagram"
|
|
335
|
+
>
|
|
336
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
337
|
+
<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
|
+
</svg>
|
|
339
|
+
</button>
|
|
340
|
+
<InlineSource block={block} kind={kind} />
|
|
341
|
+
</div>
|
|
342
|
+
) : (
|
|
343
|
+
<div
|
|
344
|
+
data-diagram-inline=""
|
|
345
|
+
className={svgReady ? 'rounded-xl bg-muted/30 border border-border/30 overflow-hidden' : undefined}
|
|
346
|
+
style={svgReady ? { height: inlineHeight(renderState) } : undefined}
|
|
347
|
+
>
|
|
348
|
+
<DiagramViewer
|
|
349
|
+
{...viewerProps}
|
|
350
|
+
renderId={`${kind}-${block.id}`}
|
|
351
|
+
onResolutionChange={setResolution}
|
|
352
|
+
onRenderState={setRenderState}
|
|
353
|
+
renderFallback={renderFallback}
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
{isExpanded && svgReady && typeof document !== 'undefined' && (
|
|
359
|
+
<DiagramPopout
|
|
360
|
+
{...viewerProps}
|
|
361
|
+
open
|
|
362
|
+
onClose={() => setIsExpanded(false)}
|
|
363
|
+
title={`${label} diagram`}
|
|
364
|
+
renderId={`${kind}-${block.id}-popout`}
|
|
365
|
+
dataAttributes={{ 'data-block-id': block.id }}
|
|
366
|
+
/>
|
|
367
|
+
)}
|
|
368
|
+
</>
|
|
369
|
+
);
|
|
370
|
+
};
|
|
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
|
+
);
|