@plannotator/ui 0.40.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.
@@ -1,601 +1,26 @@
1
- import React, { useRef, useState, useEffect, useCallback } from 'react';
2
- import { createPortal } from 'react-dom';
3
- import type { Viz } from '@viz-js/viz';
4
- import type { Block } from '../types';
5
- import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
1
+ import React from 'react';
2
+ import { __setGraphvizRuntimeLoaderForTests } from '../utils/graphviz';
3
+ import { DiagramBlock, type DiagramBlockProps } from './DiagramBlock';
6
4
 
7
- interface ViewBox {
8
- x: number;
9
- y: number;
10
- width: number;
11
- height: number;
12
- }
13
-
14
- const ZOOM_STEP = 0.25;
15
- const MIN_ZOOM = 0.25;
16
- const MAX_ZOOM = 8;
17
-
18
- /**
19
- * The Graphviz engine (about 1.2 MB of Emscripten JS) is imported inside the
20
- * render effect, not statically, so a host that bundles by route only fetches
21
- * it when a dot fence is on the page. WASM instantiation was already deferred
22
- * to first render; in Plannotator's single-file builds the import is inlined
23
- * and resolves in a microtask ahead of a render that was already asynchronous.
24
- */
25
- const loadVizInstance = (): Promise<Viz> => import('@viz-js/viz').then((m) => m.instance());
26
-
27
- let vizLoader = loadVizInstance;
28
- let vizInstancePromise: Promise<Viz> | null = null;
5
+ /** Test hook: stand in for the engine import and shorten the retry delay
6
+ * (the slot's, re-exported under the name the lazy-retry test uses). */
7
+ export const __setVizLoaderForTests = __setGraphvizRuntimeLoaderForTests;
29
8
 
30
9
  /**
31
- * Delay before the one automatic re-attempt after a failed engine import.
32
- * Only chunking hosts can fail here (a single-file build never fetches).
10
+ * A dot fence. The engine comes from the slot in `utils/graphviz` (lazy on
11
+ * the first dot fence); the canvas, the comment overlay and the popout are
12
+ * `DiagramBlock`'s, one code path with `MermaidBlock`.
33
13
  */
