@likec4/diagram 1.12.1 → 1.13.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.
- package/package.json +9 -9
- package/src/LikeC4CustomColors.tsx +2 -2
- package/src/LikeC4Diagram.props.ts +15 -7
- package/src/LikeC4Diagram.tsx +23 -14
- package/src/StaticLikeC4Diagram.tsx +2 -0
- package/src/components/Overlay.css.ts +78 -0
- package/src/components/Overlay.tsx +102 -0
- package/src/components/OverlayContext.tsx +11 -0
- package/src/components/index.ts +2 -0
- package/src/likec4model/index.ts +1 -0
- package/src/likec4model/useLikeC4ElementsTree.ts +44 -0
- package/src/overlays/edge-details/EdgeDetailsOverlay.css.ts +13 -0
- package/src/overlays/edge-details/EdgeDetailsOverlay.tsx +37 -0
- package/src/overlays/index.tsx +33 -0
- package/src/overlays/relationships-of/RelationshipsOverlay.tsx +33 -0
- package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +359 -0
- package/src/overlays/relationships-of/SelectElement.css.ts +18 -0
- package/src/overlays/relationships-of/SelectElement.tsx +133 -0
- package/src/overlays/relationships-of/_types.ts +56 -0
- package/src/overlays/relationships-of/styles.css.ts +34 -0
- package/src/overlays/relationships-of/use-layouted-relationships.ts +607 -0
- package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +76 -0
- package/src/overlays/relationships-of/xyflow/ElementNode.tsx +130 -0
- package/src/overlays/relationships-of/xyflow/EmptyNode.tsx +22 -0
- package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +59 -0
- package/src/overlays/relationships-of/xyflow/styles.css.ts +170 -0
- package/src/state/diagram-to-xyflow.ts +1 -1
- package/src/state/diagramStore.ts +119 -27
- package/src/ui/top-left/ChangeAutoLayoutButton.tsx +35 -18
- package/src/ui/top-left/ManualLayoutToolsButton.tsx +110 -0
- package/src/ui/top-left/TopLeftPanel.tsx +15 -31
- package/src/ui/top-left/_shared.ts +2 -2
- package/src/xyflow/EdgeMarkers.tsx +19 -0
- package/src/xyflow/XYFlowEvents.tsx +21 -24
- package/src/xyflow/XYFlowInner.tsx +6 -10
- package/src/xyflow/edges/EdgeLabel.tsx +6 -5
- package/src/xyflow/edges/RelationshipEdge.tsx +4 -3
- package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +16 -0
- package/src/xyflow/edges/edges.css.ts +5 -11
- package/src/xyflow/nodes/compound/CompoundNode.css.ts +17 -40
- package/src/xyflow/nodes/compound/CompoundNode.tsx +219 -28
- package/src/xyflow/nodes/element/ElementLink.tsx +11 -8
- package/src/xyflow/nodes/element/ElementNode.tsx +211 -140
- package/src/xyflow/nodes/element/element.css.ts +22 -33
- package/src/xyflow/nodes/shared/Toolbar.tsx +81 -27
- package/src/xyflow/nodes/use-animate-variants.tsx +64 -0
- package/src/xyflow/useLayoutConstraints.ts +95 -125
- package/src/xyflow/utils.ts +4 -0
- package/src/xyflow/nodes/shared/NavigateToBtn.tsx +0 -32
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { ActionIcon, Box, Text as MantineText } from '@mantine/core'
|
|
2
|
+
import { IconZoomScan } from '@tabler/icons-react'
|
|
3
|
+
import { Handle, type NodeProps, Position } from '@xyflow/react'
|
|
4
|
+
import clsx from 'clsx'
|
|
5
|
+
import { m } from 'framer-motion'
|
|
6
|
+
import { useOverlayDialog } from '../../../components'
|
|
7
|
+
import { type DiagramState, useDiagramState } from '../../../hooks'
|
|
8
|
+
import { ElementShapeSvg } from '../../../xyflow/nodes/element/ElementShapeSvg'
|
|
9
|
+
import { stopPropagation } from '../../../xyflow/utils'
|
|
10
|
+
import type { XYFlowTypes } from '../_types'
|
|
11
|
+
import * as css from './styles.css'
|
|
12
|
+
|
|
13
|
+
const Text = MantineText.withProps({
|
|
14
|
+
component: 'div'
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
type ElementNodeProps = NodeProps<XYFlowTypes.ElementNode>
|
|
18
|
+
|
|
19
|
+
function selector(s: DiagramState) {
|
|
20
|
+
return {
|
|
21
|
+
viewId: s.view.id,
|
|
22
|
+
onNavigateTo: s.onNavigateTo
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ElementNode({
|
|
27
|
+
data: {
|
|
28
|
+
element,
|
|
29
|
+
ports,
|
|
30
|
+
navigateTo,
|
|
31
|
+
...data
|
|
32
|
+
},
|
|
33
|
+
selectable = true,
|
|
34
|
+
width: w = 100,
|
|
35
|
+
height: h = 100
|
|
36
|
+
}: ElementNodeProps) {
|
|
37
|
+
const overlay = useOverlayDialog()
|
|
38
|
+
const {
|
|
39
|
+
viewId,
|
|
40
|
+
onNavigateTo
|
|
41
|
+
} = useDiagramState(selector)
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<m.div
|
|
45
|
+
className={clsx([
|
|
46
|
+
css.elementNode,
|
|
47
|
+
'likec4-element-node'
|
|
48
|
+
])}
|
|
49
|
+
data-likec4-color={element.color}
|
|
50
|
+
animate={{
|
|
51
|
+
opacity: data.dimmed ? 0.15 : 1,
|
|
52
|
+
transition: {
|
|
53
|
+
delay: data.dimmed ? .8 : 0
|
|
54
|
+
}
|
|
55
|
+
}}
|
|
56
|
+
{...(selectable && {
|
|
57
|
+
whileHover: {
|
|
58
|
+
scale: 1.045,
|
|
59
|
+
transition: {
|
|
60
|
+
delay: 0.15
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
whileTap: {
|
|
64
|
+
scale: 0.97
|
|
65
|
+
}
|
|
66
|
+
})}
|
|
67
|
+
>
|
|
68
|
+
<svg
|
|
69
|
+
className={clsx(
|
|
70
|
+
css.cssShapeSvg
|
|
71
|
+
)}
|
|
72
|
+
viewBox={`0 0 ${w} ${h}`}
|
|
73
|
+
width={w}
|
|
74
|
+
height={h}
|
|
75
|
+
>
|
|
76
|
+
<ElementShapeSvg
|
|
77
|
+
shape={element.shape}
|
|
78
|
+
w={w}
|
|
79
|
+
h={h} />
|
|
80
|
+
</svg>
|
|
81
|
+
<Box className={css.elementNodeContent}>
|
|
82
|
+
<Text className={css.elementNodeTitle} lineClamp={2}>{element.title}</Text>
|
|
83
|
+
{element.description && (
|
|
84
|
+
<Text className={css.elementNodeDescription} lineClamp={4}>{element.description}</Text>
|
|
85
|
+
)}
|
|
86
|
+
</Box>
|
|
87
|
+
{navigateTo && onNavigateTo && navigateTo !== viewId && (
|
|
88
|
+
<Box className={css.navigateBtnBox}>
|
|
89
|
+
<ActionIcon
|
|
90
|
+
className={clsx('nodrag nopan', css.navigateBtn)}
|
|
91
|
+
radius="md"
|
|
92
|
+
onClick={(event) => {
|
|
93
|
+
event.stopPropagation()
|
|
94
|
+
setTimeout(() => onNavigateTo(navigateTo), 200)
|
|
95
|
+
overlay.close()
|
|
96
|
+
}}
|
|
97
|
+
role="button"
|
|
98
|
+
onDoubleClick={stopPropagation}
|
|
99
|
+
onPointerDownCapture={stopPropagation}
|
|
100
|
+
>
|
|
101
|
+
<IconZoomScan stroke={1.8} style={{ width: '75%' }} />
|
|
102
|
+
</ActionIcon>
|
|
103
|
+
</Box>
|
|
104
|
+
)}
|
|
105
|
+
</m.div>
|
|
106
|
+
{ports.left.map(({ id, type }, i) => (
|
|
107
|
+
<Handle
|
|
108
|
+
key={id}
|
|
109
|
+
id={id}
|
|
110
|
+
type={type === 'in' ? 'target' : 'source'}
|
|
111
|
+
position={Position.Left}
|
|
112
|
+
style={{
|
|
113
|
+
visibility: 'hidden',
|
|
114
|
+
top: `${15 + (i + 1) * ((h - 30) / (ports.left.length + 1))}px`
|
|
115
|
+
}} />
|
|
116
|
+
))}
|
|
117
|
+
{ports.right.map(({ id, type }, i) => (
|
|
118
|
+
<Handle
|
|
119
|
+
key={id}
|
|
120
|
+
id={id}
|
|
121
|
+
type={type === 'in' ? 'target' : 'source'}
|
|
122
|
+
position={Position.Right}
|
|
123
|
+
style={{
|
|
124
|
+
visibility: 'hidden',
|
|
125
|
+
top: `${15 + (i + 1) * ((h - 30) / (ports.right.length + 1))}px`
|
|
126
|
+
}} />
|
|
127
|
+
))}
|
|
128
|
+
</>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Box, Text as MantineText } from '@mantine/core'
|
|
2
|
+
import { type NodeProps } from '@xyflow/react'
|
|
3
|
+
import type { XYFlowTypes } from '../_types'
|
|
4
|
+
import * as css from './styles.css'
|
|
5
|
+
|
|
6
|
+
const Text = MantineText.withProps({
|
|
7
|
+
component: 'div'
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
type EmptyNodeProps = NodeProps<XYFlowTypes.EmptyNode>
|
|
11
|
+
|
|
12
|
+
export function EmptyNode({
|
|
13
|
+
data: {
|
|
14
|
+
column
|
|
15
|
+
}
|
|
16
|
+
}: EmptyNodeProps) {
|
|
17
|
+
return (
|
|
18
|
+
<Box className={css.emptyNode}>
|
|
19
|
+
<Text c={'dimmed'} fw={500}>No {column === 'incomers' ? 'incoming' : 'outgoing'}</Text>
|
|
20
|
+
</Box>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Group, Text, ThemeIcon } from '@mantine/core'
|
|
2
|
+
import { IconBoxMultipleFilled } from '@tabler/icons-react'
|
|
3
|
+
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getBezierPath } from '@xyflow/react'
|
|
4
|
+
import clsx from 'clsx'
|
|
5
|
+
import type { XYFlowTypes } from '../_types'
|
|
6
|
+
import { ZIndexes } from '../use-layouted-relationships'
|
|
7
|
+
import * as css from './styles.css'
|
|
8
|
+
|
|
9
|
+
export function RelationshipEdge({
|
|
10
|
+
data,
|
|
11
|
+
label,
|
|
12
|
+
...props
|
|
13
|
+
}: EdgeProps<XYFlowTypes.Edge>) {
|
|
14
|
+
const [edgePath, labelX, labelY] = getBezierPath(props)
|
|
15
|
+
const isMultiRelation = data.relations.length > 1
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<g
|
|
19
|
+
className={css.edgePath}
|
|
20
|
+
data-edge-dimmed={data.dimmed}
|
|
21
|
+
data-edge-hovered={data.hovered}
|
|
22
|
+
>
|
|
23
|
+
<BaseEdge
|
|
24
|
+
path={edgePath}
|
|
25
|
+
{...props}
|
|
26
|
+
/>
|
|
27
|
+
</g>
|
|
28
|
+
{label && (
|
|
29
|
+
<EdgeLabelRenderer>
|
|
30
|
+
<div
|
|
31
|
+
style={{
|
|
32
|
+
position: 'absolute',
|
|
33
|
+
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
|
34
|
+
maxWidth: Math.abs(props.targetX - props.sourceX - 40),
|
|
35
|
+
zIndex: ZIndexes.max
|
|
36
|
+
}}
|
|
37
|
+
className={clsx([
|
|
38
|
+
css.edgeLabel,
|
|
39
|
+
'nodrag nopan'
|
|
40
|
+
])}
|
|
41
|
+
data-edge-dimmed={data.dimmed}
|
|
42
|
+
data-edge-hovered={data.hovered}
|
|
43
|
+
>
|
|
44
|
+
<Group gap={6} wrap="nowrap">
|
|
45
|
+
{isMultiRelation && (
|
|
46
|
+
<ThemeIcon size={'sm'} variant="transparent" color="orange">
|
|
47
|
+
<IconBoxMultipleFilled style={{ width: '80%' }} />
|
|
48
|
+
</ThemeIcon>
|
|
49
|
+
)}
|
|
50
|
+
<Text fw={isMultiRelation ? '500' : '400'} component={'div'} className={css.edgeLabelText} lineClamp={3}>
|
|
51
|
+
{label}
|
|
52
|
+
</Text>
|
|
53
|
+
</Group>
|
|
54
|
+
</div>
|
|
55
|
+
</EdgeLabelRenderer>
|
|
56
|
+
)}
|
|
57
|
+
</>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { style } from '@vanilla-extract/css'
|
|
2
|
+
import { mantine, vars, whereLight, xyvars } from '../../../theme-vars'
|
|
3
|
+
|
|
4
|
+
export const elementNode = style({
|
|
5
|
+
width: '100%',
|
|
6
|
+
height: '100%'
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export const elementNodeContent = style({
|
|
10
|
+
width: '100%',
|
|
11
|
+
height: '100%',
|
|
12
|
+
display: 'flex',
|
|
13
|
+
flexDirection: 'column',
|
|
14
|
+
justifyContent: 'center',
|
|
15
|
+
alignItems: 'center',
|
|
16
|
+
gap: 6,
|
|
17
|
+
padding: 16
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export const elementNodeTitle = style({
|
|
21
|
+
flex: '0 0 auto',
|
|
22
|
+
width: 'fit-content',
|
|
23
|
+
textAlign: 'center',
|
|
24
|
+
fontFamily: vars.element.font,
|
|
25
|
+
fontOpticalSizing: 'auto',
|
|
26
|
+
fontStyle: 'normal',
|
|
27
|
+
fontWeight: 500,
|
|
28
|
+
fontSize: 17,
|
|
29
|
+
lineHeight: 1.25,
|
|
30
|
+
textWrap: 'balance',
|
|
31
|
+
color: vars.element.hiContrast
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const elementNodeDescription = style({
|
|
35
|
+
flex: '0 0 auto',
|
|
36
|
+
width: 'fit-content',
|
|
37
|
+
textAlign: 'center',
|
|
38
|
+
fontFamily: vars.element.font,
|
|
39
|
+
fontOpticalSizing: 'auto',
|
|
40
|
+
fontStyle: 'normal',
|
|
41
|
+
fontSize: mantine.fontSizes.xs,
|
|
42
|
+
lineHeight: 1.25,
|
|
43
|
+
textWrap: 'balance',
|
|
44
|
+
color: vars.element.loContrast
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export const compoundNodeBody = style({
|
|
48
|
+
width: '100%',
|
|
49
|
+
height: '100%',
|
|
50
|
+
boxShadow: '0 4px 10px 0.5px rgba(0,0,0,0.1) , 0 2px 2px -1px rgba(0,0,0,0.4)',
|
|
51
|
+
background: `color-mix(in srgb , ${vars.element.fill}, transparent 45%)`,
|
|
52
|
+
borderRadius: 4
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const compoundNodeTitle = style({
|
|
56
|
+
fontFamily: vars.compound.font,
|
|
57
|
+
fontOpticalSizing: 'auto',
|
|
58
|
+
fontStyle: 'normal',
|
|
59
|
+
fontWeight: 600,
|
|
60
|
+
fontSize: 14,
|
|
61
|
+
lineHeight: 1,
|
|
62
|
+
textTransform: 'uppercase',
|
|
63
|
+
paddingTop: 12,
|
|
64
|
+
paddingLeft: 10,
|
|
65
|
+
mixBlendMode: 'screen',
|
|
66
|
+
color: vars.compound.titleColor,
|
|
67
|
+
selectors: {
|
|
68
|
+
[`${whereLight} &`]: {
|
|
69
|
+
mixBlendMode: 'darken',
|
|
70
|
+
color: vars.element.stroke
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
export const cssShapeSvg = style({
|
|
76
|
+
top: 0,
|
|
77
|
+
left: 0,
|
|
78
|
+
position: 'absolute',
|
|
79
|
+
pointerEvents: 'none',
|
|
80
|
+
fill: vars.element.fill,
|
|
81
|
+
stroke: vars.element.stroke,
|
|
82
|
+
overflow: 'visible',
|
|
83
|
+
filter: `
|
|
84
|
+
drop-shadow(0 2px 1px rgba(0, 0, 0, 0.21))
|
|
85
|
+
drop-shadow(0 1px 1px color-mix(in srgb, ${vars.element.stroke} 40%, transparent))
|
|
86
|
+
drop-shadow(0 5px 3px rgba(0, 0, 0, 0.1))
|
|
87
|
+
`,
|
|
88
|
+
zIndex: -1
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
export const edgePath = style({
|
|
92
|
+
opacity: 1,
|
|
93
|
+
transition: 'all 0.2s ease-in-out',
|
|
94
|
+
|
|
95
|
+
selectors: {
|
|
96
|
+
[`&[data-edge-dimmed='true']`]: {
|
|
97
|
+
opacity: 0.1,
|
|
98
|
+
transitionDelay: '800ms'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
export const edgeLabel = style({
|
|
104
|
+
padding: '2px 5px',
|
|
105
|
+
fontFamily: vars.likec4.font,
|
|
106
|
+
position: 'absolute',
|
|
107
|
+
cursor: 'pointer',
|
|
108
|
+
width: 'fit-content',
|
|
109
|
+
color: xyvars.edge.labelColor,
|
|
110
|
+
backgroundColor: xyvars.edge.labelBgColor,
|
|
111
|
+
borderRadius: 4,
|
|
112
|
+
// everything inside EdgeLabelRenderer has no pointer events by default
|
|
113
|
+
// if you have an interactive element, set pointer-events: all
|
|
114
|
+
pointerEvents: 'all',
|
|
115
|
+
textWrap: 'pretty',
|
|
116
|
+
whiteSpace: 'preserve-breaks',
|
|
117
|
+
opacity: 1,
|
|
118
|
+
transition: 'all 0.2s ease-in-out',
|
|
119
|
+
selectors: {
|
|
120
|
+
[`&[data-edge-dimmed='true']`]: {
|
|
121
|
+
opacity: 0.1,
|
|
122
|
+
// transitionDuration: '400ms',
|
|
123
|
+
transitionDelay: '800ms'
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
export const edgeLabelText = style({
|
|
129
|
+
textAlign: 'center',
|
|
130
|
+
whiteSpaceCollapse: 'preserve-breaks',
|
|
131
|
+
fontSize: mantine.fontSizes.sm,
|
|
132
|
+
lineHeight: mantine.lineHeights.xs
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
export const emptyNode = style({
|
|
136
|
+
width: '100%',
|
|
137
|
+
height: '100%',
|
|
138
|
+
border: `3px dashed ${mantine.colors.defaultBorder}`,
|
|
139
|
+
borderRadius: mantine.radius.md,
|
|
140
|
+
display: 'flex',
|
|
141
|
+
justifyContent: 'center',
|
|
142
|
+
alignItems: 'center'
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
export const navigateBtnBox = style({
|
|
146
|
+
zIndex: 100,
|
|
147
|
+
position: 'absolute',
|
|
148
|
+
left: '50%',
|
|
149
|
+
bottom: 2,
|
|
150
|
+
transform: 'translate(-50%, 0%)',
|
|
151
|
+
selectors: {
|
|
152
|
+
[`:where([data-likec4-shape='browser']) &`]: {
|
|
153
|
+
bottom: 4
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
export const navigateBtn = style({
|
|
158
|
+
pointerEvents: 'all',
|
|
159
|
+
color: vars.element.loContrast,
|
|
160
|
+
cursor: 'pointer',
|
|
161
|
+
backgroundColor: 'var(--ai-bg)',
|
|
162
|
+
'vars': {
|
|
163
|
+
'--ai-bg': `color-mix(in srgb , ${vars.element.fill}, transparent 99%)`,
|
|
164
|
+
'--ai-bg-hover': `color-mix(in srgb , ${vars.element.fill} 65%, ${vars.element.stroke})`,
|
|
165
|
+
'--ai-hover': `color-mix(in srgb , ${vars.element.fill} 50%, ${vars.element.stroke})`
|
|
166
|
+
},
|
|
167
|
+
':hover': {
|
|
168
|
+
boxShadow: mantine.shadows.md
|
|
169
|
+
}
|
|
170
|
+
})
|
|
@@ -73,7 +73,7 @@ export function diagramViewToXYFlowData(
|
|
|
73
73
|
// const inEdges = node.inEdges.map(e => view.edges.find(edge => edge.id === e)).filter(Boolean)
|
|
74
74
|
|
|
75
75
|
const id = ns + node.id
|
|
76
|
-
const draggable = opts.draggable
|
|
76
|
+
const draggable = opts.draggable
|
|
77
77
|
xynodes.push({
|
|
78
78
|
id,
|
|
79
79
|
type: isCompound ? 'compound' : 'element',
|
|
@@ -23,15 +23,16 @@ import {
|
|
|
23
23
|
applyEdgeChanges,
|
|
24
24
|
applyNodeChanges,
|
|
25
25
|
getViewportForBounds,
|
|
26
|
+
type InternalNode,
|
|
26
27
|
type OnEdgesChange,
|
|
27
28
|
type OnNodesChange
|
|
28
29
|
} from '@xyflow/react'
|
|
29
|
-
import { boxToRect, getBoundsOfRects, getNodeDimensions } from '@xyflow/system'
|
|
30
|
+
import { boxToRect, getBoundsOfRects, getNodeDimensions, type XYPosition } from '@xyflow/system'
|
|
30
31
|
import { DEV } from 'esm-env'
|
|
31
32
|
import { deepEqual as eq, shallowEqual } from 'fast-equals'
|
|
32
33
|
import type { MouseEvent as ReactMouseEvent } from 'react'
|
|
33
34
|
import { entries, first, hasAtLeast, isNullish, map, prop, reduce } from 'remeda'
|
|
34
|
-
import type { ConditionalKeys, Except, RequiredKeysOf, Simplify } from 'type-fest'
|
|
35
|
+
import type { ConditionalKeys, Except, RequiredKeysOf, RequireExactlyOne, Simplify } from 'type-fest'
|
|
35
36
|
import { devtools, subscribeWithSelector } from 'zustand/middleware'
|
|
36
37
|
import { shallow } from 'zustand/shallow'
|
|
37
38
|
import { createWithEqualityFn } from 'zustand/traditional'
|
|
@@ -45,6 +46,7 @@ import type {
|
|
|
45
46
|
import { type Vector, vector } from '../utils/vector'
|
|
46
47
|
import { MinZoom } from '../xyflow/const'
|
|
47
48
|
import type { XYFlowEdge, XYFlowInstance, XYFlowNode } from '../xyflow/types'
|
|
49
|
+
import { createLayoutConstraints } from '../xyflow/useLayoutConstraints'
|
|
48
50
|
import { bezierControlPoints, isInside, isSamePoint, toDomPrecision } from '../xyflow/utils'
|
|
49
51
|
import { diagramViewToXYFlowData } from './diagram-to-xyflow'
|
|
50
52
|
|
|
@@ -55,6 +57,9 @@ type RequiredOrNull<T> = {
|
|
|
55
57
|
export type DiagramInitialState = {
|
|
56
58
|
view: DiagramView
|
|
57
59
|
readonly: boolean
|
|
60
|
+
// If LikeC4Model provided
|
|
61
|
+
hasLikeC4Model: boolean
|
|
62
|
+
controls: boolean
|
|
58
63
|
showElementLinks: boolean
|
|
59
64
|
showNavigationButtons: boolean
|
|
60
65
|
showNotations: boolean
|
|
@@ -67,6 +72,7 @@ export type DiagramInitialState = {
|
|
|
67
72
|
nodesSelectable: boolean
|
|
68
73
|
experimentalEdgeEditing: boolean
|
|
69
74
|
enableFocusMode: boolean
|
|
75
|
+
enableRelationshipsBrowser: boolean
|
|
70
76
|
renderIcon: ElementIconRenderer | null
|
|
71
77
|
whereFilter: WhereOperator<string, string> | null
|
|
72
78
|
// If Dynamic View
|
|
@@ -79,6 +85,8 @@ export type DiagramInitialState = {
|
|
|
79
85
|
getContainer: () => HTMLDivElement | null
|
|
80
86
|
} & RequiredOrNull<LikeC4DiagramEventHandlers>
|
|
81
87
|
|
|
88
|
+
type AlignmentMode = 'Left' | 'Center' | 'Right' | 'Top' | 'Middle' | 'Bottom'
|
|
89
|
+
|
|
82
90
|
const StringSet = Set<string>
|
|
83
91
|
|
|
84
92
|
export type DiagramState = Simplify<
|
|
@@ -93,6 +101,13 @@ export type DiagramState = Simplify<
|
|
|
93
101
|
|
|
94
102
|
initialized: boolean
|
|
95
103
|
|
|
104
|
+
activeOverlay:
|
|
105
|
+
| null
|
|
106
|
+
| RequireExactlyOne<{
|
|
107
|
+
relationshipsOf: Fqn
|
|
108
|
+
edgeDetails: EdgeId
|
|
109
|
+
}>
|
|
110
|
+
|
|
96
111
|
// readonly=false and onChange is not null
|
|
97
112
|
isEditable: () => boolean
|
|
98
113
|
|
|
@@ -163,6 +178,9 @@ export type DiagramState = Simplify<
|
|
|
163
178
|
activateWalkthrough: (step: EdgeId | XYFlowEdge) => void
|
|
164
179
|
stopWalkthrough: () => void
|
|
165
180
|
|
|
181
|
+
openOverlay: (overlay: NonNullable<DiagramState['activeOverlay']>) => void
|
|
182
|
+
closeOverlay: () => void
|
|
183
|
+
|
|
166
184
|
onInit: (xyflow: XYFlowInstance) => void
|
|
167
185
|
onNodesChange: OnNodesChange<XYFlowNode>
|
|
168
186
|
onEdgesChange: OnEdgesChange<XYFlowEdge>
|
|
@@ -170,6 +188,7 @@ export type DiagramState = Simplify<
|
|
|
170
188
|
highlightByElementNotation: (notation: ElementNotation, onlyOfKind?: ElementKind) => void
|
|
171
189
|
|
|
172
190
|
resetEdgeControlPoints: () => void
|
|
191
|
+
align: (mode: AlignmentMode) => void
|
|
173
192
|
}
|
|
174
193
|
>
|
|
175
194
|
|
|
@@ -184,6 +203,7 @@ const DEFAULT_PROPS: Except<
|
|
|
184
203
|
initialized: false,
|
|
185
204
|
navigationHistoryIndex: 0,
|
|
186
205
|
viewportChanged: false,
|
|
206
|
+
activeOverlay: null,
|
|
187
207
|
activeWalkthrough: null,
|
|
188
208
|
focusedNodeId: null,
|
|
189
209
|
hoveredNodeId: null,
|
|
@@ -258,6 +278,7 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
258
278
|
lastClickedNodeId,
|
|
259
279
|
lastClickedEdgeId,
|
|
260
280
|
activeWalkthrough,
|
|
281
|
+
activeOverlay,
|
|
261
282
|
nodesDraggable,
|
|
262
283
|
nodesSelectable,
|
|
263
284
|
hoveredEdgeId,
|
|
@@ -364,6 +385,7 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
364
385
|
hoveredNodeId = null
|
|
365
386
|
focusedNodeId = null
|
|
366
387
|
activeWalkthrough = null
|
|
388
|
+
activeOverlay = null
|
|
367
389
|
dimmed = EmptyStringSet
|
|
368
390
|
}
|
|
369
391
|
|
|
@@ -429,6 +451,7 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
429
451
|
viewSyncDebounceTimeout,
|
|
430
452
|
view: nextView,
|
|
431
453
|
activeWalkthrough,
|
|
454
|
+
activeOverlay,
|
|
432
455
|
lastOnNavigate,
|
|
433
456
|
lastClickedNodeId,
|
|
434
457
|
lastClickedEdgeId,
|
|
@@ -453,6 +476,7 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
453
476
|
set(
|
|
454
477
|
{
|
|
455
478
|
activeWalkthrough: null,
|
|
479
|
+
activeOverlay: null,
|
|
456
480
|
focusedNodeId: null,
|
|
457
481
|
dimmed: EmptyStringSet
|
|
458
482
|
},
|
|
@@ -481,6 +505,7 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
481
505
|
set(
|
|
482
506
|
{
|
|
483
507
|
activeWalkthrough: null,
|
|
508
|
+
activeOverlay: null,
|
|
484
509
|
focusedNodeId: nodeId,
|
|
485
510
|
dimmed
|
|
486
511
|
},
|
|
@@ -515,27 +540,19 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
515
540
|
},
|
|
516
541
|
|
|
517
542
|
resetFocusAndLastClicked: () => {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
lastClickedEdgeId: null,
|
|
532
|
-
dimmed: EmptyStringSet
|
|
533
|
-
},
|
|
534
|
-
noReplace,
|
|
535
|
-
'resetLastClicked'
|
|
536
|
-
)
|
|
537
|
-
}
|
|
538
|
-
xystore.getState().resetSelectedElements()
|
|
543
|
+
set(
|
|
544
|
+
{
|
|
545
|
+
activeWalkthrough: null,
|
|
546
|
+
activeOverlay: null,
|
|
547
|
+
focusedNodeId: null,
|
|
548
|
+
lastClickedNodeId: null,
|
|
549
|
+
lastClickedEdgeId: null,
|
|
550
|
+
dimmed: EmptyStringSet
|
|
551
|
+
},
|
|
552
|
+
noReplace,
|
|
553
|
+
'resetLastClicked'
|
|
554
|
+
)
|
|
555
|
+
get().xystore.getState().resetSelectedElements()
|
|
539
556
|
},
|
|
540
557
|
|
|
541
558
|
getElement: (fqn) => {
|
|
@@ -740,10 +757,7 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
740
757
|
onChange?.({ change })
|
|
741
758
|
},
|
|
742
759
|
scheduleSaveManualLayout: () => {
|
|
743
|
-
|
|
744
|
-
if (viewSyncDebounceTimeout) {
|
|
745
|
-
clearTimeout(viewSyncDebounceTimeout)
|
|
746
|
-
}
|
|
760
|
+
clearTimeout(get().viewSyncDebounceTimeout ?? undefined)
|
|
747
761
|
set(
|
|
748
762
|
{
|
|
749
763
|
viewSyncDebounceTimeout: setTimeout(() => {
|
|
@@ -835,6 +849,25 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
835
849
|
}
|
|
836
850
|
},
|
|
837
851
|
|
|
852
|
+
openOverlay: (overlay) => {
|
|
853
|
+
set(
|
|
854
|
+
{
|
|
855
|
+
activeOverlay: overlay
|
|
856
|
+
},
|
|
857
|
+
noReplace,
|
|
858
|
+
'openOverlay'
|
|
859
|
+
)
|
|
860
|
+
},
|
|
861
|
+
closeOverlay: () => {
|
|
862
|
+
set(
|
|
863
|
+
{
|
|
864
|
+
activeOverlay: null
|
|
865
|
+
},
|
|
866
|
+
noReplace,
|
|
867
|
+
'openOverlay'
|
|
868
|
+
)
|
|
869
|
+
},
|
|
870
|
+
|
|
838
871
|
fitDiagram: (duration = 500) => {
|
|
839
872
|
const { fitViewPadding, view, xystore } = get()
|
|
840
873
|
const { width, height, panZoom, transform } = xystore.getState()
|
|
@@ -1024,6 +1057,65 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
1024
1057
|
|
|
1025
1058
|
return vector(v).mul(scale).add(nodeCenter)
|
|
1026
1059
|
}
|
|
1060
|
+
},
|
|
1061
|
+
align: (mode: AlignmentMode) => {
|
|
1062
|
+
const { scheduleSaveManualLayout, xystore } = get()
|
|
1063
|
+
const { nodeLookup, parentLookup } = xystore.getState()
|
|
1064
|
+
|
|
1065
|
+
const selectedNodes = new Set(nodeLookup.values().filter(n => n.selected).map(n => n.id))
|
|
1066
|
+
const nodesToAlign = new Set(selectedNodes.difference(new Set(parentLookup.keys())))
|
|
1067
|
+
const constraints = createLayoutConstraints(xystore, nodesToAlign)
|
|
1068
|
+
|
|
1069
|
+
let getEdgePosition: (nodes: InternalNode<XYFlowNode>[]) => number
|
|
1070
|
+
let getPosition: (alignTo: number, node: InternalNode<XYFlowNode>) => number
|
|
1071
|
+
let propertyToEdit: keyof XYPosition
|
|
1072
|
+
|
|
1073
|
+
switch (mode) {
|
|
1074
|
+
case 'Left':
|
|
1075
|
+
getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.x))
|
|
1076
|
+
propertyToEdit = 'x'
|
|
1077
|
+
getPosition = (alignTo, _) => Math.floor(alignTo)
|
|
1078
|
+
break
|
|
1079
|
+
case 'Top':
|
|
1080
|
+
getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.y))
|
|
1081
|
+
propertyToEdit = 'y'
|
|
1082
|
+
getPosition = (alignTo, _) => Math.floor(alignTo)
|
|
1083
|
+
break
|
|
1084
|
+
case 'Right':
|
|
1085
|
+
getEdgePosition = nodes =>
|
|
1086
|
+
Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width))
|
|
1087
|
+
propertyToEdit = 'x'
|
|
1088
|
+
getPosition = (alignTo, node) => Math.floor(alignTo - node.width!)
|
|
1089
|
+
break
|
|
1090
|
+
case 'Bottom':
|
|
1091
|
+
getEdgePosition = nodes =>
|
|
1092
|
+
Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height))
|
|
1093
|
+
propertyToEdit = 'y'
|
|
1094
|
+
getPosition = (alignTo, node) => Math.floor(alignTo - node.height!)
|
|
1095
|
+
break
|
|
1096
|
+
case 'Center':
|
|
1097
|
+
getEdgePosition = nodes =>
|
|
1098
|
+
Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width / 2))
|
|
1099
|
+
propertyToEdit = 'x'
|
|
1100
|
+
getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).width / 2)
|
|
1101
|
+
break
|
|
1102
|
+
case 'Middle':
|
|
1103
|
+
getEdgePosition = nodes =>
|
|
1104
|
+
Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height / 2))
|
|
1105
|
+
propertyToEdit = 'y'
|
|
1106
|
+
getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).height / 2)
|
|
1107
|
+
break
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
constraints.onMove((nodes) => {
|
|
1111
|
+
const alignTo = getEdgePosition(nodes.map(({ node }) => node))
|
|
1112
|
+
|
|
1113
|
+
nodes.forEach(({ rect, node }) => {
|
|
1114
|
+
rect.positionAbsolute = { ...rect.positionAbsolute, [propertyToEdit]: getPosition(alignTo, node) }
|
|
1115
|
+
})
|
|
1116
|
+
})
|
|
1117
|
+
|
|
1118
|
+
scheduleSaveManualLayout()
|
|
1027
1119
|
}
|
|
1028
1120
|
} satisfies DiagramState),
|
|
1029
1121
|
{
|