@likec4/diagram 1.16.0 → 1.17.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 (47) hide show
  1. package/package.json +7 -7
  2. package/src/LikeC4CustomColors.tsx +24 -21
  3. package/src/LikeC4Diagram.props.ts +9 -9
  4. package/src/LikeC4Diagram.tsx +25 -26
  5. package/src/StaticLikeC4Diagram.tsx +9 -5
  6. package/src/overlays/OverlayContext.tsx +1 -1
  7. package/src/overlays/Overlays.css.ts +28 -23
  8. package/src/overlays/Overlays.tsx +116 -64
  9. package/src/overlays/edge-details/xyflow/ElementNode.tsx +15 -12
  10. package/src/overlays/edge-details/xyflow/RelationshipEdge.tsx +7 -3
  11. package/src/overlays/edge-details/xyflow/styles.css.ts +22 -16
  12. package/src/overlays/element-details/ElementDetailsCard.css.ts +322 -0
  13. package/src/overlays/element-details/ElementDetailsCard.tsx +639 -0
  14. package/src/overlays/element-details/TabPanelRelationships.css.ts +106 -0
  15. package/src/overlays/element-details/TabPanelRelationships.tsx +201 -0
  16. package/src/overlays/element-details/TabPanelStructure.css.ts +36 -0
  17. package/src/overlays/element-details/TabPanelStructure.tsx +120 -0
  18. package/src/overlays/relationships-of/RelationshipsOverlay.tsx +185 -0
  19. package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +179 -213
  20. package/src/overlays/relationships-of/_types.ts +15 -4
  21. package/src/overlays/relationships-of/use-layouted-relationships.ts +301 -207
  22. package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +68 -14
  23. package/src/overlays/relationships-of/xyflow/ElementNode.tsx +54 -14
  24. package/src/overlays/relationships-of/xyflow/EmptyNode.tsx +1 -1
  25. package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +51 -23
  26. package/src/overlays/relationships-of/xyflow/styles.css.ts +34 -27
  27. package/src/state/DiagramContext.tsx +15 -8
  28. package/src/state/diagramStore.ts +14 -6
  29. package/src/theme-vars.ts +9 -0
  30. package/src/ui/FramerMotionConfig.tsx +18 -0
  31. package/src/ui/notation/NotationPanel.tsx +3 -3
  32. package/src/xyflow/XYFlow.tsx +18 -35
  33. package/src/xyflow/XYFlowInner.tsx +2 -2
  34. package/src/xyflow/edges/EdgeLabel.tsx +3 -3
  35. package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +25 -18
  36. package/src/xyflow/nodes/compound/CompoundNode.css.ts +96 -30
  37. package/src/xyflow/nodes/compound/CompoundNode.tsx +149 -165
  38. package/src/xyflow/nodes/element/ElementDetails.css.ts +101 -0
  39. package/src/xyflow/nodes/element/ElementDetails.tsx +393 -0
  40. package/src/xyflow/nodes/element/ElementNode.tsx +110 -116
  41. package/src/xyflow/nodes/element/element.css.ts +38 -18
  42. package/src/xyflow/nodes/shared/ElementIcon.tsx +43 -0
  43. package/src/xyflow/nodes/shared/Toolbar.tsx +6 -6
  44. package/src/xyflow/{XYFlowEvents.tsx → useXYFlowEvents.ts} +26 -22
  45. package/src/xyflow/utils.ts +12 -3
  46. package/src/LikeC4CustomColors.props.ts +0 -5
  47. /package/src/{mantine → ui}/EnsureMantine.tsx +0 -0
