@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.
Files changed (56) hide show
  1. package/dist/LikeC4Diagram.d.ts +1 -1
  2. package/dist/LikeC4Diagram.props.d.ts +21 -3
  3. package/dist/index.mjs +1335 -1240
  4. package/dist/state/DiagramContext.d.ts +2263 -175
  5. package/dist/state/diagramStore.d.ts +2272 -180
  6. package/dist/state/useDiagramStore.d.ts +2409 -321
  7. package/dist/style.css +123 -92
  8. package/dist/theme.css.d.ts +0 -1
  9. package/dist/ui/OptionsPanel.d.ts +3 -1
  10. package/dist/xyflow/XYFlow.d.ts +2 -1
  11. package/dist/xyflow/XYFlowInner.d.ts +1 -1
  12. package/dist/xyflow/const.d.ts +1 -1
  13. package/dist/xyflow/diagram-to-xyflow.d.ts +1 -1
  14. package/dist/xyflow/edges/RelationshipEdge.d.ts +3 -4
  15. package/dist/xyflow/edges/utils.d.ts +4 -2
  16. package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +3 -1
  17. package/dist/xyflow/nodes/element/ElementNode.d.ts +1 -1
  18. package/dist/xyflow/nodes/element/index.d.ts +1 -1
  19. package/dist/xyflow/types.d.ts +2 -2
  20. package/dist/xyflow/utils.d.ts +3 -0
  21. package/package.json +15 -14
  22. package/src/LikeC4Diagram.css.ts +4 -4
  23. package/src/LikeC4Diagram.props.ts +23 -2
  24. package/src/LikeC4Diagram.tsx +79 -31
  25. package/src/globals.css.ts +0 -1
  26. package/src/mantine/theme.ts +1 -0
  27. package/src/state/DiagramContext.tsx +0 -1
  28. package/src/state/diagramStore.ts +177 -100
  29. package/src/theme.css.ts +0 -1
  30. package/src/ui/DiagramTitlePanel.tsx +3 -3
  31. package/src/ui/DynamicViewWalkthrough.css.ts +3 -2
  32. package/src/ui/DynamicViewWalkthrough.tsx +36 -17
  33. package/src/ui/OptionsPanel.tsx +12 -12
  34. package/src/ui/options/NodeOptions.tsx +16 -19
  35. package/src/xyflow/FitviewOnDiagramChange.tsx +15 -23
  36. package/src/xyflow/SelectEdgesOnNodeFocus.tsx +3 -6
  37. package/src/xyflow/SyncWithDiagram.tsx +21 -2
  38. package/src/xyflow/XYFlow.tsx +20 -23
  39. package/src/xyflow/XYFlowEvents.tsx +26 -8
  40. package/src/xyflow/XYFlowInner.tsx +5 -4
  41. package/src/xyflow/const.ts +1 -1
  42. package/src/xyflow/diagram-to-xyflow.ts +5 -31
  43. package/src/xyflow/edges/RelationshipEdge.tsx +158 -112
  44. package/src/xyflow/edges/edges.css.ts +32 -21
  45. package/src/xyflow/edges/utils.ts +5 -2
  46. package/src/xyflow/hooks/useLayoutConstraints.ts +17 -3
  47. package/src/xyflow/nodes/compound/CompoundNode.css.ts +40 -32
  48. package/src/xyflow/nodes/compound/CompoundNode.tsx +36 -26
  49. package/src/xyflow/nodes/element/ElementNode.tsx +20 -6
  50. package/src/xyflow/nodes/element/element.css.ts +15 -15
  51. package/src/xyflow/nodes/element/index.ts +1 -1
  52. package/src/xyflow/nodes/shared/NavigateToBtn.tsx +7 -3
  53. package/src/xyflow/types.ts +5 -2
  54. package/src/xyflow/utils.ts +48 -0
  55. package/dist/ui/index.d.ts +0 -2
  56. 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, motion } from 'framer-motion'
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
- <motion.div
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
- </motion.div>
135
+ </m.div>
136
136
  </AnimatePresence>
137
137
  )
138
138
  }
@@ -41,6 +41,7 @@ export const btn = style({
41
41
  }
42
42
  })
43
43
 
44
- globalStyle(`${btn} .tabler-icon`, {
45
- width: '80%'
44
+ globalStyle(`:where(${buttons}, ${btn}) .tabler-icon`, {
45
+ width: '0.85em',
46
+ height: '0.85em'
46
47
  })
@@ -1,19 +1,19 @@
1
- import { ActionIcon, Box, Button, rem, useComputedColorScheme } from '@mantine/core'
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-xl',
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={'sm'}
85
+ px={hasNextSteps ? 'md' : 'xl'}
68
86
  onClick={e => {
69
87
  e.stopPropagation()
70
88
  stopDynamicView()
71
89
  }}>
72
- <IconPlayerStopFilled />
73
- </Button>
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>
@@ -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 { shallowEqual } from 'fast-equals'
5
- import { AnimatePresence, motion } from 'framer-motion'
6
- import { useState } from 'react'
7
- import { isNullish, isTruthy } from 'remeda'
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
- export default function OptionsPanel() {
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).sort()
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
- <motion.div
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
- </motion.div>
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 { useEffect, useState } from 'react'
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().triggerOnChange([{
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
- <Box maw={200}>
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
- </Box>
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
- <Box key={firstNode.id}>
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
- </Box>
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
- <Box>
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
- </Box>
194
+ </div>
198
195
  )
199
196
  }
200
197
 
@@ -215,7 +212,7 @@ function ShapeOption({
215
212
  })
216
213
 
217
214
  return (
218
- <Box>
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
- </Box>
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
- <Box>
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
- </Box>
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
- useEffect(() => {
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
- useEffect(() => {
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
- 200,
30
- 800
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 && elTo && elFrom) {
84
- const [x, y, zoom] = xyflowApi.getState().transform
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
- const nextZoom = Math.min(
87
- elFrom.width / elTo.width,
88
- elFrom.height / elTo.height
89
- )
90
- const centerFrom = lastOnNavigate!.elementCenterScreenPosition
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(centerFrom.x - centerTo.x),
105
- y: toDomPrecision(centerFrom.y - centerTo.y)
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
- if (anotherNode) {
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
- includeHiddenNodes: true,
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
- return eq(existing.data.edge, update.data.edge) ? existing : {
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
  }))
@@ -1,10 +1,8 @@
1
- import { useMantineColorScheme } from '@mantine/core'
2
- import { Controls, ReactFlow, useOnViewportChange } from '@xyflow/react'
3
- import { deepEqual as eq, shallowEqual } from 'fast-equals'
4
- import { type CSSProperties, memo, type PropsWithChildren } from 'react'
5
- import { omit, omitBy } from 'remeda'
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
- fitViewPadding: s.fitViewPadding,
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
- fitViewPadding,
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, propsAreEqual) as typeof 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 { focusedNodeId, fitDiagram, onCanvasClick, resetLastClicked } = diagramApi.getState()
72
- if (!!focusedNodeId) {
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
- if (!!focusedNodeId && focusedNodeId !== xynode.id) {
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
- const { hoveredNodeId, setHoveredNode } = diagramApi.getState()
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 type { PropsWithChildren } from 'react'
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 function XYFlowInner({
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)
@@ -4,4 +4,4 @@ export const ZIndexes = {
4
4
  Element: 3
5
5
  } as const
6
6
 
7
- export const MinZoom = 0.05
7
+ export const MinZoom = 0.1