@likec4/diagram 1.13.0 → 1.15.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 (43) hide show
  1. package/package.json +9 -9
  2. package/src/LikeC4CustomColors.tsx +6 -3
  3. package/src/LikeC4Diagram.css.ts +4 -0
  4. package/src/LikeC4Diagram.tsx +9 -7
  5. package/src/overlays/Overlays.css.ts +124 -0
  6. package/src/overlays/Overlays.tsx +210 -0
  7. package/src/overlays/edge-details/EdgeDetailsXYFlow.tsx +247 -0
  8. package/src/overlays/edge-details/SelectEdge.css.ts +99 -0
  9. package/src/overlays/edge-details/SelectEdge.tsx +153 -0
  10. package/src/overlays/edge-details/_types.ts +38 -0
  11. package/src/overlays/edge-details/use-layouted-edge-details.ts +356 -0
  12. package/src/overlays/edge-details/xyflow/CompoundNode.tsx +80 -0
  13. package/src/overlays/edge-details/xyflow/ElementNode.tsx +154 -0
  14. package/src/overlays/edge-details/xyflow/RelationshipEdge.tsx +81 -0
  15. package/src/overlays/edge-details/xyflow/styles.css.ts +196 -0
  16. package/src/overlays/index.tsx +1 -33
  17. package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +124 -63
  18. package/src/overlays/relationships-of/_types.ts +13 -2
  19. package/src/overlays/relationships-of/use-layouted-relationships.ts +37 -37
  20. package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +2 -2
  21. package/src/overlays/relationships-of/xyflow/ElementNode.tsx +47 -26
  22. package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +58 -22
  23. package/src/overlays/relationships-of/xyflow/styles.css.ts +53 -16
  24. package/src/state/DiagramContext.tsx +23 -5
  25. package/src/state/diagram-to-xyflow.ts +31 -20
  26. package/src/state/diagramStore.ts +30 -23
  27. package/src/xyflow/XYFlow.tsx +2 -1
  28. package/src/xyflow/XYFlowEvents.tsx +13 -1
  29. package/src/xyflow/edges/RelationshipEdge.tsx +10 -4
  30. package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +24 -21
  31. package/src/xyflow/nodes/compound/CompoundNode.css.ts +1 -0
  32. package/src/xyflow/nodes/compound/CompoundNode.tsx +11 -11
  33. package/src/xyflow/nodes/shared/Toolbar.tsx +1 -1
  34. package/src/xyflow/types.ts +1 -0
  35. package/src/xyflow/useLayoutConstraints.ts +38 -19
  36. package/src/components/Overlay.css.ts +0 -78
  37. package/src/components/Overlay.tsx +0 -102
  38. package/src/components/index.ts +0 -2
  39. package/src/overlays/edge-details/EdgeDetailsOverlay.css.ts +0 -13
  40. package/src/overlays/edge-details/EdgeDetailsOverlay.tsx +0 -37
  41. package/src/overlays/relationships-of/RelationshipsOverlay.tsx +0 -33
  42. package/src/overlays/relationships-of/styles.css.ts +0 -34
  43. /package/src/{components → overlays}/OverlayContext.tsx +0 -0
