@likec4/diagram 1.2.2 → 1.3.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/dist/LikeC4Diagram.d.ts +1 -1
- package/dist/LikeC4Diagram.props.d.ts +21 -3
- package/dist/index.mjs +1335 -1240
- package/dist/state/DiagramContext.d.ts +2263 -175
- package/dist/state/diagramStore.d.ts +2272 -180
- package/dist/state/useDiagramStore.d.ts +2409 -321
- package/dist/style.css +123 -92
- package/dist/theme.css.d.ts +0 -1
- package/dist/ui/OptionsPanel.d.ts +3 -1
- package/dist/xyflow/XYFlow.d.ts +2 -1
- package/dist/xyflow/XYFlowInner.d.ts +1 -1
- package/dist/xyflow/const.d.ts +1 -1
- package/dist/xyflow/diagram-to-xyflow.d.ts +1 -1
- package/dist/xyflow/edges/RelationshipEdge.d.ts +3 -4
- package/dist/xyflow/edges/utils.d.ts +4 -2
- package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +3 -1
- package/dist/xyflow/nodes/element/ElementNode.d.ts +1 -1
- package/dist/xyflow/nodes/element/index.d.ts +1 -1
- package/dist/xyflow/types.d.ts +2 -2
- package/dist/xyflow/utils.d.ts +3 -0
- package/package.json +15 -14
- package/src/LikeC4Diagram.css.ts +4 -4
- package/src/LikeC4Diagram.props.ts +23 -2
- package/src/LikeC4Diagram.tsx +79 -31
- package/src/globals.css.ts +0 -1
- package/src/mantine/theme.ts +1 -0
- package/src/state/DiagramContext.tsx +0 -1
- package/src/state/diagramStore.ts +177 -100
- package/src/theme.css.ts +0 -1
- package/src/ui/DiagramTitlePanel.tsx +3 -3
- package/src/ui/DynamicViewWalkthrough.css.ts +3 -2
- package/src/ui/DynamicViewWalkthrough.tsx +36 -17
- package/src/ui/OptionsPanel.tsx +12 -12
- package/src/ui/options/NodeOptions.tsx +16 -19
- package/src/xyflow/FitviewOnDiagramChange.tsx +15 -23
- package/src/xyflow/SelectEdgesOnNodeFocus.tsx +3 -6
- package/src/xyflow/SyncWithDiagram.tsx +21 -2
- package/src/xyflow/XYFlow.tsx +20 -23
- package/src/xyflow/XYFlowEvents.tsx +26 -8
- package/src/xyflow/XYFlowInner.tsx +5 -4
- package/src/xyflow/const.ts +1 -1
- package/src/xyflow/diagram-to-xyflow.ts +5 -31
- package/src/xyflow/edges/RelationshipEdge.tsx +158 -112
- package/src/xyflow/edges/edges.css.ts +32 -21
- package/src/xyflow/edges/utils.ts +5 -2
- package/src/xyflow/hooks/useLayoutConstraints.ts +17 -3
- package/src/xyflow/nodes/compound/CompoundNode.css.ts +40 -32
- package/src/xyflow/nodes/compound/CompoundNode.tsx +36 -26
- package/src/xyflow/nodes/element/ElementNode.tsx +20 -6
- package/src/xyflow/nodes/element/element.css.ts +15 -15
- package/src/xyflow/nodes/element/index.ts +1 -1
- package/src/xyflow/nodes/shared/NavigateToBtn.tsx +7 -3
- package/src/xyflow/types.ts +5 -2
- package/src/xyflow/utils.ts +48 -0
- package/dist/ui/index.d.ts +0 -2
- package/src/ui/index.ts +0 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Anchor, Box, Button, CopyButton, Group, Paper, Spoiler, Stack, Text } from '@mantine/core'
|
|
2
2
|
import { useLocalStorage } from '@mantine/hooks'
|
|
3
3
|
import clsx from 'clsx'
|
|
4
|
-
import { AnimatePresence,
|
|
4
|
+
import { AnimatePresence, m } from 'framer-motion'
|
|
5
5
|
import { type DiagramState, useDiagramState } from '../state'
|
|
6
6
|
import * as css from './DiagramTitlePanel.css'
|
|
7
7
|
|
|
@@ -22,7 +22,7 @@ export default function DiagramTitlePanel() {
|
|
|
22
22
|
|
|
23
23
|
return (
|
|
24
24
|
<AnimatePresence mode="wait">
|
|
25
|
-
<
|
|
25
|
+
<m.div
|
|
26
26
|
key={id}
|
|
27
27
|
initial={{ opacity: 0.05, scale: 0.7 }}
|
|
28
28
|
animate={{ opacity: 1, scale: 1 }}
|
|
@@ -132,7 +132,7 @@ export default function DiagramTitlePanel() {
|
|
|
132
132
|
)}
|
|
133
133
|
</Stack>
|
|
134
134
|
</Paper>
|
|
135
|
-
</
|
|
135
|
+
</m.div>
|
|
136
136
|
</AnimatePresence>
|
|
137
137
|
)
|
|
138
138
|
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Box, Button } from '@mantine/core'
|
|
2
|
+
import { useHotkeys } from '@mantine/hooks'
|
|
2
3
|
import {
|
|
3
|
-
IconHeart,
|
|
4
|
-
IconPhoto,
|
|
5
4
|
IconPlayerPlayFilled,
|
|
6
5
|
IconPlayerSkipBackFilled,
|
|
7
6
|
IconPlayerSkipForwardFilled,
|
|
8
|
-
IconPlayerStopFilled
|
|
9
|
-
IconSettings
|
|
7
|
+
IconPlayerStopFilled
|
|
10
8
|
} from '@tabler/icons-react'
|
|
11
9
|
import clsx from 'clsx'
|
|
12
10
|
import { isNullish, isNumber } from 'remeda'
|
|
13
11
|
import { useDiagramState } from '../state/useDiagramStore'
|
|
12
|
+
import { useXYStore } from '../xyflow/hooks/useXYFlow'
|
|
14
13
|
import * as css from './DynamicViewWalkthrough.css'
|
|
15
14
|
|
|
16
15
|
export function DynamicViewWalkthrough() {
|
|
16
|
+
const isMobile = useXYStore(s => s.width <= 750)
|
|
17
17
|
const {
|
|
18
18
|
nextDynamicStep,
|
|
19
19
|
stopDynamicView,
|
|
@@ -26,10 +26,28 @@ export function DynamicViewWalkthrough() {
|
|
|
26
26
|
hasNextSteps: (s.activeDynamicViewStep ?? 1) < s.view.edges.length
|
|
27
27
|
}))
|
|
28
28
|
|
|
29
|
+
const isActive = isNumber(activeDynamicViewStep)
|
|
30
|
+
|
|
31
|
+
useHotkeys(
|
|
32
|
+
isActive
|
|
33
|
+
? [
|
|
34
|
+
['ArrowLeft', () => nextDynamicStep(-1)],
|
|
35
|
+
['ArrowRight', () => nextDynamicStep()],
|
|
36
|
+
['Escape', (e) => {
|
|
37
|
+
e.stopImmediatePropagation()
|
|
38
|
+
stopDynamicView()
|
|
39
|
+
}, { preventDefault: true }]
|
|
40
|
+
]
|
|
41
|
+
: [
|
|
42
|
+
['ArrowLeft', () => nextDynamicStep()],
|
|
43
|
+
['ArrowRight', () => nextDynamicStep()]
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
|
|
29
47
|
const buttonProps = {
|
|
30
48
|
className: css.btn,
|
|
31
|
-
size: 'compact-
|
|
32
|
-
radius: 'xl'
|
|
49
|
+
size: isMobile ? 'compact-md' : 'lg',
|
|
50
|
+
radius: isMobile ? 'lg' : 'xl'
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
const nextStep = (increment = 1) => (e: React.MouseEvent) => {
|
|
@@ -64,21 +82,22 @@ export function DynamicViewWalkthrough() {
|
|
|
64
82
|
|
|
65
83
|
<Button
|
|
66
84
|
{...buttonProps}
|
|
67
|
-
px={'
|
|
85
|
+
px={hasNextSteps ? 'md' : 'xl'}
|
|
68
86
|
onClick={e => {
|
|
69
87
|
e.stopPropagation()
|
|
70
88
|
stopDynamicView()
|
|
71
89
|
}}>
|
|
72
|
-
<IconPlayerStopFilled />
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<Button
|
|
76
|
-
{...buttonProps}
|
|
77
|
-
pr={'lg'}
|
|
78
|
-
disabled={!hasNextSteps}
|
|
79
|
-
onClick={nextStep()}>
|
|
80
|
-
<IconPlayerSkipForwardFilled />
|
|
90
|
+
{hasNextSteps && <IconPlayerStopFilled />}
|
|
91
|
+
{!hasNextSteps && 'End'}
|
|
81
92
|
</Button>
|
|
93
|
+
{hasNextSteps && (
|
|
94
|
+
<Button
|
|
95
|
+
{...buttonProps}
|
|
96
|
+
pr={'lg'}
|
|
97
|
+
onClick={nextStep()}>
|
|
98
|
+
<IconPlayerSkipForwardFilled />
|
|
99
|
+
</Button>
|
|
100
|
+
)}
|
|
82
101
|
</Button.Group>
|
|
83
102
|
)}
|
|
84
103
|
</Box>
|
package/src/ui/OptionsPanel.tsx
CHANGED
|
@@ -1,31 +1,29 @@
|
|
|
1
1
|
import { Card } from '@mantine/core'
|
|
2
2
|
import { useOnSelectionChange } from '@xyflow/react'
|
|
3
3
|
import clsx from 'clsx'
|
|
4
|
-
import {
|
|
5
|
-
import { AnimatePresence,
|
|
6
|
-
import { useState } from 'react'
|
|
7
|
-
import { isNullish
|
|
4
|
+
import { deepEqual as eq } from 'fast-equals'
|
|
5
|
+
import { AnimatePresence, m } from 'framer-motion'
|
|
6
|
+
import { memo, useState } from 'react'
|
|
7
|
+
import { isNullish } from 'remeda'
|
|
8
8
|
import { useDiagramState } from '../state'
|
|
9
9
|
import { NodeOptions } from './options/NodeOptions'
|
|
10
10
|
import * as styles from './OptionsPanel.css'
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
const OptionsPanelMemo = memo(function OptionsPanel() {
|
|
13
13
|
const isFocusDisabled = useDiagramState(s => isNullish(s.focusedNodeId))
|
|
14
14
|
const [selectedNodes, setSelectedNodes] = useState([] as string[])
|
|
15
15
|
|
|
16
16
|
useOnSelectionChange({
|
|
17
17
|
onChange: ({ nodes }) => {
|
|
18
|
-
const next = nodes.map(n => n.id)
|
|
19
|
-
setSelectedNodes(prev =>
|
|
20
|
-
return shallowEqual(prev, next) ? prev : next
|
|
21
|
-
})
|
|
18
|
+
const next = nodes.map(n => n.id)
|
|
19
|
+
setSelectedNodes(prev => eq(prev, next) ? prev : next)
|
|
22
20
|
}
|
|
23
21
|
})
|
|
24
22
|
|
|
25
23
|
return (
|
|
26
24
|
<AnimatePresence mode="wait">
|
|
27
25
|
{isFocusDisabled && selectedNodes.length > 0 && (
|
|
28
|
-
<
|
|
26
|
+
<m.div
|
|
29
27
|
initial={{ opacity: 0, scale: 0.85 }}
|
|
30
28
|
animate={{ opacity: 1, scale: 1 }}
|
|
31
29
|
exit={{
|
|
@@ -41,8 +39,10 @@ export default function OptionsPanel() {
|
|
|
41
39
|
<Card shadow="sm">
|
|
42
40
|
<NodeOptions selectedNodeIds={selectedNodes} />
|
|
43
41
|
</Card>
|
|
44
|
-
</
|
|
42
|
+
</m.div>
|
|
45
43
|
)}
|
|
46
44
|
</AnimatePresence>
|
|
47
45
|
)
|
|
48
|
-
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export default OptionsPanelMemo
|
|
@@ -22,17 +22,14 @@ import {
|
|
|
22
22
|
Tooltip,
|
|
23
23
|
TooltipGroup
|
|
24
24
|
} from '@mantine/core'
|
|
25
|
-
import {
|
|
25
|
+
import { useState } from 'react'
|
|
26
26
|
import { hasAtLeast, keys, takeWhile } from 'remeda'
|
|
27
|
+
import { useUpdateEffect } from '../../hooks/useUpdateEffect'
|
|
27
28
|
import type { Changes } from '../../LikeC4Diagram.props'
|
|
28
29
|
import { useDiagramStoreApi } from '../../state'
|
|
29
30
|
import { useXYNodesData } from '../../xyflow/hooks'
|
|
30
31
|
import { XYFlowNode } from '../../xyflow/types'
|
|
31
32
|
|
|
32
|
-
// const ColorPanel = () => {
|
|
33
|
-
// const selectedNodes = useStore(state => state.nodeInternals
|
|
34
|
-
// }
|
|
35
|
-
|
|
36
33
|
const {
|
|
37
34
|
primary,
|
|
38
35
|
secondary,
|
|
@@ -78,20 +75,20 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
|
|
|
78
75
|
const triggerChange = (style: Changes.ChangeElementStyle['style']) => {
|
|
79
76
|
const targets = nodes.map(node => node.data.element.id)
|
|
80
77
|
invariant(hasAtLeast(targets, 1), 'At least one target is required')
|
|
81
|
-
diagramApi.getState().
|
|
78
|
+
diagramApi.getState().triggerChangeElementStyle({
|
|
82
79
|
op: 'change-element-style',
|
|
83
80
|
style,
|
|
84
81
|
targets
|
|
85
|
-
}
|
|
82
|
+
})
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
return (
|
|
89
86
|
<Stack>
|
|
90
|
-
<
|
|
87
|
+
<div>
|
|
91
88
|
<Text size="xs" c={rest.length > 0 ? 'dimmed' : ''} truncate>
|
|
92
89
|
{rest.length === 0 ? firstNode.data.element.title : `[ multiple ]`}
|
|
93
90
|
</Text>
|
|
94
|
-
</
|
|
91
|
+
</div>
|
|
95
92
|
{showShapeOption && (
|
|
96
93
|
<ShapeOption
|
|
97
94
|
nodes={nodes}
|
|
@@ -106,7 +103,7 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
|
|
|
106
103
|
}} />
|
|
107
104
|
|
|
108
105
|
{showOpacityOption && (
|
|
109
|
-
<
|
|
106
|
+
<div key={firstNode.id}>
|
|
110
107
|
<Divider label="opacity and border" labelPosition="left" />
|
|
111
108
|
<OpacityOption
|
|
112
109
|
node={firstNode}
|
|
@@ -119,7 +116,7 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
|
|
|
119
116
|
triggerChange({ border })
|
|
120
117
|
}}
|
|
121
118
|
/>
|
|
122
|
-
</
|
|
119
|
+
</div>
|
|
123
120
|
)}
|
|
124
121
|
<NavigateToOption nodes={nodes} />
|
|
125
122
|
</Stack>
|
|
@@ -148,7 +145,7 @@ function Colors({
|
|
|
148
145
|
onColorChange(color)
|
|
149
146
|
}
|
|
150
147
|
return (
|
|
151
|
-
<
|
|
148
|
+
<div>
|
|
152
149
|
<TooltipGroup openDelay={400} closeDelay={300}>
|
|
153
150
|
<Divider label="color" labelPosition="left" />
|
|
154
151
|
<Flex mt={'xs'} maw={150} gap="xs" justify="flex-start" align="flex-start" direction="row" wrap="wrap">
|
|
@@ -194,7 +191,7 @@ function Colors({
|
|
|
194
191
|
))}
|
|
195
192
|
</Flex>
|
|
196
193
|
</TooltipGroup>
|
|
197
|
-
</
|
|
194
|
+
</div>
|
|
198
195
|
)
|
|
199
196
|
}
|
|
200
197
|
|
|
@@ -215,7 +212,7 @@ function ShapeOption({
|
|
|
215
212
|
})
|
|
216
213
|
|
|
217
214
|
return (
|
|
218
|
-
<
|
|
215
|
+
<div>
|
|
219
216
|
<Divider label="shape" labelPosition="left" />
|
|
220
217
|
<Select
|
|
221
218
|
mt={'xs'}
|
|
@@ -232,7 +229,7 @@ function ShapeOption({
|
|
|
232
229
|
}
|
|
233
230
|
onShapeChange(value as ElementShape)
|
|
234
231
|
}} />
|
|
235
|
-
</
|
|
232
|
+
</div>
|
|
236
233
|
)
|
|
237
234
|
}
|
|
238
235
|
|
|
@@ -243,7 +240,7 @@ function NavigateToOption({
|
|
|
243
240
|
}) {
|
|
244
241
|
const isMultiple = nodes.length >= 1
|
|
245
242
|
return (
|
|
246
|
-
<
|
|
243
|
+
<div>
|
|
247
244
|
<Divider label="navigate to" labelPosition="left" />
|
|
248
245
|
<Select
|
|
249
246
|
mt={'xs'}
|
|
@@ -265,7 +262,7 @@ function NavigateToOption({
|
|
|
265
262
|
// onShapeChange(value as ElementShape)
|
|
266
263
|
// }} />
|
|
267
264
|
/>
|
|
268
|
-
</
|
|
265
|
+
</div>
|
|
269
266
|
)
|
|
270
267
|
}
|
|
271
268
|
|
|
@@ -278,7 +275,7 @@ function OpacityOption({
|
|
|
278
275
|
}) {
|
|
279
276
|
let selectedOpacity = node.data.element.style.opacity ?? 100
|
|
280
277
|
const [value, setValue] = useState(selectedOpacity)
|
|
281
|
-
|
|
278
|
+
useUpdateEffect(() => {
|
|
282
279
|
setValue(selectedOpacity)
|
|
283
280
|
}, [selectedOpacity])
|
|
284
281
|
|
|
@@ -302,7 +299,7 @@ function BorderStyleOption({
|
|
|
302
299
|
}) {
|
|
303
300
|
let selecteBorderStyle = node.data.element.style.border ?? 'dashed'
|
|
304
301
|
const [value, setValue] = useState(selecteBorderStyle)
|
|
305
|
-
|
|
302
|
+
useUpdateEffect(() => {
|
|
306
303
|
setValue(selecteBorderStyle)
|
|
307
304
|
}, [selecteBorderStyle])
|
|
308
305
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { invariant } from '@likec4/core'
|
|
1
2
|
import { useDebouncedEffect } from '@react-hookz/web'
|
|
3
|
+
import { shallowEqual } from 'fast-equals'
|
|
2
4
|
import { useEffect, useRef } from 'react'
|
|
3
5
|
import { type DiagramState, type DiagramStoreApi, useDiagramState, useDiagramStoreApi } from '../state'
|
|
4
6
|
import { useXYStore, useXYStoreApi, type XYStoreApi } from './hooks'
|
|
@@ -26,8 +28,8 @@ function FitViewOnViewportResize({ diagramApi, xyflowApi }: {
|
|
|
26
28
|
prevDimensionsRef.current = dimensions
|
|
27
29
|
},
|
|
28
30
|
[dimensions, diagramApi, xyflowApi],
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
250,
|
|
32
|
+
1000
|
|
31
33
|
)
|
|
32
34
|
|
|
33
35
|
return null
|
|
@@ -72,7 +74,7 @@ export function FitViewOnDiagramChange() {
|
|
|
72
74
|
waitCorrection,
|
|
73
75
|
elFrom,
|
|
74
76
|
elTo
|
|
75
|
-
} = useDiagramState(selector)
|
|
77
|
+
} = useDiagramState(selector, shallowEqual)
|
|
76
78
|
const diagramApi = useDiagramStoreApi()
|
|
77
79
|
const processedRef = useRef(pendingViewId)
|
|
78
80
|
|
|
@@ -80,29 +82,19 @@ export function FitViewOnDiagramChange() {
|
|
|
80
82
|
if (!xyflowSynced || pendingViewId === processedRef.current) {
|
|
81
83
|
return
|
|
82
84
|
}
|
|
83
|
-
if (waitCorrection
|
|
84
|
-
|
|
85
|
+
if (waitCorrection) {
|
|
86
|
+
invariant(elTo, 'elTo should be defined')
|
|
87
|
+
invariant(elFrom, 'elFrom should be defined')
|
|
85
88
|
const { xyflow, lastOnNavigate } = diagramApi.getState()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (nextZoom !== 1 && nextZoom < zoom) {
|
|
92
|
-
xyflowApi.setState({ transform: [x, y, nextZoom] })
|
|
93
|
-
xyflow.setViewport({
|
|
94
|
-
x,
|
|
95
|
-
y,
|
|
96
|
-
zoom: nextZoom
|
|
97
|
-
})
|
|
98
|
-
}
|
|
99
|
-
const centerTo = xyflow.flowToScreenPosition({
|
|
100
|
-
x: elTo.position[0] + elTo.width / 2,
|
|
101
|
-
y: elTo.position[1] + elTo.height / 2
|
|
89
|
+
invariant(lastOnNavigate, 'lastOnNavigate should be defined')
|
|
90
|
+
const fromPos = lastOnNavigate.elementScreenPosition
|
|
91
|
+
const toPos = xyflow.flowToScreenPosition({
|
|
92
|
+
x: elTo.position[0], // + elFrom.width / 2,
|
|
93
|
+
y: elTo.position[1] // + elFrom.height / 2
|
|
102
94
|
}),
|
|
103
95
|
diff = {
|
|
104
|
-
x: toDomPrecision(
|
|
105
|
-
y: toDomPrecision(
|
|
96
|
+
x: toDomPrecision(fromPos.x - toPos.x),
|
|
97
|
+
y: toDomPrecision(fromPos.y - toPos.y)
|
|
106
98
|
}
|
|
107
99
|
xyflowApi.getState().panBy(diff)
|
|
108
100
|
diagramApi.setState({ lastOnNavigate: null })
|
|
@@ -30,10 +30,8 @@ export function SelectEdgesOnNodeFocus() {
|
|
|
30
30
|
type: 'select',
|
|
31
31
|
selected: true
|
|
32
32
|
})
|
|
33
|
-
const anotherNode = nodeLookup.get(edge.source === focusedNodeId ? edge.target : edge.source)
|
|
34
|
-
|
|
35
|
-
fitViewNodes.add(anotherNode)
|
|
36
|
-
}
|
|
33
|
+
const anotherNode = nonNullable(nodeLookup.get(edge.source === focusedNodeId ? edge.target : edge.source))
|
|
34
|
+
fitViewNodes.add(anotherNode)
|
|
37
35
|
} else if (edge.selected) {
|
|
38
36
|
edgeChanges.push({
|
|
39
37
|
id: edge.id,
|
|
@@ -62,8 +60,7 @@ export function SelectEdgesOnNodeFocus() {
|
|
|
62
60
|
triggerNodeChanges(nodeChanges)
|
|
63
61
|
|
|
64
62
|
fitView({
|
|
65
|
-
|
|
66
|
-
duration: 300,
|
|
63
|
+
duration: 350,
|
|
67
64
|
padding: 0.1,
|
|
68
65
|
minZoom: MinZoom,
|
|
69
66
|
maxZoom: Math.max(1, transform[2]),
|
|
@@ -52,10 +52,29 @@ export function SyncWithDiagram() {
|
|
|
52
52
|
setEdges(updates.edges.map(update => {
|
|
53
53
|
const existing = edgeLookup.get(update.id)
|
|
54
54
|
if (existing) {
|
|
55
|
-
|
|
55
|
+
if (eq(existing.data.controlPoints, update.data.controlPoints) && eq(existing.data.edge, update.data.edge)) {
|
|
56
|
+
return existing
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
56
59
|
...existing,
|
|
57
|
-
...update
|
|
60
|
+
...update,
|
|
61
|
+
data: {
|
|
62
|
+
...existing.data,
|
|
63
|
+
...update.data
|
|
64
|
+
}
|
|
58
65
|
}
|
|
66
|
+
// if (update.data.controlPoints === null && existing.data.controlPoints !== null) {
|
|
67
|
+
// // we don't want accidentally
|
|
68
|
+
// // overwrite existing control points
|
|
69
|
+
// return {
|
|
70
|
+
// ...existing,
|
|
71
|
+
// ...update,
|
|
72
|
+
// data: {
|
|
73
|
+
// ...update.data,
|
|
74
|
+
// controlPoints: existing.data.controlPoints
|
|
75
|
+
// }
|
|
76
|
+
// }
|
|
77
|
+
// }
|
|
59
78
|
}
|
|
60
79
|
return update
|
|
61
80
|
}))
|
package/src/xyflow/XYFlow.tsx
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { deepEqual as eq
|
|
4
|
-
import { type CSSProperties,
|
|
5
|
-
import
|
|
6
|
-
import type { SetNonNullable, Simplify } from 'type-fest'
|
|
7
|
-
import type { LikeC4DiagramProperties } from '../LikeC4Diagram.props'
|
|
1
|
+
import { ReactFlow, useOnViewportChange } from '@xyflow/react'
|
|
2
|
+
import { DEV } from 'esm-env'
|
|
3
|
+
import { deepEqual as eq } from 'fast-equals'
|
|
4
|
+
import { type CSSProperties, type PropsWithChildren, useCallback } from 'react'
|
|
5
|
+
import useTilg from 'tilg'
|
|
8
6
|
import { type DiagramState, useDiagramState, useDiagramStoreApi } from '../state'
|
|
9
7
|
import { MinZoom } from './const'
|
|
10
8
|
import { RelationshipEdge } from './edges/RelationshipEdge'
|
|
@@ -13,7 +11,6 @@ import { useXYStoreApi } from './hooks/useXYFlow'
|
|
|
13
11
|
import { CompoundNode } from './nodes/compound'
|
|
14
12
|
import { ElementNode } from './nodes/element'
|
|
15
13
|
import { XYFlowEdge, XYFlowNode } from './types'
|
|
16
|
-
import { XYFlowBackground } from './XYFlowBackground'
|
|
17
14
|
import { useXYFlowEvents } from './XYFlowEvents'
|
|
18
15
|
|
|
19
16
|
const nodeTypes = {
|
|
@@ -25,6 +22,7 @@ const edgeTypes = {
|
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
type XYFlowWrapperProps = PropsWithChildren<{
|
|
25
|
+
colorMode?: 'system' | 'light' | 'dark'
|
|
28
26
|
className?: string | undefined
|
|
29
27
|
defaultNodes: XYFlowNode[]
|
|
30
28
|
defaultEdges: XYFlowEdge[]
|
|
@@ -45,7 +43,12 @@ const selector = (s: DiagramState) => ({
|
|
|
45
43
|
nodesSelectable: s.nodesSelectable || s.focusedNodeId !== null,
|
|
46
44
|
nodesDraggable: s.nodesDraggable,
|
|
47
45
|
fitView: s.fitViewEnabled,
|
|
48
|
-
|
|
46
|
+
fitViewOptions: {
|
|
47
|
+
minZoom: MinZoom,
|
|
48
|
+
maxZoom: 1,
|
|
49
|
+
padding: s.fitViewPadding,
|
|
50
|
+
includeHiddenNodes: true
|
|
51
|
+
},
|
|
49
52
|
hasOnNavigateTo: !!s.onNavigateTo,
|
|
50
53
|
hasOnNodeClick: !!s.onNodeClick,
|
|
51
54
|
hasOnNodeContextMenu: !!s.onNodeContextMenu,
|
|
@@ -57,19 +60,21 @@ const selector = (s: DiagramState) => ({
|
|
|
57
60
|
})
|
|
58
61
|
|
|
59
62
|
export function XYFlow({
|
|
63
|
+
colorMode = 'system',
|
|
60
64
|
className,
|
|
61
65
|
children,
|
|
62
66
|
defaultNodes,
|
|
63
67
|
defaultEdges,
|
|
64
68
|
style
|
|
65
69
|
}: XYFlowWrapperProps) {
|
|
70
|
+
DEV && useTilg()
|
|
66
71
|
const xyflowApi = useXYStoreApi()
|
|
67
72
|
const diagramApi = useDiagramStoreApi()
|
|
68
73
|
const {
|
|
69
74
|
nodesSelectable,
|
|
70
75
|
nodesDraggable,
|
|
71
76
|
fitView,
|
|
72
|
-
|
|
77
|
+
fitViewOptions,
|
|
73
78
|
pannable,
|
|
74
79
|
zoomable,
|
|
75
80
|
hasOnNodeClick,
|
|
@@ -78,15 +83,12 @@ export function XYFlow({
|
|
|
78
83
|
hasOnCanvasContextMenu,
|
|
79
84
|
hasOnEdgeContextMenu,
|
|
80
85
|
hasOnEdgeClick
|
|
81
|
-
} = useDiagramState(selector)
|
|
86
|
+
} = useDiagramState(selector, eq)
|
|
82
87
|
|
|
83
88
|
const layoutConstraints = useLayoutConstraints()
|
|
84
89
|
|
|
85
90
|
const handlers = useXYFlowEvents()
|
|
86
91
|
|
|
87
|
-
const { colorScheme } = useMantineColorScheme()
|
|
88
|
-
const colorMode = colorScheme !== 'auto' ? colorScheme : undefined
|
|
89
|
-
|
|
90
92
|
/**
|
|
91
93
|
* WORKAROUND - Called on viewport change
|
|
92
94
|
* Viewport transform is not rounded to integers which results in blurry nodes on some resolution
|
|
@@ -122,12 +124,7 @@ export function XYFlow({
|
|
|
122
124
|
maxZoom={zoomable ? 1.9 : 1}
|
|
123
125
|
minZoom={zoomable ? MinZoom : 1}
|
|
124
126
|
fitView={fitView}
|
|
125
|
-
fitViewOptions={
|
|
126
|
-
minZoom: MinZoom,
|
|
127
|
-
maxZoom: 1,
|
|
128
|
-
padding: fitViewPadding,
|
|
129
|
-
includeHiddenNodes: true
|
|
130
|
-
}}
|
|
127
|
+
fitViewOptions={fitViewOptions}
|
|
131
128
|
preventScrolling={zoomable || pannable}
|
|
132
129
|
defaultMarkerColor="var(--xy-edge-stroke)"
|
|
133
130
|
noDragClassName="nodrag"
|
|
@@ -159,9 +156,9 @@ export function XYFlow({
|
|
|
159
156
|
onNodeClick={handlers.onNodeClick}
|
|
160
157
|
onNodeDoubleClick={handlers.onNodeDoubleClick}
|
|
161
158
|
onEdgeClick={handlers.onEdgeClick}
|
|
162
|
-
onInit={() => {
|
|
159
|
+
onInit={useCallback(() => {
|
|
163
160
|
diagramApi.setState({ initialized: true }, false, 'initialized')
|
|
164
|
-
}}
|
|
161
|
+
}, [diagramApi])}
|
|
165
162
|
{...(hasOnNodeContextMenu && {
|
|
166
163
|
onNodeContextMenu: handlers.onNodeContextMenu
|
|
167
164
|
})}
|
|
@@ -176,4 +173,4 @@ export function XYFlow({
|
|
|
176
173
|
)
|
|
177
174
|
}
|
|
178
175
|
|
|
179
|
-
// export const XYFlow = memo(XYFlowWrapper
|
|
176
|
+
// export const XYFlow = memo(XYFlowWrapper) as typeof XYFlowWrapper
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactFlowProps } from '@xyflow/react'
|
|
2
2
|
import { useMemo, useRef } from 'react'
|
|
3
|
+
import { isNullish } from 'remeda'
|
|
3
4
|
import type { Simplify } from 'type-fest'
|
|
4
5
|
import { useDiagramStoreApi } from '../state'
|
|
5
6
|
import { useXYStoreApi } from './hooks'
|
|
@@ -30,6 +31,8 @@ export function useXYFlowEvents() {
|
|
|
30
31
|
const diagramApi = useDiagramStoreApi()
|
|
31
32
|
const xyflowApi = useXYStoreApi()
|
|
32
33
|
|
|
34
|
+
const lastClickTimestamp = useRef<number>()
|
|
35
|
+
|
|
33
36
|
const dblclickTimeout = useRef<number>()
|
|
34
37
|
|
|
35
38
|
const dbclickLock = () => {
|
|
@@ -42,6 +45,10 @@ export function useXYFlowEvents() {
|
|
|
42
45
|
return false
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
const lastClickWasRecent = (ms = 1500) => {
|
|
49
|
+
return lastClickTimestamp.current && (Date.now() - lastClickTimestamp.current) < ms
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
// If we are in focused mode, on edge enter we want to "highlight" the other node
|
|
46
53
|
// This ref contains the id of this node
|
|
47
54
|
const hoveredNodeFromOnEdgeEnterRef = useRef('')
|
|
@@ -68,8 +75,14 @@ export function useXYFlowEvents() {
|
|
|
68
75
|
if (dbclickLock()) {
|
|
69
76
|
return
|
|
70
77
|
}
|
|
71
|
-
const {
|
|
72
|
-
|
|
78
|
+
const {
|
|
79
|
+
focusedNodeId,
|
|
80
|
+
activeDynamicViewStep,
|
|
81
|
+
fitDiagram,
|
|
82
|
+
onCanvasClick,
|
|
83
|
+
resetLastClicked
|
|
84
|
+
} = diagramApi.getState()
|
|
85
|
+
if (focusedNodeId || activeDynamicViewStep) {
|
|
73
86
|
fitDiagram(xyflowApi)
|
|
74
87
|
if (!onCanvasClick) {
|
|
75
88
|
event.stopPropagation()
|
|
@@ -100,20 +113,28 @@ export function useXYFlowEvents() {
|
|
|
100
113
|
},
|
|
101
114
|
onNodeClick: (event, xynode) => {
|
|
102
115
|
const {
|
|
116
|
+
zoomable,
|
|
117
|
+
fitViewEnabled,
|
|
103
118
|
focusedNodeId,
|
|
104
119
|
focusOnNode,
|
|
105
120
|
onNodeClick,
|
|
121
|
+
lastClickedNodeId,
|
|
106
122
|
setLastClickedNode
|
|
107
123
|
} = diagramApi.getState()
|
|
108
124
|
setLastClickedNode(xynode.id)
|
|
125
|
+
const focusPossible = zoomable && fitViewEnabled
|
|
109
126
|
// if we focused on a node, and clicked on another node - focus on the clicked node
|
|
110
|
-
|
|
127
|
+
const shallChangeFocus = !!focusedNodeId && focusedNodeId !== xynode.id
|
|
128
|
+
// if user clicked on the same node twice in a short time, focus on it
|
|
129
|
+
const clickedRecently = isNullish(focusedNodeId) && lastClickedNodeId === xynode.id && lastClickWasRecent()
|
|
130
|
+
if (focusPossible && (shallChangeFocus || clickedRecently)) {
|
|
111
131
|
focusOnNode(xynode.id)
|
|
112
132
|
if (!onNodeClick) {
|
|
113
133
|
// user did not provide a custom handler, stop propagation
|
|
114
134
|
event.stopPropagation()
|
|
115
135
|
}
|
|
116
136
|
}
|
|
137
|
+
lastClickTimestamp.current = Date.now()
|
|
117
138
|
onNodeClick?.({
|
|
118
139
|
element: xynode.data.element,
|
|
119
140
|
xynode,
|
|
@@ -166,10 +187,7 @@ export function useXYFlowEvents() {
|
|
|
166
187
|
},
|
|
167
188
|
onNodeMouseEnter: (_event, xynode) => {
|
|
168
189
|
hoveredNodeFromOnEdgeEnterRef.current = ''
|
|
169
|
-
|
|
170
|
-
if (hoveredNodeId !== xynode.id) {
|
|
171
|
-
setHoveredNode(xynode.id)
|
|
172
|
-
}
|
|
190
|
+
diagramApi.getState().setHoveredNode(xynode.id)
|
|
173
191
|
},
|
|
174
192
|
onNodeMouseLeave: (_event, xynode) => {
|
|
175
193
|
const { hoveredNodeId, setHoveredNode } = diagramApi.getState()
|
|
@@ -191,9 +209,9 @@ export function useXYFlowEvents() {
|
|
|
191
209
|
setHoveredEdge(null)
|
|
192
210
|
}
|
|
193
211
|
if (hoveredNodeId === hoveredNodeFromOnEdgeEnterRef.current) {
|
|
194
|
-
hoveredNodeFromOnEdgeEnterRef.current = ''
|
|
195
212
|
setHoveredNode(null)
|
|
196
213
|
}
|
|
214
|
+
hoveredNodeFromOnEdgeEnterRef.current = ''
|
|
197
215
|
}
|
|
198
216
|
}) satisfies XYFlowEventHandlers, [diagramApi, xyflowApi])
|
|
199
217
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { isDynamicView } from '@likec4/core'
|
|
2
2
|
import { Controls } from '@xyflow/react'
|
|
3
|
-
import
|
|
3
|
+
import { shallowEqual } from 'fast-equals'
|
|
4
|
+
import { memo, type PropsWithChildren } from 'react'
|
|
4
5
|
import { isNonNull, isNonNullish, isNullish } from 'remeda'
|
|
5
6
|
import type { LikeC4DiagramProperties } from '../LikeC4Diagram.props'
|
|
6
7
|
import { useDiagramState } from '../state/useDiagramStore'
|
|
7
|
-
import { DynamicViewWalkthrough } from '../ui'
|
|
8
8
|
import DiagramTitlePanel from '../ui/DiagramTitlePanel'
|
|
9
|
+
import { DynamicViewWalkthrough } from '../ui/DynamicViewWalkthrough'
|
|
9
10
|
import OptionsPanel from '../ui/OptionsPanel'
|
|
10
11
|
import { XYFlowBackground } from './XYFlowBackground'
|
|
11
12
|
|
|
@@ -16,7 +17,7 @@ type XYFlowInnerProps = PropsWithChildren<{
|
|
|
16
17
|
enableDynamicViewWalkthrough: boolean
|
|
17
18
|
}>
|
|
18
19
|
|
|
19
|
-
export
|
|
20
|
+
export const XYFlowInner = memo(function XYFlowInnerR({
|
|
20
21
|
children,
|
|
21
22
|
background,
|
|
22
23
|
controls,
|
|
@@ -47,4 +48,4 @@ export function XYFlowInner({
|
|
|
47
48
|
{children}
|
|
48
49
|
</>
|
|
49
50
|
)
|
|
50
|
-
}
|
|
51
|
+
}, shallowEqual)
|
package/src/xyflow/const.ts
CHANGED