34
- let runtimeRetryDelayMs = 750;
35
-
36
- /**
37
- * Memoized engine. A rejected load is dropped from the memo so the next call
38
- * (the automatic re-attempt, a later mount, or the Retry button) issues a
39
- * fresh import() instead of replaying the cached rejection.
40
- */
41
- function getVizInstance(): Promise<Viz> {
42
- if (!vizInstancePromise) {
43
- const attempt = vizLoader().catch((err: unknown) => {
44
- if (vizInstancePromise === attempt) vizInstancePromise = null;
45
- throw err;
46
- });
47
- vizInstancePromise = attempt;
48
- }
49
- return vizInstancePromise;
50
- }
51
-
52
- /** Test hook: stand in for the engine import and shorten the retry delay. */
53
- export function __setVizLoaderForTests(
54
- loader: (() => Promise<Viz>) | undefined,
55
- options?: { retryDelayMs?: number },
56
- ): void {
57
- vizLoader = loader ?? loadVizInstance;
58
- vizInstancePromise = null;
59
- runtimeRetryDelayMs = options?.retryDelayMs ?? 750;
60
- }
61
-
62
- const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
63
-
64
- /** One Retry re-attempts every block whose engine import failed (see utils/runtimeRetry). */
65
- const vizRetryEpoch = createRuntimeRetryEpoch();
66
-
67
- function parseViewBox(svgEl: SVGSVGElement): ViewBox | null {
68
- const raw = svgEl.getAttribute('viewBox');
69
- if (!raw) return null;
70
-
71
- const values = raw
72
- .trim()
73
- .split(/[\s,]+/)
74
- .map((value) => Number.parseFloat(value));
75
-
76
- if (values.length !== 4 || values.some((value) => Number.isNaN(value))) {
77
- return null;
78
- }
79
-
80
- const [x, y, width, height] = values;
81
- if (width <= 0 || height <= 0) return null;
82
- return { x, y, width, height };
83
- }
84
-
85
- function parseViewBoxFromMarkup(markup: string): ViewBox | null {
86
- const viewBoxMatch = markup.match(/viewBox\s*=\s*"([^"]+)"/i);
87
- if (viewBoxMatch?.[1]) {
88
- const values = viewBoxMatch[1]
89
- .trim()
90
- .split(/[\s,]+/)
91
- .map((value) => Number.parseFloat(value));
92
-
93
- if (values.length === 4 && values.every((value) => Number.isFinite(value))) {
94
- const [x, y, width, height] = values;
95
- if (width > 0 && height > 0) {
96
- return { x, y, width, height };
97
- }
98
- }
99
- }
100
-
101
- const widthMatch = markup.match(/\bwidth\s*=\s*"([0-9.]+)(?:px|pt)?"/i);
102
- const heightMatch = markup.match(/\bheight\s*=\s*"([0-9.]+)(?:px|pt)?"/i);
103
- const width = widthMatch?.[1] ? Number.parseFloat(widthMatch[1]) : NaN;
104
- const height = heightMatch?.[1] ? Number.parseFloat(heightMatch[1]) : NaN;
105
- if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
106
- return { x: 0, y: 0, width, height };
107
- }
108
-
109
- return null;
110
- }
111
-
112
- function applyView(svgEl: SVGSVGElement, base: ViewBox, zoom: number, pan: { x: number; y: number }): void {
113
- const zoomedWidth = base.width / zoom;
114
- const zoomedHeight = base.height / zoom;
115
- const centerX = base.x + base.width / 2;
116
- const centerY = base.y + base.height / 2;
117
- const vbX = centerX - zoomedWidth / 2 + pan.x;
118
- const vbY = centerY - zoomedHeight / 2 + pan.y;
119
- svgEl.setAttribute('viewBox', `${vbX} ${vbY} ${zoomedWidth} ${zoomedHeight}`);
120
- }
121
-
122
- function fitBoundsToContainer(bounds: ViewBox, containerRect: DOMRect): ViewBox {
123
- const containerWidth = Math.max(containerRect.width, 1);
124
- const containerHeight = Math.max(containerRect.height, 1);
125
- const contentRatio = bounds.width / bounds.height;
126
- const containerRatio = containerWidth / containerHeight;
127
-
128
- if (contentRatio > containerRatio) {
129
- const targetHeight = bounds.width / containerRatio;
130
- const extra = (targetHeight - bounds.height) / 2;
131
- return {
132
- x: bounds.x,
133
- y: bounds.y - extra,
134
- width: bounds.width,
135
- height: targetHeight,
136
- };
137
- }
138
-
139
- const targetWidth = bounds.height * containerRatio;
140
- const extra = (targetWidth - bounds.width) / 2;
141
- return {
142
- x: bounds.x - extra,
143
- y: bounds.y,
144
- width: targetWidth,
145
- height: bounds.height,
146
- };
147
- }
148
-
149
- export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
150
- const containerRef = useRef<HTMLDivElement>(null);
151
- const [svg, setSvg] = useState('');
152
- const [error, setError] = useState<string | null>(null);
153
- // True when the failure was the engine import itself (a chunking host's
154
- // fetch), which is the only failure a Retry can change; a dot syntax error
155
- // keeps the panel exactly as it always was.
156
- const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
157
- const [retryToken, setRetryToken] = useState(0);
158
- const [showSource, setShowSource] = useState(false);
159
- const [isExpanded, setIsExpanded] = useState(false);
160
- // A sibling's Retry re-attempts this block too, but only while its own
161
- // failure was the shared engine import; a healthy block or a dot syntax
162
- // error is left alone.
163
- const runtimeUnavailableRef = useRef(runtimeUnavailable);
164
- runtimeUnavailableRef.current = runtimeUnavailable;
165
- useEffect(() => vizRetryEpoch.subscribe(() => {
166
- if (!runtimeUnavailableRef.current) return;
167
- setError(null);
168
- setRetryToken((token) => token + 1);
169
- }), []);
170
-
171
- const zoomLevelRef = useRef(1);
172
- const isDraggingRef = useRef(false);
173
- const naturalBoundsRef = useRef<ViewBox | null>(null);
174
- const baseViewBoxRef = useRef<ViewBox | null>(null);
175
- const panOffsetRef = useRef({ x: 0, y: 0 });
176
- const dragStartRef = useRef({ x: 0, y: 0 });
177
- const panStartRef = useRef({ x: 0, y: 0 });
178
-
179
- const zoomInBtnRef = useRef<HTMLButtonElement>(null);
180
- const zoomOutBtnRef = useRef<HTMLButtonElement>(null);
181
- const zoomDisplayRef = useRef<HTMLSpanElement>(null);
182
-
183
- const updateZoom = useCallback((newZoom: number) => {
184
- zoomLevelRef.current = newZoom;
185
-
186
- if (containerRef.current && baseViewBoxRef.current) {
187
- const svgEl = containerRef.current.querySelector('svg');
188
- if (svgEl instanceof SVGSVGElement) {
189
- applyView(svgEl, baseViewBoxRef.current, newZoom, panOffsetRef.current);
190
- }
191
- }
192
-
193
- if (zoomInBtnRef.current) zoomInBtnRef.current.disabled = newZoom >= MAX_ZOOM;
194
- if (zoomOutBtnRef.current) zoomOutBtnRef.current.disabled = newZoom <= MIN_ZOOM;
195
- if (zoomDisplayRef.current) {
196
- const show = Math.abs(newZoom - 1) > 0.001;
197
- zoomDisplayRef.current.textContent = show ? `${Math.round(newZoom * 100)}%` : '';
198
- zoomDisplayRef.current.hidden = !show;
199
- }
200
- }, []);
201
-
202
- const fitToCurrentViewport = useCallback(() => {
203
- if (!containerRef.current || !naturalBoundsRef.current) return;
204
-
205
- const svgEl = containerRef.current.querySelector('svg');
206
- if (!(svgEl instanceof SVGSVGElement)) return;
207
-
208
- const fitted = fitBoundsToContainer(naturalBoundsRef.current, containerRef.current.getBoundingClientRect());
209
- baseViewBoxRef.current = fitted;
210
- panOffsetRef.current = { x: 0, y: 0 };
211
- updateZoom(1);
212
- applyView(svgEl, fitted, 1, { x: 0, y: 0 });
213
- }, [updateZoom]);
214
-
215
- useEffect(() => {
216
- let cancelled = false;
217
-
218
- const renderDiagram = async () => {
219
- let viz: Viz;
220
- try {
221
- try {
222
- viz = await getVizInstance();
223
- } catch {
224
- // Transient chunk failure on a chunking host: one automatic
225
- // re-attempt with a fresh import() after a short delay. In a
226
- // single-file build the first await never rejects, so this branch
227
- // is unreachable there and the success path is unchanged.
228
- await wait(runtimeRetryDelayMs);
229
- if (cancelled) return;
230
- viz = await getVizInstance();
231
- }
232
- } catch (err) {
233
- if (!cancelled) {
234
- setError(err instanceof Error ? err.message : 'Failed to render diagram');
235
- setRuntimeUnavailable(true);
236
- setSvg('');
237
- }
238
- return;
239
- }
240
- try {
241
- const renderedSvg = await viz.renderString(block.content, { format: 'svg' });
242
- const cleaned = renderedSvg
243
- .replace(/ width="[^"]*"/, ' width="100%"')
244
- .replace(/ height="[^"]*"/, ' height="100%"')
245
- .replace(/ style="[^"]*"/, '')
246
- .replace(/<polygon[^>]*fill="white"[^>]*\/>/, '')
247
- .replace(/fill="black"/g, 'fill="var(--foreground)"')
248
- .replace(/fill="#000000"/g, 'fill="var(--foreground)"')
249
- .replace(/stroke="black"/g, 'stroke="var(--muted-foreground)"')
250
- .replace(/stroke="#000000"/g, 'stroke="var(--muted-foreground)"')
251
- .replace(/fill="lightgrey"/g, 'fill="var(--muted)"')
252
- .replace(/fill="lightgray"/g, 'fill="var(--muted)"');
253
-
254
- if (!cancelled) {
255
- naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned);
256
- setSvg(cleaned);
257
- setError(null);
258
- setRuntimeUnavailable(false);
259
- }
260
- } catch (err) {
261
- if (!cancelled) {
262
- setError(err instanceof Error ? err.message : 'Failed to render diagram');
263
- setRuntimeUnavailable(false);
264
- setSvg('');
265
- }
266
- }
267
- };
268
-
269
- renderDiagram();
270
-
271
- return () => {
272
- cancelled = true;
273
- };
274
- }, [block.content, retryToken]);
275
-
276
- useEffect(() => {
277
- zoomLevelRef.current = 1;
278
- naturalBoundsRef.current = null;
279
- baseViewBoxRef.current = null;
280
- panOffsetRef.current = { x: 0, y: 0 };
281
- setIsExpanded(false);
282
- }, [block.content]);
283
-
284
- useEffect(() => {
285
- if (showSource) {
286
- setIsExpanded(false);
287
- return;
288
- }
289
-
290
- zoomLevelRef.current = 1;
291
- panOffsetRef.current = { x: 0, y: 0 };
292
- baseViewBoxRef.current = null;
293
- }, [showSource]);
294
-
295
- useEffect(() => {
296
- if (!isExpanded) return undefined;
297
-
298
- const previousOverflow = document.body.style.overflow;
299
- document.body.style.overflow = 'hidden';
300
-
301
- const handleKeyDown = (event: KeyboardEvent) => {
302
- if (event.key === 'Escape') {
303
- setIsExpanded(false);
304
- }
305
- };
306
-
307
- window.addEventListener('keydown', handleKeyDown);
308
-
309
- return () => {
310
- document.body.style.overflow = previousOverflow;
311
- window.removeEventListener('keydown', handleKeyDown);
312
- };
313
- }, [isExpanded]);
314
-
315
- useEffect(() => {
316
- if (!svg || showSource || !containerRef.current) return;
317
-
318
- const svgEl = containerRef.current.querySelector('svg');
319
- if (!(svgEl instanceof SVGSVGElement)) return;
320
-
321
- svgEl.style.maxWidth = 'none';
322
- svgEl.style.width = '100%';
323
- svgEl.style.height = '100%';
324
- svgEl.style.display = 'block';
325
- svgEl.style.filter = 'none';
326
- svgEl.style.willChange = 'auto';
327
- svgEl.setAttribute('width', '100%');
328
- svgEl.setAttribute('height', '100%');
329
- svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
330
-
331
- let cancelled = false;
332
-
333
- const applyInitialView = () => {
334
- if (cancelled) return;
335
-
336
- try {
337
- const base = naturalBoundsRef.current ?? parseViewBox(svgEl);
338
-
339
- if (!base) return;
340
-
341
- naturalBoundsRef.current = base;
342
- fitToCurrentViewport();
343
- } catch {
344
- setError('Failed to measure diagram bounds');
345
- setSvg('');
346
- }
347
- };
348
-
349
- const raf = requestAnimationFrame(() => requestAnimationFrame(applyInitialView));
350
- const timer = window.setTimeout(applyInitialView, 120);
351
-
352
- return () => {
353
- cancelled = true;
354
- cancelAnimationFrame(raf);
355
- window.clearTimeout(timer);
356
- };
357
- }, [fitToCurrentViewport, isExpanded, showSource, svg]);
358
-
359
- useEffect(() => {
360
- if (showSource || !containerRef.current) return;
361
-
362
- const container = containerRef.current;
363
- const handleWheel = (e: WheelEvent) => {
364
- e.preventDefault();
365
- const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
366
- const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevelRef.current + delta));
367
- updateZoom(newZoom);
368
- };
369
-
370
- container.addEventListener('wheel', handleWheel, { passive: false });
371
- return () => container.removeEventListener('wheel', handleWheel);
372
- }, [showSource, isExpanded, updateZoom]);
373
-
374
- const handleZoomIn = useCallback(() => {
375
- updateZoom(Math.min(zoomLevelRef.current + ZOOM_STEP, MAX_ZOOM));
376
- }, [updateZoom]);
377
-
378
- const handleZoomOut = useCallback(() => {
379
- updateZoom(Math.max(zoomLevelRef.current - ZOOM_STEP, MIN_ZOOM));
380
- }, [updateZoom]);
381
-
382
- const handleFitToScreen = useCallback(() => {
383
- fitToCurrentViewport();
384
- }, [fitToCurrentViewport]);
385
-
386
- useEffect(() => {
387
- if (showSource || !containerRef.current || !naturalBoundsRef.current) return;
388
- if (typeof ResizeObserver === 'undefined') return;
389
-
390
- const observer = new ResizeObserver(() => {
391
- if (Math.abs(zoomLevelRef.current - 1) > 0.001) return;
392
- fitToCurrentViewport();
393
- });
394
-
395
- observer.observe(containerRef.current);
396
- return () => observer.disconnect();
397
- }, [fitToCurrentViewport, isExpanded, showSource, svg]);
398
-
399
- const handleMouseDown = useCallback((event: React.MouseEvent) => {
400
- if (event.button !== 0) return;
401
- event.preventDefault();
402
- isDraggingRef.current = true;
403
- dragStartRef.current = { x: event.clientX, y: event.clientY };
404
- panStartRef.current = { ...panOffsetRef.current };
405
- if (containerRef.current) containerRef.current.style.cursor = 'grabbing';
406
- }, []);
407
-
408
- const handleMouseMove = useCallback((event: React.MouseEvent) => {
409
- if (!isDraggingRef.current || !containerRef.current || !baseViewBoxRef.current) return;
410
-
411
- const svgEl = containerRef.current.querySelector('svg');
412
- if (!(svgEl instanceof SVGSVGElement)) return;
413
-
414
- const rect = svgEl.getBoundingClientRect();
415
- const base = baseViewBoxRef.current;
416
- const zoom = zoomLevelRef.current;
417
- const scaleX = (base.width / zoom) / rect.width;
418
- const scaleY = (base.height / zoom) / rect.height;
419
-
420
- const dx = event.clientX - dragStartRef.current.x;
421
- const dy = event.clientY - dragStartRef.current.y;
422
-
423
- panOffsetRef.current = {
424
- x: panStartRef.current.x - dx * scaleX,
425
- y: panStartRef.current.y - dy * scaleY,
426
- };
427
-
428
- applyView(svgEl, base, zoom, panOffsetRef.current);
429
- }, []);
430
-
431
- const stopDragging = useCallback(() => {
432
- if (!isDraggingRef.current) return;
433
- isDraggingRef.current = false;
434
- if (containerRef.current) containerRef.current.style.cursor = 'grab';
435
- }, []);
436
-
437
- if (error) {
438
- return (
439
- <div className="my-5 rounded-lg border border-destructive/30 bg-destructive/5 overflow-hidden">
440
- <div className="px-3 py-2 bg-destructive/10 border-b border-destructive/20 flex items-center gap-2">
441
- <svg className="w-4 h-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
442
- <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" />
443
- </svg>
444
- <span className="text-xs text-destructive font-medium">Graphviz Error</span>
445
- {runtimeUnavailable && (
446
- <button
447
- type="button"
448
- onClick={() => vizRetryEpoch.bump()}
449
- className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
450
- title="Retry loading the diagram renderer"
451
- >
452
- Retry
453
- </button>
454
- )}
455
- </div>
456
- <pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{error}</pre>
457
- <pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">
458
- <code>{block.content}</code>
459
- </pre>
460
- </div>
461
- );
462
- }
463
-
464
- const controls = (
465
- <div className={`absolute top-2 right-2 flex flex-col gap-1 items-center z-10 ${isExpanded ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 transition-opacity'}`}>
466
- <button
467
- onClick={() => setShowSource(!showSource)}
468
- className="p-1.5 rounded-md bg-muted/85 hover:bg-muted text-muted-foreground hover:text-foreground"
469
- title={showSource ? 'Show diagram' : 'Show source'}
470
- >
471
- {showSource ? (
472
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
473
- <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" />
474
- </svg>
475
- ) : (
476
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
477
- <path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
478
- </svg>
479
- )}
480
- </button>
481
-
482
- {!showSource && svg && (
483
- <>
484
- <div className="flex w-10 flex-col items-center gap-0.5 bg-muted/85 rounded-md p-0.5">
485
- <button
486
- onClick={() => setIsExpanded(!isExpanded)}
487
- className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
488
- title={isExpanded ? 'Exit expanded view' : 'Expand diagram'}
489
- aria-label={isExpanded ? 'Exit expanded view' : 'Expand diagram'}
490
- >
491
- {isExpanded ? (
492
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
493
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 10h4V6M18 10h-4V6M6 14h4v4M18 14h-4v4" />
494
- </svg>
495
- ) : (
496
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
497
- <path strokeLinecap="round" strokeLinejoin="round" d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
498
- </svg>
499
- )}
500
- </button>
501
-
502
- <button
503
- ref={zoomInBtnRef}
504
- onClick={handleZoomIn}
505
- className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
506
- title="Zoom in"
507
- aria-label="Zoom in"
508
- >
509
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
510
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
511
- </svg>
512
- </button>
513
-
514
- <button
515
- onClick={handleFitToScreen}
516
- className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
517
- title="Fit to view"
518
- aria-label="Fit to view"
519
- >
520
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
521
- <circle cx="12" cy="12" r="4" strokeLinecap="round" strokeLinejoin="round" />
522
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 2v4M12 18v4M2 12h4M18 12h4" />
523
- </svg>
524
- </button>
525
-
526
- <button
527
- ref={zoomOutBtnRef}
528
- onClick={handleZoomOut}
529
- className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
530
- title="Zoom out"
531
- aria-label="Zoom out"
532
- >
533
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
534
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" />
535
- </svg>
536
- </button>
537
- </div>
538
-
539
- <span
540
- ref={zoomDisplayRef}
541
- hidden
542
- className="min-w-10 rounded bg-muted/85 px-1 py-0.5 text-[10px] text-center text-muted-foreground tabular-nums leading-tight"
543
- />
544
- </>
545
- )}
546
- </div>
547
- );
548
-
549
- const inlineSource = (
550
- <pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30 p-4">
551
- <code className={`pn-code font-mono language-${block.language ?? 'graphviz'}`}>{block.content}</code>
552
- </pre>
553
- );
554
-
555
- const naturalHeight = naturalBoundsRef.current
556
- ? `min(65vh, ${Math.min(36 * 16, Math.max(4 * 16, Math.round(naturalBoundsRef.current.height * (800 / naturalBoundsRef.current.width))))}px)`
557
- : 'min(65vh, 36rem)';
558
-
559
- const diagramBody = (
560
- <div
561
- ref={containerRef}
562
- className={`rounded-xl bg-muted/30 border border-border/30 overflow-hidden select-none cursor-grab ${isExpanded ? 'h-full min-h-0' : ''}`}
563
- style={isExpanded ? undefined : { height: naturalHeight }}
564
- dangerouslySetInnerHTML={{ __html: svg }}
565
- onMouseDown={handleMouseDown}
566
- onMouseMove={handleMouseMove}
567
- onMouseUp={stopDragging}
568
- onMouseLeave={stopDragging}
569
- />
570
- );
571
-
572
- return (
573
- <>
574
- <div className="my-5 group relative" data-block-id={block.id}>
575
- {!isExpanded && controls}
576
- {showSource || !svg ? inlineSource : !isExpanded ? diagramBody : <div className="rounded-xl border border-border/30 bg-muted/10" style={{ height: naturalHeight }} />}
577
- </div>
578
-
579
- {!showSource && svg && isExpanded && typeof document !== 'undefined' && createPortal(
580
- <div className="fixed inset-0 z-[9999] bg-background/90 backdrop-blur-sm p-4 md:p-6">
581
- <div className="mx-auto flex h-full max-w-[min(96vw,110rem)] flex-col gap-3">
582
- <div className="flex items-center justify-between gap-4 text-xs text-muted-foreground">
583
- <span className="truncate">Graphviz diagram</span>
584
- <button
585
- onClick={() => setIsExpanded(false)}
586
- className="rounded-md border border-border/60 bg-card/70 px-2.5 py-1.5 text-foreground hover:bg-card"
587
- >
588
- Close
589
- </button>
590
- </div>
591
- <div className="group relative flex-1 min-h-0">
592
- {controls}
593
- {diagramBody}
594
- </div>
595
- </div>
596
- </div>,
597
- document.body
598
- )}
599
- </>
600
- );
601
- };
14
+ export const GraphvizBlock = React.memo(
15
+ (props: DiagramBlockProps) => <DiagramBlock kind="graphviz" {...props} />,
16
+ (prev, next) =>
17
+ prev.block.id === next.block.id &&
18
+ prev.block.content === next.block.content &&
19
+ prev.block.startLine === next.block.startLine &&
20
+ prev.annotations === next.annotations &&
21
+ prev.selectedAnnotationId === next.selectedAnnotationId &&
22
+ prev.onSelectAnnotation === next.onSelectAnnotation &&
23
+ prev.onAddAnnotation === next.onAddAnnotation &&
24
+ prev.readOnly === next.readOnly &&
25
+ prev.onRestoreReport === next.onRestoreReport,
26
+ );