@@ -0,0 +1,247 @@
1
+ import { type DiagramEdge, type EdgeId, isAncestor } from '@likec4/core'
2
+ import { Box, Group, Text } from '@mantine/core'
3
+ import { useDebouncedCallback, useSyncedRef, useUpdateEffect } from '@react-hookz/web'
4
+ import {
5
+ getViewportForBounds,
6
+ Panel,
7
+ ReactFlow,
8
+ type ReactFlowInstance,
9
+ useReactFlow,
10
+ useStoreApi,
11
+ ViewportPortal
12
+ } from '@xyflow/react'
13
+ import { memo, useEffect } from 'react'
14
+ import { useDiagramStoreApi } from '../../hooks/useDiagramState'
15
+ import type { XYFlowTypes } from './_types'
16
+ import { SelectEdge } from './SelectEdge'
17
+ import * as css from './SelectEdge.css'
18
+ import { useLayoutedEdgeDetails, ZIndexes } from './use-layouted-edge-details'
19
+ import { CompoundNode } from './xyflow/CompoundNode'
20
+ import { ElementNode } from './xyflow/ElementNode'
21
+ import { RelationshipEdge } from './xyflow/RelationshipEdge'
22
+
23
+ const nodeTypes = {
24
+ element: ElementNode,
25
+ compound: CompoundNode
26
+ }
27
+ const edgeTypes = {
28
+ relation: RelationshipEdge
29
+ }
30
+
31
+ const resetDimmedAndHovered = (xyflow: ReactFlowInstance<XYFlowTypes.Node, XYFlowTypes.Edge>) => {
32
+ xyflow.setEdges(edges =>
33
+ edges.map(edge => ({
34
+ ...edge,
35
+ data: {
36
+ ...edge.data,
37
+ dimmed: false,
38
+ hovered: false
39
+ },
40
+ animated: false
41
+ }))
42
+ )
43
+ xyflow.setNodes(nodes =>
44
+ nodes.map(n =>
45
+ ({
46
+ ...n,
47
+ data: {
48
+ ...n.data,
49
+ dimmed: false,
50
+ hovered: false
51
+ }
52
+ }) as XYFlowTypes.Node
53
+ )
54
+ )
55
+ }
56
+
57
+ const animateEdge = (node: XYFlowTypes.Node, animated = true) => (edges: XYFlowTypes.Edge[]) => {
58
+ return edges.map(edge => {
59
+ const isConnected = edge.source === node.id || edge.target === node.id || isAncestor(node.id, edge.source)
60
+ || isAncestor(node.id, edge.target)
61
+ return {
62
+ ...edge,
63
+ animated: animated && isConnected
64
+ }
65
+ })
66
+ }
67
+
68
+ export const EdgeDetailsXYFlow = memo<{ edgeId: EdgeId }>(function EdgeDetailsXYFlow({ edgeId }) {
69
+ const diagramStore = useDiagramStoreApi()
70
+
71
+ const {
72
+ view,
73
+ edge,
74
+ edges,
75
+ nodes,
76
+ bounds
77
+ } = useLayoutedEdgeDetails(edgeId)
78
+
79
+ const boundsRef = useSyncedRef(bounds)
80
+
81
+ const xyflow = useReactFlow<XYFlowTypes.Node, XYFlowTypes.Edge>()
82
+ const xystore = useStoreApi<XYFlowTypes.Node, XYFlowTypes.Edge>()
83
+
84
+ const fitview = useDebouncedCallback(
85
+ () => {
86
+ const {
87
+ width,
88
+ height
89
+ } = xystore.getState()
90
+ const viewport = getViewportForBounds(
91
+ {
92
+ ...boundsRef.current,
93
+ height: Math.max(boundsRef.current.height + 100, height - 200) // Add some padding to the bottom
94
+ },
95
+ width,
96
+ height,
97
+ 0.2,
98
+ 1,
99
+ 0.2
100
+ )
101
+ xyflow.setViewport(viewport, { duration: 350 })
102
+ },
103
+ [xyflow],
104
+ 150
105
+ )
106
+
107
+ useEffect(() => {
108
+ xyflow.setNodes(nodes)
109
+ xyflow.setEdges(edges)
110
+ }, [nodes, edges])
111
+
112
+ const zoomable = true
113
+
114
+ useUpdateEffect(() => fitview(), [edge.id])
115
+
116
+ return (
117
+ <ReactFlow
118
+ defaultEdges={[] as XYFlowTypes.Edge[]}
119
+ defaultNodes={[] as XYFlowTypes.Node[]}
120
+ nodeTypes={nodeTypes}
121
+ edgeTypes={edgeTypes}
122
+ defaultMarkerColor="var(--xy-edge-stroke)"
123
+ zoomOnPinch={zoomable}
124
+ zoomOnScroll={false}
125
+ zoomOnDoubleClick={false}
126
+ maxZoom={1.5}
127
+ minZoom={0.1}
128
+ fitView
129
+ fitViewOptions={{
130
+ padding: 0.2,
131
+ maxZoom: 1,
132
+ minZoom: 0.1,
133
+ includeHiddenNodes: true
134
+ }}
135
+ preventScrolling={true}
136
+ noDragClassName="nodrag"
137
+ noPanClassName="nopan"
138
+ panOnScroll
139
+ panOnDrag
140
+ elementsSelectable
141
+ nodesFocusable={false}
142
+ edgesFocusable={false}
143
+ nodesDraggable={false}
144
+ // onNodeMouseEnter={(_, node) => {
145
+ // if (node.type === 'empty') return
146
+ // xyflow.setEdges(animateEdge(node, true))
147
+ // }}
148
+ // onNodeMouseLeave={() => {
149
+ // resetDimmedAndHovered(xyflow)
150
+ // }}
151
+ onEdgeMouseEnter={(_, edge) => {
152
+ xyflow.setEdges(edges =>
153
+ edges.map(e => ({
154
+ ...e,
155
+ data: {
156
+ ...e.data,
157
+ dimmed: e.id !== edge.id,
158
+ hovered: e.id === edge.id
159
+ },
160
+ zIndex: e.id === edge.id ? ZIndexes.max : ZIndexes.edge,
161
+ animated: e.id === edge.id
162
+ }))
163
+ )
164
+ xyflow.setNodes(nodes =>
165
+ nodes.map(n => ({
166
+ ...n,
167
+ data: {
168
+ ...n.data,
169
+ dimmed: n.id !== edge.source && n.id !== edge.target
170
+ }
171
+ } as XYFlowTypes.Node))
172
+ )
173
+ }}
174
+ onEdgeMouseLeave={() => {
175
+ resetDimmedAndHovered(xyflow)
176
+ }}
177
+ // onNodeClick={(e, node) => {
178
+ // e.stopPropagation()
179
+ // if (node.type === 'empty') return
180
+ // if (subject.id !== node.data.fqn) {
181
+ // lastClickedNodeRef.current = node
182
+ // }
183
+ // overlay.openOverlay({
184
+ // relationshipsOf: node.data.fqn
185
+ // })
186
+ // }}
187
+ // onDoubleClick={e => {
188
+ // e.stopPropagation()
189
+ // xyflow.fitView({
190
+ // includeHiddenNodes: true,
191
+ // maxZoom: 1,
192
+ // duration: 450
193
+ // })
194
+ // }}
195
+ onEdgeClick={(e, edge) => {
196
+ e.stopPropagation()
197
+ diagramStore.getState().onOpenSourceRelation?.(edge.data.relation.id)
198
+ }}
199
+ // onEdgeClick={(e, edge) => {
200
+ // e.stopPropagation()
201
+ // if (edge.data.relations.length <= 1) {
202
+ // const relationId = only(edge.data.relations)?.id
203
+ // if (relationId) {
204
+ // diagramStore.getState().onOpenSourceRelation?.(relationId)
205
+ // }
206
+ // return
207
+ // }
208
+ // const nodeId = onlyOneUnique(edge.data, 'source') ? edge.source : edge.target
209
+ // const next = xyflow.getNode(nodeId)
210
+ // if (next && next.type !== 'empty') {
211
+ // lastClickedNodeRef.current = next
212
+ // overlay.openOverlay({
213
+ // relationshipsOf: next.data.fqn
214
+ // })
215
+ // }
216
+ // }}
217
+ >
218
+ <Panel position="top-center">
219
+ <Group gap={'xs'} wrap={'nowrap'}>
220
+ <SelectEdge
221
+ view={view}
222
+ edge={edge} />
223
+ </Group>
224
+ </Panel>
225
+ <EdgeData edge={edge} top={bounds.height} width={bounds.width} />
226
+ </ReactFlow>
227
+ )
228
+ })
229
+
230
+ const EdgeData = ({ edge, top, width }: { edge: DiagramEdge; top: number; width: number }) => {
231
+ return (
232
+ <ViewportPortal>
233
+ <Box
234
+ maw={width}
235
+ style={{
236
+ transform: `translate(100px, ${top + 32}px)`
237
+ }}>
238
+ <Box className={css.edgeDataGrid}>
239
+ <Text size="xs" fw={500} c="dimmed">technology</Text>
240
+ <Text>{edge.technology || 'unknown'}</Text>
241
+ <Text size="xs" fw={500} c="dimmed">description</Text>
242
+ <Text>{edge.description || 'no description'}</Text>
243
+ </Box>
244
+ </Box>
245
+ </ViewportPortal>
246
+ )
247
+ }
@@ -0,0 +1,99 @@
1
+ import { globalStyle, style } from '@vanilla-extract/css'
2
+ import { mantine } from '../../theme-vars'
3
+
4
+ const edgeEnpoint = style({
5
+ display: 'inline-flex',
6
+ alignItems: 'center',
7
+ padding: '6px 2px 0 2px'
8
+ })
9
+
10
+ globalStyle(`${edgeEnpoint} .mantine-Text-root`, {
11
+ fontSize: mantine.fontSizes.xs,
12
+ fontWeight: 500,
13
+ lineHeight: 1
14
+ })
15
+
16
+ export const edgeSource = style([{
17
+ paddingLeft: 4,
18
+ gridColumn: 1
19
+ }, edgeEnpoint])
20
+ export const edgeArrow = style([{
21
+ gridColumn: 2
22
+ }, edgeEnpoint])
23
+ export const edgeTarget = style([{
24
+ gridColumn: 3,
25
+ paddingRight: 4
26
+ }, edgeEnpoint])
27
+
28
+ export const edgeLabel = style({
29
+ display: 'grid',
30
+ gridColumnStart: 1,
31
+ gridColumnEnd: 4,
32
+ borderBottom: `1px solid ${mantine.colors.defaultBorder}`,
33
+ marginBottom: '0px',
34
+ padding: '0 4px 5px 4px'
35
+ // gridArea: '2 / 1 / 3 / 3',
36
+ // selectors: {
37
+ // '&:last-child': {
38
+ // borderBottom: 'none',
39
+ // marginBottom: 0
40
+ // }
41
+ // }
42
+ })
43
+
44
+ globalStyle(`${edgeLabel} .mantine-Text-root`, {
45
+ fontSize: mantine.fontSizes.sm,
46
+ fontWeight: 400,
47
+ color: mantine.colors.dimmed
48
+ })
49
+
50
+ export const edgeRow = style({
51
+ display: 'contents'
52
+ })
53
+
54
+ globalStyle(`${edgeRow}:last-child ${edgeLabel}`, {
55
+ borderBottom: 'none',
56
+ marginBottom: 0
57
+ })
58
+
59
+ globalStyle(`${edgeRow} > *`, {
60
+ transition: 'all 0.15s ease-in'
61
+ })
62
+
63
+ globalStyle(`${edgeRow}:is(:hover, [data-selected=true]) > *`, {
64
+ transition: 'all 0.15s ease-out',
65
+ cursor: 'pointer',
66
+ backgroundColor: mantine.colors.defaultHover
67
+ })
68
+
69
+ export const edgeGrid = style({
70
+ display: 'grid',
71
+ gridTemplateColumns: '1fr 30px 1fr',
72
+ gridAutoRows: 'min-content max-content',
73
+ gap: 0,
74
+ alignItems: 'stretch'
75
+ // gridTemplateColumns: 'auto',
76
+ // gridTemplateRows: `
77
+ // [row1-start] "source target" auto [row1-end]
78
+ // [row2-start] "label label" 50px [row2-end]
79
+ // `,
80
+ // gridTemplateAreas: `
81
+ // "source target"
82
+ // "label label"
83
+ // `
84
+
85
+ // gridTemplateRows: `
86
+ // repeat(auto-fill, [row1-start] "source target" 20px [row1-end]
87
+ // [row2-start] "label" . 20px [row2-end])
88
+ // `,
89
+ // gridAutoFlow: 'row',
90
+ })
91
+
92
+ export const edgeDataGrid = style({
93
+ display: 'grid',
94
+ gridTemplateColumns: 'min-content 1fr',
95
+ gridAutoRows: 'min-content max-content',
96
+ gap: '10px 12px',
97
+ alignItems: 'baseline',
98
+ justifyItems: 'start'
99
+ })
@@ -0,0 +1,153 @@
1
+ import { type DiagramEdge, type DiagramView } from '@likec4/core'
2
+ import {
3
+ Box,
4
+ Button,
5
+ Popover,
6
+ PopoverDropdown,
7
+ PopoverTarget,
8
+ ScrollAreaAutosize,
9
+ Text,
10
+ ThemeIcon,
11
+ useComputedColorScheme
12
+ } from '@mantine/core'
13
+ import { IconArrowRight, IconSelector } from '@tabler/icons-react'
14
+ import { useRef } from 'react'
15
+ import { useOverlayDialog } from '../OverlayContext'
16
+ import * as css from './SelectEdge.css'
17
+
18
+ export const SelectEdge = ({
19
+ edge,
20
+ view
21
+ }: {
22
+ edge: DiagramEdge
23
+ view: DiagramView
24
+ // onSelect: (id: Fqn) => void
25
+ }) => {
26
+ const overlay = useOverlayDialog()
27
+ const viewport = useRef<HTMLDivElement>(null)
28
+ // const data = useLikeC4ElementsTree(scope === 'view' ? viewId : undefined)
29
+ // const tree = useTree({
30
+ // multiple: false
31
+ // })
32
+
33
+ // useEffect(() => {
34
+ // ancestorsFqn(subject.id).reverse().forEach(id => {
35
+ // tree.expand(id)
36
+ // })
37
+ // tree.select(subject.id)
38
+ // }, [subject.id])
39
+
40
+ const theme = useComputedColorScheme()
41
+
42
+ const edgeSource = view.nodes.find(n => n.id === edge.source)!
43
+ const edgeTarget = view.nodes.find(n => n.id === edge.target)!
44
+
45
+ const edges = view.edges.map(edge => {
46
+ const source = view.nodes.find(n => n.id === edge.source)!
47
+ const target = view.nodes.find(n => n.id === edge.target)!
48
+ return {
49
+ id: edge.id,
50
+ source,
51
+ target,
52
+ label: edge.label
53
+ }
54
+ })
55
+
56
+ return (
57
+ <Popover
58
+ position="bottom"
59
+ shadow="md"
60
+ keepMounted
61
+ withinPortal={false}
62
+ closeOnClickOutside
63
+ clickOutsideEvents={['pointerdown', 'mousedown', 'click']}
64
+ onOpen={() => {
65
+ setTimeout(() => {
66
+ const item = viewport.current?.querySelector(`[data-edge-id="${edge.id}"]`)
67
+ item?.scrollIntoView({ behavior: 'instant', block: 'nearest' })
68
+ }, 100)
69
+ }}
70
+ >
71
+ <PopoverTarget>
72
+ <Button
73
+ size="xs"
74
+ variant="light"
75
+ color={theme === 'light' ? 'dark' : 'gray'}
76
+ fw={'500'}
77
+ style={{ padding: '0.25rem 0.75rem' }}
78
+ rightSection={<IconSelector size={16} />}
79
+ >
80
+ <Box
81
+ className={css.edgeSource}
82
+ maw={160}
83
+ p={0}
84
+ mod={{
85
+ 'likec4-color': edgeSource.color
86
+ }}
87
+ >
88
+ <Text component="span" truncate>{edgeSource.title}</Text>
89
+ </Box>
90
+ <ThemeIcon color="dark" variant="transparent" size={'xs'}>
91
+ <IconArrowRight style={{ width: '80%' }} />
92
+ </ThemeIcon>
93
+ <Box
94
+ className={css.edgeTarget}
95
+ maw={160}
96
+ p={0}
97
+ mod={{
98
+ 'likec4-color': edgeTarget.color
99
+ }}
100
+ >
101
+ <Text component="span" truncate>{edgeTarget.title}</Text>
102
+ </Box>
103
+ </Button>
104
+ </PopoverTarget>
105
+ <PopoverDropdown p={0} miw={250} maw={420}>
106
+ <ScrollAreaAutosize mah={'70vh'} scrollbars="y" type="never" viewportRef={viewport}>
107
+ <Box className={css.edgeGrid} p="xs">
108
+ {edges.map(e => (
109
+ <div
110
+ key={e.id}
111
+ className={css.edgeRow}
112
+ data-selected={e.id === edge.id}
113
+ onClick={event => {
114
+ event.stopPropagation()
115
+ overlay.openOverlay({
116
+ edgeDetails: e.id
117
+ })
118
+ }}>
119
+ <Box
120
+ className={css.edgeSource}
121
+ maw={160}
122
+ mod={{
123
+ 'edge-id': e.id,
124
+ 'likec4-color': e.source.color
125
+ }}
126
+ >
127
+ <Text component="span" truncate>{e.source.title}</Text>
128
+ </Box>
129
+ <Box className={css.edgeArrow}>
130
+ <ThemeIcon color="dark" variant="transparent" size={'xs'}>
131
+ <IconArrowRight style={{ width: '80%' }} />
132
+ </ThemeIcon>
133
+ </Box>
134
+ <Box
135
+ className={css.edgeTarget}
136
+ maw={160}
137
+ mod={{
138
+ 'likec4-color': e.target.color
139
+ }}
140
+ >
141
+ <Text component="span" truncate>{e.target.title}</Text>
142
+ </Box>
143
+ <Box className={css.edgeLabel}>
144
+ <Text component="span" truncate>{e.label || 'untitled'}</Text>
145
+ </Box>
146
+ </div>
147
+ ))}
148
+ </Box>
149
+ </ScrollAreaAutosize>
150
+ </PopoverDropdown>
151
+ </Popover>
152
+ )
153
+ }
@@ -0,0 +1,38 @@
1
+ import type { ComputedNode, Fqn, Relation, ViewID } from '@likec4/core'
2
+ import type { Edge as ReactFlowEdge, Node as ReactFlowNode, ReactFlowInstance } from '@xyflow/react'
3
+ import type { SetRequired } from 'type-fest'
4
+
5
+ export namespace XYFlowTypes {
6
+ type NodeProps = {
7
+ fqn: Fqn
8
+ element: Pick<ComputedNode, 'color' | 'title' | 'description' | 'shape' | 'kind'>
9
+ ports: {
10
+ in: string[]
11
+ out: string[]
12
+ }
13
+ navigateTo: ViewID | null
14
+ hovered?: boolean
15
+ /**
16
+ * Whether the node is dimmed
17
+ * 'immediate' means that the node is dimmed without delay
18
+ */
19
+ dimmed?: 'immediate' | boolean
20
+ }
21
+
22
+ export type ElementNode = SetRequired<ReactFlowNode<NodeProps, 'element'>, 'type'>
23
+
24
+ export type CompoundNode = SetRequired<ReactFlowNode<NodeProps, 'compound'>, 'type'>
25
+
26
+ export type Node = ElementNode | CompoundNode
27
+
28
+ export type Edge = Omit<ReactFlowEdge, 'data' | 'type'> & {
29
+ data: {
30
+ relation: Relation
31
+ hovered?: boolean
32
+ dimmed?: boolean
33
+ }
34
+ type: 'relation'
35
+ }
36
+
37
+ export type Instance = ReactFlowInstance<Node, Edge>
38
+ }