@@ -0,0 +1,393 @@
1
+ import { type ComputedView, type DiagramView, type EdgeId, invariant, nameFromFqn, type ViewID } from '@likec4/core'
2
+ import {
3
+ Anchor,
4
+ Badge,
5
+ Box,
6
+ Button,
7
+ CopyButton,
8
+ Divider as MantineDivider,
9
+ Flex,
10
+ Group,
11
+ Paper,
12
+ ScrollAreaAutosize,
13
+ Stack,
14
+ Text
15
+ } from '@mantine/core'
16
+ import { useElementSize, useHotkeys } from '@mantine/hooks'
17
+ import { IconArrowRight, IconInfoCircle, IconTransform, IconZoomScan } from '@tabler/icons-react'
18
+ import { useInternalNode, useViewport, ViewportPortal } from '@xyflow/react'
19
+ import { getNodeDimensions, getViewportForBounds, type Viewport } from '@xyflow/system'
20
+ import clsx from 'clsx'
21
+ import React, { memo, type ReactNode, useCallback, useState } from 'react'
22
+ import { createPortal } from 'react-dom'
23
+ import { clamp, unique } from 'remeda'
24
+ import { useDiagramStoreApi, useXYStore } from '../../../hooks'
25
+ import type { OnNavigateTo } from '../../../LikeC4Diagram.props'
26
+ import { useLikeC4Model } from '../../../likec4model'
27
+ import { MinZoom } from '../../const'
28
+ import type { ElementXYFlowNode, XYFlowState } from '../../types'
29
+ import * as css from './ElementDetails.css'
30
+
31
+ const Divider = MantineDivider.withProps({
32
+ mb: 6,
33
+ labelPosition: 'left',
34
+ variant: 'dashed'
35
+ })
36
+
37
+ const selector = (state: XYFlowState) => state.domNode?.querySelector('.react-flow__renderer')
38
+ // const selector = (state: XYFlowState) => state.domNode?.querySelector('.react-flow__nodes');
39
+
40
+ export function NodeToolbarPortal({ children }: { children: ReactNode }) {
41
+ const wrapperRef = useXYStore(selector)
42
+
43
+ if (!wrapperRef) {
44
+ return null
45
+ }
46
+
47
+ return createPortal(children, wrapperRef)
48
+ }
49
+
50
+ type ElementDetailsProps = {
51
+ nodeId: string
52
+ }
53
+
54
+ const MAX_HEIGHT = 300
55
+
56
+ export const ElementDetails = memo<ElementDetailsProps>(({ nodeId }) => {
57
+ const [previousViewport, setPreviousViewport] = useState<null | Viewport>(null)
58
+ const diagramApi = useDiagramStoreApi()
59
+ const xyviewport = useViewport()
60
+ const { ref, height: detailsHeight } = useElementSize()
61
+ const hoveredNode = useInternalNode<ElementXYFlowNode>(nodeId)
62
+ invariant(hoveredNode, 'Node not found')
63
+
64
+ const {
65
+ data: { element },
66
+ internals: {
67
+ positionAbsolute
68
+ }
69
+ } = hoveredNode
70
+
71
+ const likec4Model = useLikeC4Model(true)
72
+ const elementModel = likec4Model.element(element.id)
73
+
74
+ const incoming = elementModel.incoming().map(r => r.id)
75
+ const outgoing = elementModel.outgoing().map(r => r.id)
76
+
77
+ const { view: currentView, onNavigateTo } = diagramApi.getState()
78
+
79
+ const findRelationIds = (edgeId: EdgeId) => currentView.edges.find((edge) => edge.id === edgeId)?.relations ?? []
80
+
81
+ const incomingInView = unique(element.inEdges.flatMap(findRelationIds))
82
+ const outgoingInView = unique(element.outEdges.flatMap(findRelationIds))
83
+
84
+ const notIncludedRelations = [
85
+ ...incoming,
86
+ ...outgoing
87
+ ].filter(r => !incomingInView.includes(r) && !outgoingInView.includes(r)).length
88
+
89
+ const defaultView = (element.navigateTo && element.navigateTo !== currentView.id)
90
+ ? likec4Model.view(element.navigateTo).view
91
+ : null
92
+ const otherViews = elementModel.views().filter(v => v.id !== currentView.id && v.id !== element.navigateTo).map(v =>
93
+ v.view
94
+ )
95
+
96
+ const { width: nodeWidth, height } = getNodeDimensions(hoveredNode)
97
+ const width = Math.round(nodeWidth * 1.015)
98
+ const left = positionAbsolute.x
99
+ const top = positionAbsolute.y + height
100
+
101
+ const zoomInOut = useCallback((e: React.UIEvent) => {
102
+ e.stopPropagation()
103
+ const {
104
+ xyflow,
105
+ xystore,
106
+ fitViewPadding
107
+ } = diagramApi.getState()
108
+ if (previousViewport) {
109
+ xyflow.updateNode(nodeId, { selected: false })
110
+ xyflow.setViewport(previousViewport, { duration: 500 })
111
+ setPreviousViewport(null)
112
+ return
113
+ }
114
+ setPreviousViewport({ ...xyflow.getViewport() })
115
+ const xy = xystore.getState()
116
+ xy.addSelectedNodes([nodeId])
117
+ const viewport = getViewportForBounds(
118
+ {
119
+ x: positionAbsolute.x - 25,
120
+ y: positionAbsolute.y,
121
+ width: width + 50,
122
+ height: height + MAX_HEIGHT + 20
123
+ },
124
+ xy.width,
125
+ xy.height,
126
+ MinZoom,
127
+ 1.3,
128
+ fitViewPadding
129
+ )
130
+ xyflow.setViewport(viewport, { duration: 500 })
131
+ }, [previousViewport, width, height, positionAbsolute])
132
+
133
+ useHotkeys(
134
+ previousViewport
135
+ ? [
136
+ ['Escape', zoomInOut as any, { preventDefault: true }]
137
+ ]
138
+ : []
139
+ )
140
+
141
+ const openRelationshipsOverlay = useCallback((e: React.MouseEvent): void => {
142
+ e.stopPropagation()
143
+ diagramApi.getState().openOverlay({
144
+ relationshipsOf: element.id
145
+ })
146
+ }, [element.id])
147
+
148
+ const onNavigateToCb = useCallback((toView: ViewID, e?: React.MouseEvent): void => {
149
+ e?.stopPropagation()
150
+ diagramApi.setState({
151
+ hoveredNodeId: null,
152
+ lastClickedNodeId: nodeId,
153
+ lastOnNavigate: {
154
+ fromView: currentView.id,
155
+ toView,
156
+ fromNode: element.id
157
+ }
158
+ })
159
+ diagramApi.getState().onNavigateTo?.(toView, e)
160
+ }, [element.id, currentView.id, nodeId])
161
+
162
+ return (
163
+ <ViewportPortal>
164
+ <Paper
165
+ shadow="lg"
166
+ radius="sm"
167
+ p={0}
168
+ data-likec4-color={element.color}
169
+ className={clsx(css.container, 'nodrag')}
170
+ style={{
171
+ transform: `translate(${left}px, ${top + 18}px)`,
172
+ maxWidth: Math.max(width + 30, 450),
173
+ width: 'max-content',
174
+ minWidth: width,
175
+ cursor: xyviewport.zoom < 1.01 ? 'pointer' : 'default'
176
+ }}
177
+ onClick={xyviewport.zoom < 1.01 ? zoomInOut : undefined}
178
+ >
179
+ <Group align="baseline" m="xs" wrap="nowrap" justify="space-between">
180
+ <Box flex={0}>
181
+ <Text component="span" size="sm" c="dimmed">kind:</Text>
182
+ <Text component="span" size="sm" fw={600}>{element.kind}</Text>
183
+ </Box>
184
+ {element.tags && (
185
+ <Flex gap={4} flex={1}>
186
+ {/* {element.tags.map((tag) => <Text key={tag} size="xs" fw={600}>#{tag}</Text>)} */}
187
+ {element.tags.map((tag) => (
188
+ <Badge key={tag} color="violet" radius={'sm'} size="sm" fw={600} variant="gradient">#{tag}</Badge>
189
+ ))}
190
+ </Flex>
191
+ )}
192
+ <Box flex={0}>
193
+ <Button
194
+ size="xs"
195
+ color="gray"
196
+ variant="light"
197
+ radius="sm"
198
+ onClick={zoomInOut}
199
+ >
200
+ {previousViewport && 'Back'}
201
+ {!previousViewport && (xyviewport.zoom < 1.01 ? 'Zoom' : 'Center')}
202
+ </Button>
203
+ </Box>
204
+ </Group>
205
+ <ScrollAreaAutosize
206
+ mah={MAX_HEIGHT}
207
+ mt={10}
208
+ className={clsx(
209
+ (detailsHeight > MAX_HEIGHT) && (
210
+ (previousViewport || xyviewport.zoom > 1.1) ? 'nowheel' : css.noPointerEvents
211
+ )
212
+ )}
213
+ pb={'sm'}
214
+ scrollbars="y"
215
+ type="hover">
216
+ <Stack gap={'md'} px="xs" ref={ref}>
217
+ {element.notation && <Text style={{ maxWidth: width }}>{element.notation}</Text>}
218
+ {defaultView && !!onNavigateTo && (
219
+ <Box>
220
+ <Divider label="view of this element" />
221
+ <ViewButton view={defaultView} onNavigateTo={onNavigateToCb} />
222
+ </Box>
223
+ )}
224
+ {(incoming.length + outgoing.length) > 0 && (
225
+ <Box>
226
+ <Divider label="relationships" />
227
+ <Group gap={'xs'} justify="space-between" wrap="nowrap" align="baseline">
228
+ <Box>
229
+ <Group gap={6} c={'dimmed'} mb={4} wrap="nowrap">
230
+ <Text
231
+ className={css.edgeNum}
232
+ mod={{
233
+ zero: incoming.length === 0,
234
+ missing: incoming.length !== incomingInView.length
235
+ }}>
236
+ {incoming.length !== incomingInView.length
237
+ ? (
238
+ <>
239
+ {incomingInView.length} / {incoming.length}
240
+ </>
241
+ )
242
+ : (
243
+ <>
244
+ {incoming.length}
245
+ </>
246
+ )}
247
+ </Text>
248
+ <IconArrowRight style={{ width: 14 }} />
249
+ <Text className={css.fqn}>{nameFromFqn(element.id)}</Text>
250
+ <IconArrowRight style={{ width: 14 }} />
251
+ <Text
252
+ className={css.edgeNum}
253
+ mod={{
254
+ zero: outgoing.length === 0,
255
+ missing: outgoing.length !== outgoingInView.length
256
+ }}>
257
+ {outgoing.length !== outgoingInView.length
258
+ ? (
259
+ <>
260
+ {outgoingInView.length} / {outgoing.length}
261
+ </>
262
+ )
263
+ : (
264
+ <>
265
+ {outgoing.length}
266
+ </>
267
+ )}
268
+ </Text>
269
+ </Group>
270
+ {notIncludedRelations > 0 && (
271
+ <Group
272
+ mt={'xs'}
273
+ gap={6}
274
+ c="orange"
275
+ style={{ cursor: 'pointer' }}
276
+ onClick={openRelationshipsOverlay}>
277
+ <IconInfoCircle style={{ width: 12 }} />
278
+ <Text size="xs">
279
+ {notIncludedRelations} relationship{notIncludedRelations > 1 ? 's are' : ' is'} hidden
280
+ </Text>
281
+ </Group>
282
+ )}
283
+ </Box>
284
+ <Box>
285
+ <Button
286
+ size="xs"
287
+ color="gray"
288
+ variant="light"
289
+ radius="sm"
290
+ leftSection={<IconTransform stroke={1.8} style={{ width: '67%' }} />}
291
+ styles={{
292
+ section: {
293
+ marginInline: 0
294
+ }
295
+ }}
296
+ onClick={openRelationshipsOverlay}>
297
+ Browse
298
+ </Button>
299
+ </Box>
300
+ </Group>
301
+ </Box>
302
+ )}
303
+ {element.links && (
304
+ <Box>
305
+ <Divider label="links" />
306
+ <Stack gap={'xs'}>
307
+ {element.links.map((link) => (
308
+ <Group key={link.url} wrap="nowrap" gap={'sm'}>
309
+ <Box
310
+ flex={'1'}
311
+ style={{ overflow: 'clip', maxWidth: clamp(element.width, { min: 200, max: 400 }) }}>
312
+ <Anchor href={link.url} target="_blank" fz="13" truncate="end">
313
+ {link.title || link.url}
314
+ </Anchor>
315
+ </Box>
316
+ <CopyButton value={link.url}>
317
+ {({ copied, copy }) => (
318
+ <Button
319
+ size="compact-xs"
320
+ fz={'10'}
321
+ variant="light"
322
+ onClick={copy}
323
+ color={copied ? 'teal' : 'gray'}
324
+ >
325
+ {copied ? 'copied' : 'copy'}
326
+ </Button>
327
+ )}
328
+ </CopyButton>
329
+ </Group>
330
+ ))}
331
+ </Stack>
332
+ </Box>
333
+ )}
334
+ {otherViews.length > 0 && !!onNavigateTo && (
335
+ <Box>
336
+ <Divider label="views including this element" />
337
+ <Stack gap={'xs'}>
338
+ {otherViews.map((view) => (
339
+ <ViewButton
340
+ key={view.id}
341
+ view={view}
342
+ onNavigateTo={onNavigateToCb}
343
+ />
344
+ ))}
345
+ </Stack>
346
+ </Box>
347
+ )}
348
+ </Stack>
349
+ </ScrollAreaAutosize>
350
+ {
351
+ /* <Group mt={'sm'}>
352
+ <Stack gap={2}>
353
+ <Button size="compact-sm" variant="light" radius="sm" color="gray" justify='start'>Base</Button>
354
+ <Button size="compact-sm" variant="subtle" radius="sm" color="gray" justify='start'>Links</Button>
355
+ <Button size="compact-sm" variant="subtle" radius="sm" color="gray" justify='start'>Views</Button>
356
+ <Button size="compact-sm" variant="subtle" radius="sm" color="gray" justify='start'>Metadata</Button>
357
+ </Stack>
358
+ </Group> */
359
+ }
360
+ </Paper>
361
+ </ViewportPortal>
362
+ )
363
+ })
364
+
365
+ const ViewButton = ({
366
+ view,
367
+ onNavigateTo
368
+ }: {
369
+ view: ComputedView | DiagramView
370
+ onNavigateTo: OnNavigateTo
371
+ }) => {
372
+ return (
373
+ <Button
374
+ className={css.viewButton}
375
+ variant="subtle"
376
+ radius="sm"
377
+ color="gray"
378
+ leftSection={<IconZoomScan stroke={1.8} />}
379
+ styles={{
380
+ section: {
381
+ marginInline: 2,
382
+ alignSelf: 'baseline'
383
+ }
384
+ }}
385
+ justify="start"
386
+ onClick={e => {
387
+ onNavigateTo(view.id, e)
388
+ }}>
389
+ <Text component="div">{view.title || 'untitled'}</Text>
390
+ <Text component="div" lh={1.2} fz={'xs'} c="dimmed">id: {view.id}</Text>
391
+ </Button>
392
+ )
393
+ }