@nocobase/app-template-default 1.0.0-beta.69 → 1.0.0-beta.70

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.
@@ -0,0 +1,674 @@
1
+ import {
2
+ useEffect,
3
+ useMemo,
4
+ useRef,
5
+ useState,
6
+ type CSSProperties,
7
+ type ReactElement,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import {
11
+ Background,
12
+ MiniMap,
13
+ Panel,
14
+ useReactFlow,
15
+ useViewport,
16
+ BaseEdge,
17
+ EdgeText,
18
+ Handle,
19
+ MarkerType,
20
+ Position,
21
+ ReactFlow,
22
+ type Edge,
23
+ type EdgeProps,
24
+ type Node,
25
+ type NodeProps,
26
+ } from '@xyflow/react';
27
+ import { useTranslation } from '@nocobase/i18n/client';
28
+ import { cn } from 'cn';
29
+ import {
30
+ CheckIcon,
31
+ CircleDotIcon,
32
+ FlagIcon,
33
+ GitBranchIcon,
34
+ MaximizeIcon,
35
+ MinusIcon,
36
+ PlayIcon,
37
+ PlusIcon,
38
+ Settings2Icon,
39
+ } from 'lucide-react';
40
+ import { Button } from '#components/ui/button';
41
+ import {
42
+ Tooltip,
43
+ TooltipContent,
44
+ TooltipTrigger,
45
+ } from '#components/ui/tooltip';
46
+ import '@xyflow/react/dist/style.css';
47
+
48
+ import {
49
+ layoutFlowDiagram,
50
+ type FlowDiagramDirection,
51
+ type FlowDiagramEdge,
52
+ type FlowDiagramGraph,
53
+ type FlowDiagramLayout,
54
+ type FlowDiagramNode,
55
+ type FlowDiagramPoint,
56
+ } from './flow-layout.js';
57
+
58
+ type DiagramNode = Node<{
59
+ item: FlowDiagramNode;
60
+ direction: FlowDiagramDirection;
61
+ content: ReactNode;
62
+ actions?: ReactNode;
63
+ description?: ReactNode;
64
+ }>;
65
+ type DiagramEdge = Edge<{
66
+ points: readonly FlowDiagramPoint[];
67
+ labelPosition?: FlowDiagramPoint;
68
+ }>;
69
+
70
+ function DiagramState({ data, id }: NodeProps<DiagramNode>): ReactElement {
71
+ const { t } = useTranslation();
72
+ const flow = useReactFlow();
73
+ const Icon =
74
+ data.item.kind === 'initial'
75
+ ? PlayIcon
76
+ : data.item.kind === 'final'
77
+ ? FlagIcon
78
+ : GitBranchIcon;
79
+ const StatusIcon = data.item.highlighted ? CircleDotIcon : CheckIcon;
80
+ const state = data.item.highlighted
81
+ ? t('flowDiagram.current', { defaultValue: 'Current' })
82
+ : data.item.visited
83
+ ? t('flowDiagram.visited', { defaultValue: 'Visited' })
84
+ : data.item.kind === 'initial'
85
+ ? t('flowDiagram.initial', { defaultValue: 'Initial' })
86
+ : data.item.kind === 'final'
87
+ ? t('flowDiagram.final', { defaultValue: 'Final' })
88
+ : t('flowDiagram.state', { defaultValue: 'State' });
89
+ return (
90
+ <div
91
+ onFocusCapture={(event) => {
92
+ // Readable initial zoom can put controls outside the viewport. Keyboard
93
+ // focus brings their owning node into view without changing the zoom.
94
+ const bounds = event.currentTarget.getBoundingClientRect();
95
+ const canvas = event.currentTarget
96
+ .closest('.react-flow')
97
+ ?.getBoundingClientRect();
98
+ const node = flow.getNode(id);
99
+ if (
100
+ canvas &&
101
+ node &&
102
+ (bounds.left < canvas.left ||
103
+ bounds.right > canvas.right ||
104
+ bounds.top < canvas.top ||
105
+ bounds.bottom > canvas.bottom)
106
+ ) {
107
+ void flow.setCenter(
108
+ node.position.x + (node.width ?? 232) / 2,
109
+ node.position.y + (node.height ?? 104) / 2,
110
+ { zoom: flow.getZoom() },
111
+ );
112
+ }
113
+ }}
114
+ data-execution-state={
115
+ data.item.highlighted
116
+ ? 'current'
117
+ : data.item.visited
118
+ ? 'visited'
119
+ : 'pending'
120
+ }
121
+ className={cn(
122
+ 'group relative flex h-full w-full flex-col overflow-hidden rounded-xl border border-border bg-linear-to-b from-card to-muted/20 text-sm text-card-foreground shadow-md transition-[box-shadow,border-color] hover:border-primary/50 hover:shadow-lg motion-reduce:transition-none [.selected_&]:ring-2 [.selected_&]:ring-ring [.selected_&]:ring-offset-2 [.selected_&]:ring-offset-background',
123
+ data.item.highlighted && 'border-primary ring-4 ring-primary/10',
124
+ !data.item.highlighted &&
125
+ (data.item.kind === 'initial' || data.item.configurable) &&
126
+ 'border-primary/25',
127
+ data.item.visited && !data.item.highlighted && 'border-primary/40',
128
+ data.item.className,
129
+ )}
130
+ >
131
+ {!data.item.decorative && (
132
+ <div
133
+ aria-hidden
134
+ className={cn(
135
+ 'absolute inset-x-0 top-0 h-1 bg-linear-to-r from-border to-border/40',
136
+ data.item.kind === 'initial' && 'from-primary/70 to-primary/20',
137
+ data.item.kind === 'final' && 'from-foreground/50 to-foreground/15',
138
+ data.item.configurable && 'from-primary/60 to-primary/15',
139
+ (data.item.highlighted || data.item.visited) &&
140
+ 'from-primary to-primary/50',
141
+ )}
142
+ />
143
+ )}
144
+ <Handle
145
+ id='in'
146
+ type='target'
147
+ position={data.direction === 'RIGHT' ? Position.Left : Position.Top}
148
+ className='invisible'
149
+ />
150
+ {data.item.decorative ? (
151
+ <div className='flex h-full items-center justify-center text-center'>
152
+ {data.content}
153
+ </div>
154
+ ) : (
155
+ <>
156
+ <div className='flex min-h-0 flex-1 items-center gap-3 px-4 pt-1'>
157
+ <div
158
+ aria-hidden
159
+ className={cn(
160
+ 'flex size-9 shrink-0 items-center justify-center rounded-xl border border-border bg-linear-to-br from-muted to-card text-muted-foreground shadow-xs',
161
+ (data.item.kind === 'initial' ||
162
+ data.item.configurable ||
163
+ data.item.visited ||
164
+ data.item.highlighted) &&
165
+ 'border-primary/15 from-primary/15 to-primary/5 text-primary',
166
+ )}
167
+ >
168
+ <Icon className='size-4' />
169
+ </div>
170
+ <div className='flex min-w-0 flex-col gap-2 leading-snug'>
171
+ <div className='line-clamp-2 font-semibold'>{data.content}</div>
172
+ {data.description && (
173
+ <div className='text-xs text-muted-foreground'>
174
+ {data.description}
175
+ </div>
176
+ )}
177
+ </div>
178
+ </div>
179
+ <div className='flex min-h-8 shrink-0 items-center justify-between gap-2 border-t border-border/60 bg-muted/35 px-4 text-xs text-muted-foreground'>
180
+ <span
181
+ className={cn(
182
+ 'inline-flex items-center gap-1.5',
183
+ (data.item.highlighted || data.item.visited) &&
184
+ 'font-medium text-primary',
185
+ )}
186
+ >
187
+ {(data.item.highlighted || data.item.visited) && (
188
+ <StatusIcon className='size-3' aria-hidden />
189
+ )}
190
+ {state}
191
+ </span>
192
+ {data.actions ??
193
+ (data.item.configurable && (
194
+ <span className='inline-flex items-center gap-1 rounded-md border border-primary/15 bg-primary/5 px-1.5 py-0.5 text-primary'>
195
+ <Settings2Icon className='size-3' aria-hidden />
196
+ {t('flowDiagram.configurable', {
197
+ defaultValue: 'Parameters',
198
+ })}
199
+ </span>
200
+ ))}
201
+ </div>
202
+ </>
203
+ )}
204
+ <Handle
205
+ id='out'
206
+ type='source'
207
+ position={data.direction === 'RIGHT' ? Position.Right : Position.Bottom}
208
+ className='invisible'
209
+ />
210
+ </div>
211
+ );
212
+ }
213
+
214
+ /** Keep ELK's routes while rounding their right-angle bends. */
215
+ function routePath(points: readonly FlowDiagramPoint[]): string {
216
+ if (!points.length) return '';
217
+ let path = `M ${points[0].x} ${points[0].y}`;
218
+ for (let index = 1; index < points.length - 1; index++) {
219
+ const before = points[index - 1];
220
+ const point = points[index];
221
+ const after = points[index + 1];
222
+ const incoming = Math.hypot(point.x - before.x, point.y - before.y);
223
+ const outgoing = Math.hypot(after.x - point.x, after.y - point.y);
224
+ if (!incoming || !outgoing) continue;
225
+ const radius = Math.min(8, incoming / 2, outgoing / 2);
226
+ const start = {
227
+ x: point.x + ((before.x - point.x) / incoming) * radius,
228
+ y: point.y + ((before.y - point.y) / incoming) * radius,
229
+ };
230
+ const end = {
231
+ x: point.x + ((after.x - point.x) / outgoing) * radius,
232
+ y: point.y + ((after.y - point.y) / outgoing) * radius,
233
+ };
234
+ path += ` L ${start.x} ${start.y} Q ${point.x} ${point.y} ${end.x} ${end.y}`;
235
+ }
236
+ const last = points.at(-1)!;
237
+ return `${path} L ${last.x} ${last.y}`;
238
+ }
239
+
240
+ function DiagramConnection(props: EdgeProps<DiagramEdge>): ReactElement {
241
+ const points = props.data?.points ?? [];
242
+ const path = routePath(points);
243
+ const label = props.data?.labelPosition;
244
+ return (
245
+ <>
246
+ <BaseEdge
247
+ id={props.id}
248
+ path={path}
249
+ markerEnd={props.markerEnd}
250
+ style={props.style}
251
+ />
252
+ {label && props.label ? (
253
+ <EdgeText
254
+ x={label.x}
255
+ y={label.y}
256
+ label={props.label}
257
+ labelStyle={{
258
+ fill: 'var(--foreground)',
259
+ fontSize: 'var(--text-xs)',
260
+ fontWeight: 500,
261
+ }}
262
+ labelBgStyle={{
263
+ fill: 'var(--card)',
264
+ stroke: 'var(--border)',
265
+ strokeWidth: 1,
266
+ }}
267
+ labelBgPadding={[9, 5]}
268
+ labelBgBorderRadius={7}
269
+ />
270
+ ) : null}
271
+ </>
272
+ );
273
+ }
274
+
275
+ const nodeTypes = { diagram: DiagramState };
276
+ const edgeTypes = { diagram: DiagramConnection };
277
+
278
+ export interface FlowDiagramProps extends FlowDiagramGraph {
279
+ readonly direction?: FlowDiagramDirection;
280
+ /** Expand vertically to show the entire graph at the available width. */
281
+ readonly autoHeight?: boolean;
282
+ readonly className?: string;
283
+ readonly style?: CSSProperties;
284
+ readonly ariaLabel: string;
285
+ /** Interactive controls shown in the node footer. */
286
+ readonly nodeActions?: (node: FlowDiagramNode) => ReactNode;
287
+ /** Supporting content below the node title; provide sufficient fixed node dimensions. */
288
+ readonly nodeDescription?: (node: FlowDiagramNode) => ReactNode;
289
+ /** Initial fit prioritizes legibility; the Fit control still shows the entire graph. */
290
+ readonly initialMinZoom?: number;
291
+ readonly showMiniMap?: boolean;
292
+ readonly renderNode?: (node: FlowDiagramNode) => ReactNode;
293
+ readonly onNodeClick?: (node: FlowDiagramNode) => void;
294
+ readonly onEdgeClick?: (edge: FlowDiagramEdge) => void;
295
+ readonly onLayoutError?: (error: unknown) => void;
296
+ }
297
+
298
+ /** A read-only graph canvas. The parent controls its data, selection details and dimensions. */
299
+ export function FlowDiagram({
300
+ nodes,
301
+ edges,
302
+ direction = 'RIGHT',
303
+ autoHeight = false,
304
+ className,
305
+ style,
306
+ ariaLabel,
307
+ renderNode,
308
+ nodeActions,
309
+ nodeDescription,
310
+ initialMinZoom = 0.1,
311
+ showMiniMap = false,
312
+ onNodeClick,
313
+ onEdgeClick,
314
+ onLayoutError,
315
+ }: FlowDiagramProps): ReactElement {
316
+ const { t } = useTranslation();
317
+ const containerRef = useRef<HTMLDivElement>(null);
318
+ const [width, setWidth] = useState(0);
319
+ useEffect(() => {
320
+ if (!autoHeight || !containerRef.current) return;
321
+ const observer = new ResizeObserver(([entry]) => {
322
+ if (entry) setWidth(entry.contentRect.width);
323
+ });
324
+ observer.observe(containerRef.current);
325
+ return () => observer.disconnect();
326
+ }, [autoHeight]);
327
+ // Layout only structural changes; polling a record or highlighting a node preserves the viewport.
328
+ const structure = JSON.stringify({
329
+ nodes: nodes.map(({ id, width, height }) => ({
330
+ id,
331
+ label: '',
332
+ width,
333
+ height,
334
+ })),
335
+ edges: edges.map(({ id, source, target, label, configurable }) => ({
336
+ id,
337
+ source,
338
+ target,
339
+ label: configurable
340
+ ? [label, t('flowDiagram.configurable', { defaultValue: 'Parameters' })]
341
+ .filter(Boolean)
342
+ .join(' · ')
343
+ : label,
344
+ })),
345
+ direction,
346
+ });
347
+ const [result, setResult] = useState<{
348
+ key: string;
349
+ layout?: FlowDiagramLayout;
350
+ error?: unknown;
351
+ }>();
352
+ useEffect(() => {
353
+ let active = true;
354
+ const input = JSON.parse(structure) as FlowDiagramGraph & {
355
+ direction: FlowDiagramDirection;
356
+ };
357
+ void layoutFlowDiagram(input, input.direction).then(
358
+ (layout) => {
359
+ if (active) setResult({ key: structure, layout });
360
+ },
361
+ (error: unknown) => {
362
+ if (active) setResult({ key: structure, error });
363
+ },
364
+ );
365
+ return () => {
366
+ active = false;
367
+ };
368
+ }, [structure]);
369
+ useEffect(() => {
370
+ if (result?.key === structure && result.error)
371
+ onLayoutError?.(result.error);
372
+ }, [result, structure, onLayoutError]);
373
+ const layout = result?.key === structure ? result.layout : undefined;
374
+ const placedNodes = useMemo<DiagramNode[]>(
375
+ () =>
376
+ (layout?.nodes ?? []).map((placed) => {
377
+ const item = nodes.find((node) => node.id === placed.id)!;
378
+ return {
379
+ id: item.id,
380
+ type: 'diagram',
381
+ focusable: !item.decorative,
382
+ selectable: !item.decorative,
383
+ position: { x: placed.x, y: placed.y },
384
+ style: { width: placed.width, height: placed.height },
385
+ // ELK owns fixed geometry. Recreated controlled nodes must not clear
386
+ // React Flow's measurements and briefly remove every connected edge.
387
+ width: placed.width,
388
+ height: placed.height,
389
+ measured: { width: placed.width, height: placed.height },
390
+ handles: [
391
+ {
392
+ id: 'in',
393
+ type: 'target',
394
+ position: direction === 'RIGHT' ? Position.Left : Position.Top,
395
+ x: direction === 'RIGHT' ? 0 : placed.width / 2,
396
+ y: direction === 'RIGHT' ? placed.height / 2 : 0,
397
+ width: 0,
398
+ height: 0,
399
+ },
400
+ {
401
+ id: 'out',
402
+ type: 'source',
403
+ position:
404
+ direction === 'RIGHT' ? Position.Right : Position.Bottom,
405
+ x: direction === 'RIGHT' ? placed.width : placed.width / 2,
406
+ y: direction === 'RIGHT' ? placed.height / 2 : placed.height,
407
+ width: 0,
408
+ height: 0,
409
+ },
410
+ ],
411
+ ariaLabel: item.label,
412
+ data: {
413
+ item,
414
+ direction,
415
+ content: renderNode ? renderNode(item) : item.label,
416
+ actions: nodeActions?.(item),
417
+ description: nodeDescription?.(item),
418
+ },
419
+ };
420
+ }),
421
+ [layout, nodes, direction, renderNode, nodeActions, nodeDescription],
422
+ );
423
+ const placedEdges = useMemo<DiagramEdge[]>(
424
+ () =>
425
+ (layout?.edges ?? []).map((placed) => {
426
+ const item = edges.find((edge) => edge.id === placed.id)!;
427
+ const color = item.visited
428
+ ? 'color-mix(in srgb, var(--primary) 80%, var(--foreground))'
429
+ : item.highlighted
430
+ ? 'var(--primary)'
431
+ : 'color-mix(in srgb, var(--muted-foreground) 65%, transparent)';
432
+ return {
433
+ ...item,
434
+ label: placed.label,
435
+ type: 'diagram',
436
+ animated: Boolean(item.highlighted && !item.visited),
437
+ focusable: !item.decorative,
438
+ selectable: !item.decorative,
439
+ sourceHandle: 'out',
440
+ targetHandle: 'in',
441
+ ariaLabel: placed.label ?? `${item.source} → ${item.target}`,
442
+ markerEnd: { type: MarkerType.ArrowClosed, color },
443
+ style: {
444
+ stroke: color,
445
+ strokeWidth: item.visited || item.highlighted ? 2.5 : 1.5,
446
+ strokeDasharray: item.dashed ? '5 4' : undefined,
447
+ },
448
+ data: { points: placed.points, labelPosition: placed.labelPosition },
449
+ };
450
+ }),
451
+ [layout, edges],
452
+ );
453
+ const zoom =
454
+ layout && width
455
+ ? Math.max(
456
+ 0.1,
457
+ initialMinZoom,
458
+ Math.min(1, (width - 48) / Math.max(1, layout.width)),
459
+ )
460
+ : 1;
461
+ // Reserve room above and below the graph for the canvas chrome.
462
+ const height = layout ? Math.max(320, layout.height * zoom + 144) : 320;
463
+ return (
464
+ <div
465
+ ref={containerRef}
466
+ onKeyDown={(event) => {
467
+ if (event.key !== 'Enter' && event.key !== ' ') return;
468
+ const target = event.target as HTMLElement | SVGElement;
469
+ const nodeElement = target.closest<HTMLElement>('.react-flow__node');
470
+ const edgeElement = target.closest<HTMLElement>('.react-flow__edge');
471
+ if (target !== nodeElement && target !== edgeElement) return;
472
+ const id = target.dataset.id;
473
+ const node = nodes.find((item) => item.id === id);
474
+ const edge = edges.find((item) => item.id === id);
475
+ if (nodeElement && node) {
476
+ event.preventDefault();
477
+ onNodeClick?.(node);
478
+ }
479
+ if (edgeElement && edge) {
480
+ event.preventDefault();
481
+ onEdgeClick?.(edge);
482
+ }
483
+ }}
484
+ aria-label={ariaLabel}
485
+ className={cn(
486
+ 'relative isolate h-96 w-full overflow-hidden rounded-xl border border-border bg-linear-to-br from-primary/5 via-background to-muted/40 text-foreground [&_.react-flow__attribution]:bg-card/80 [&_.react-flow__attribution]:text-muted-foreground [&_.react-flow__edge.selected_path.react-flow__edge-path]:stroke-primary [&_.react-flow__edge.animated_path]:motion-reduce:animate-none',
487
+ className,
488
+ )}
489
+ style={{ ...(autoHeight ? { height } : {}), ...style }}
490
+ >
491
+ {!nodes.length ? (
492
+ <p role='status' className='p-4 text-sm text-muted-foreground'>
493
+ {t('flowDiagram.empty', { defaultValue: 'No states to display.' })}
494
+ </p>
495
+ ) : result?.key === structure && result.error ? (
496
+ <p role='alert' className='p-4 text-sm text-destructive'>
497
+ {t('flowDiagram.failed', {
498
+ defaultValue: 'Unable to lay out this diagram.',
499
+ })}
500
+ </p>
501
+ ) : !layout ? (
502
+ <p role='status' className='p-4 text-sm text-muted-foreground'>
503
+ {t('flowDiagram.loading', { defaultValue: 'Laying out diagram…' })}
504
+ </p>
505
+ ) : (
506
+ <ReactFlow<DiagramNode, DiagramEdge>
507
+ key={autoHeight ? `${structure}:${width}` : structure}
508
+ nodes={placedNodes}
509
+ edges={placedEdges}
510
+ nodeTypes={nodeTypes}
511
+ edgeTypes={edgeTypes}
512
+ fitView={!autoHeight}
513
+ fitViewOptions={{
514
+ padding: 0.2,
515
+ minZoom: initialMinZoom,
516
+ maxZoom: 1,
517
+ }}
518
+ defaultViewport={
519
+ autoHeight
520
+ ? {
521
+ x: (width - layout.width * zoom) / 2,
522
+ y: (height - layout.height * zoom) / 2,
523
+ zoom,
524
+ }
525
+ : undefined
526
+ }
527
+ // A full-height process is read by scrolling the page, not wheel-zooming the canvas.
528
+ zoomOnScroll={!autoHeight}
529
+ preventScrolling={!autoHeight}
530
+ minZoom={0.1}
531
+ maxZoom={2}
532
+ nodesDraggable={false}
533
+ nodesConnectable={false}
534
+ edgesReconnectable={false}
535
+ deleteKeyCode={null}
536
+ onNodeClick={(_, node) => onNodeClick?.(node.data.item)}
537
+ onEdgeClick={(_, edge) => {
538
+ const item = edges.find((candidate) => candidate.id === edge.id);
539
+ if (item) onEdgeClick?.(item);
540
+ }}
541
+ ariaLabelConfig={{
542
+ 'node.a11yDescription.keyboardDisabled': t('flowDiagram.nodeHelp', {
543
+ defaultValue: 'Press Enter or Space to select a state.',
544
+ }),
545
+ 'node.a11yDescription.default': t('flowDiagram.nodeHelp', {
546
+ defaultValue: 'Press Enter or Space to select a state.',
547
+ }),
548
+ 'edge.a11yDescription.default': t('flowDiagram.edgeHelp', {
549
+ defaultValue: 'Press Enter or Space to select a transition.',
550
+ }),
551
+ }}
552
+ >
553
+ <Background color='var(--border)' gap={24} size={1.5} />
554
+ {showMiniMap && (
555
+ <MiniMap
556
+ pannable
557
+ zoomable
558
+ ariaLabel={t('flowDiagram.overview', {
559
+ defaultValue: 'Diagram overview',
560
+ })}
561
+ nodeColor='var(--muted-foreground)'
562
+ maskColor='color-mix(in srgb, var(--background) 75%, transparent)'
563
+ style={{
564
+ background: 'var(--card)',
565
+ border: '1px solid var(--border)',
566
+ borderRadius: 'var(--radius)',
567
+ }}
568
+ className='hidden sm:block'
569
+ />
570
+ )}
571
+ <Panel position='top-left' className='pointer-events-none!'>
572
+ <div className='inline-flex items-center gap-3 rounded-lg border bg-card/95 px-3 py-2 text-xs text-muted-foreground shadow-xs'>
573
+ <span className='inline-flex items-center gap-1.5'>
574
+ <GitBranchIcon className='size-3.5' aria-hidden />
575
+ {t('flowDiagram.states', { defaultValue: 'States' })}
576
+ <span className='font-semibold text-foreground tabular-nums'>
577
+ {nodes.filter((node) => !node.decorative).length}
578
+ </span>
579
+ </span>
580
+ <span className='h-3 w-px bg-border' aria-hidden />
581
+ <span>
582
+ {t('flowDiagram.transitions', { defaultValue: 'Transitions' })}{' '}
583
+ <span className='font-semibold text-foreground tabular-nums'>
584
+ {edges.filter((edge) => !edge.decorative).length}
585
+ </span>
586
+ </span>
587
+ </div>
588
+ </Panel>
589
+ <DiagramToolbar top={autoHeight} />
590
+ </ReactFlow>
591
+ )}
592
+ </div>
593
+ );
594
+ }
595
+
596
+ /** View controls belong inside React Flow so they always use this canvas's viewport. */
597
+ function DiagramToolbar({
598
+ top = false,
599
+ }: {
600
+ readonly top?: boolean;
601
+ }): ReactElement {
602
+ const { t } = useTranslation();
603
+ const flow = useReactFlow();
604
+ const { zoom } = useViewport();
605
+ const controls = [
606
+ {
607
+ label: t('flowDiagram.zoomOut', { defaultValue: 'Zoom out' }),
608
+ icon: MinusIcon,
609
+ disabled: zoom <= 0.1,
610
+ run: () => void flow.zoomOut(),
611
+ },
612
+ {
613
+ label: t('flowDiagram.zoomIn', { defaultValue: 'Zoom in' }),
614
+ icon: PlusIcon,
615
+ disabled: zoom >= 2,
616
+ run: () => void flow.zoomIn(),
617
+ },
618
+ {
619
+ label: t('flowDiagram.fit', { defaultValue: 'Fit to view' }),
620
+ icon: MaximizeIcon,
621
+ disabled: false,
622
+ run: () => void flow.fitView({ padding: 0.2, maxZoom: 1 }),
623
+ },
624
+ ];
625
+ return (
626
+ <Panel
627
+ position={top ? 'top-right' : 'bottom-left'}
628
+ className='max-sm:top-12! max-sm:bottom-auto!'
629
+ >
630
+ <div className='flex items-center gap-1 rounded-xl border bg-card/95 p-1.5 shadow-md'>
631
+ {controls.map(({ label, icon: Icon, disabled, run }) => (
632
+ <Tooltip key={label}>
633
+ <TooltipTrigger render={<span />}>
634
+ <Button
635
+ type='button'
636
+ size='sm'
637
+ className='size-7 p-0'
638
+ variant='ghost'
639
+ aria-label={label}
640
+ disabled={disabled || !flow.viewportInitialized}
641
+ onClick={run}
642
+ >
643
+ <Icon />
644
+ </Button>
645
+ </TooltipTrigger>
646
+ <TooltipContent>{label}</TooltipContent>
647
+ </Tooltip>
648
+ ))}
649
+ <Tooltip>
650
+ <TooltipTrigger
651
+ render={
652
+ <Button
653
+ type='button'
654
+ variant='ghost'
655
+ size='sm'
656
+ className='min-w-12 px-2 text-xs text-muted-foreground tabular-nums'
657
+ aria-label={t('flowDiagram.actualSize', {
658
+ defaultValue: 'Actual size',
659
+ })}
660
+ disabled={!flow.viewportInitialized}
661
+ onClick={() => void flow.zoomTo(1)}
662
+ />
663
+ }
664
+ >
665
+ {Math.round(zoom * 100)}%
666
+ </TooltipTrigger>
667
+ <TooltipContent>
668
+ {t('flowDiagram.actualSize', { defaultValue: 'Actual size' })}
669
+ </TooltipContent>
670
+ </Tooltip>
671
+ </div>
672
+ </Panel>
673
+ );
674
+ }