@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.
Files changed (52) hide show
  1. package/HANDOFF.md +140 -10
  2. package/README.md +9 -5
  3. package/components/AnnotationPanel.tsx +244 -13
  4. package/components/CommentPopover.tsx +91 -2
  5. package/components/DiagramBlock.tsx +376 -0
  6. package/components/GraphvizBlock.tsx +22 -597
  7. package/components/HtmlSurfaceControls.tsx +188 -23
  8. package/components/ListMarker.tsx +10 -1
  9. package/components/MermaidBlock.tsx +17 -630
  10. package/components/TableOfContents.tsx +5 -1
  11. package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
  12. package/components/Viewer.tsx +67 -3
  13. package/components/blocks/AlertBlock.tsx +7 -2
  14. package/components/diagram/DiagramCanvas.tsx +431 -0
  15. package/components/diagram/DiagramComposer.tsx +135 -0
  16. package/components/diagram/DiagramOverlay.tsx +215 -0
  17. package/components/diagram/DiagramPopout.tsx +70 -0
  18. package/components/diagram/DiagramSourcePane.tsx +244 -0
  19. package/components/diagram/DiagramViewer.tsx +276 -0
  20. package/components/diagram/anchorClaims.ts +71 -0
  21. package/components/diagram/index.ts +36 -0
  22. package/components/diagram/useDiagramComments.ts +341 -0
  23. package/components/diagram/useDiagramRender.ts +91 -0
  24. package/components/diagram/useDiagramSourceDraft.ts +143 -0
  25. package/components/diagram/useDiagramViewport.ts +156 -0
  26. package/components/html-viewer/HtmlViewer.tsx +32 -0
  27. package/components/html-viewer/bridge-script.asset.js +121 -9
  28. package/components/html-viewer/bridge-script.lite.ts +1 -1
  29. package/components/html-viewer/bridge-script.ts +133 -9
  30. package/components/html-viewer/useHtmlAnnotation.ts +44 -151
  31. package/hooks/useAnnotationHighlighter.ts +469 -14
  32. package/hooks/useLinkedDoc.ts +100 -9
  33. package/package.json +6 -4
  34. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
  35. package/styles.css +1 -1
  36. package/theme.css +62 -0
  37. package/types.ts +5 -0
  38. package/utils/annotationScope.ts +159 -0
  39. package/utils/cssColor.ts +463 -0
  40. package/utils/diagram-anchor-graphviz.ts +143 -0
  41. package/utils/diagram-anchor.ts +401 -0
  42. package/utils/diagram-projection.ts +66 -0
  43. package/utils/diagram-render.ts +668 -0
  44. package/utils/graphviz.ts +93 -0
  45. package/utils/htmlChrome.ts +70 -5
  46. package/utils/htmlLinkNavigation.ts +196 -0
  47. package/utils/mermaid-eager.ts +13 -11
  48. package/utils/mermaid.ts +19 -10
  49. package/utils/mermaidTheme.ts +732 -0
  50. package/utils/parser.ts +36 -7
  51. package/utils/terminalToolsAnnouncement.ts +76 -0
  52. 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
+ );