@likec4/diagram 1.4.0 → 1.5.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 (83) hide show
  1. package/dist/LikeC4Diagram.css.d.ts +0 -1
  2. package/dist/LikeC4Diagram.props.d.ts +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.mjs +1398 -1114
  5. package/dist/state/DiagramContext.d.ts +86 -4159
  6. package/dist/state/diagramStore.d.ts +88 -4162
  7. package/dist/state/hooks.d.ts +4 -0
  8. package/dist/state/index.d.ts +1 -2
  9. package/dist/style.css +182 -156
  10. package/dist/ui/options/EdgeOptions.d.ts +19 -0
  11. package/dist/ui/options/NodeOptions.d.ts +2 -2
  12. package/dist/xyflow/EdgeMarkers.d.ts +11 -0
  13. package/dist/xyflow/XYFlowEvents.d.ts +8 -5
  14. package/dist/xyflow/const.d.ts +3 -3
  15. package/dist/xyflow/diagram-to-xyflow.d.ts +1 -1
  16. package/dist/xyflow/edges/RelationshipEdge.d.ts +3 -2
  17. package/dist/xyflow/edges/edges.css.d.ts +1 -0
  18. package/dist/xyflow/edges/utils.d.ts +1 -0
  19. package/dist/xyflow/hooks/index.d.ts +3 -2
  20. package/dist/xyflow/hooks/useXYFlow.d.ts +13 -12
  21. package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +1 -0
  22. package/dist/xyflow/nodes/element/ElementIcon.d.ts +1 -4
  23. package/dist/xyflow/nodes/element/element.css.d.ts +2 -1
  24. package/dist/xyflow/types.d.ts +2 -32
  25. package/package.json +19 -19
  26. package/src/LikeC4Diagram.css.ts +0 -8
  27. package/src/LikeC4Diagram.props.ts +2 -2
  28. package/src/LikeC4Diagram.tsx +14 -20
  29. package/src/globals.css.ts +10 -3
  30. package/src/index.ts +1 -1
  31. package/src/mantine.css.ts +2 -2
  32. package/src/state/DiagramContext.tsx +44 -13
  33. package/src/state/diagramStore.ts +64 -52
  34. package/src/state/hooks.ts +5 -0
  35. package/src/state/index.ts +1 -2
  36. package/src/ui/BackwardForwardPanel.css.ts +9 -9
  37. package/src/ui/BackwardForwardPanel.tsx +1 -1
  38. package/src/ui/DiagramTitlePanel.css.ts +7 -2
  39. package/src/ui/DiagramTitlePanel.tsx +5 -3
  40. package/src/ui/DynamicViewWalkthrough.tsx +6 -4
  41. package/src/ui/OptionsPanel.tsx +37 -27
  42. package/src/ui/options/EdgeOptions.tsx +157 -0
  43. package/src/ui/options/NodeOptions.tsx +37 -29
  44. package/src/xyflow/EdgeMarkers.tsx +148 -0
  45. package/src/xyflow/FitviewOnDiagramChange.tsx +8 -4
  46. package/src/xyflow/SelectEdgesOnNodeFocus.tsx +59 -55
  47. package/src/xyflow/SyncWithDiagram.tsx +13 -20
  48. package/src/xyflow/XYFlow.tsx +2 -4
  49. package/src/xyflow/XYFlowEvents.tsx +48 -33
  50. package/src/xyflow/XYFlowInner.tsx +1 -1
  51. package/src/xyflow/const.ts +3 -3
  52. package/src/xyflow/diagram-to-xyflow.ts +17 -41
  53. package/src/xyflow/edges/RelationshipEdge.tsx +207 -118
  54. package/src/xyflow/edges/edges.css.ts +36 -22
  55. package/src/xyflow/edges/utils.ts +10 -0
  56. package/src/xyflow/hooks/index.ts +4 -2
  57. package/src/xyflow/hooks/useLayoutConstraints.ts +3 -4
  58. package/src/xyflow/hooks/useXYFlow.ts +28 -1
  59. package/src/xyflow/nodes/compound/CompoundNode.css.ts +34 -24
  60. package/src/xyflow/nodes/compound/CompoundNode.tsx +52 -53
  61. package/src/xyflow/nodes/element/ElementIcon.tsx +11 -22
  62. package/src/xyflow/nodes/element/ElementLink.tsx +11 -16
  63. package/src/xyflow/nodes/element/ElementNode.tsx +64 -108
  64. package/src/xyflow/nodes/element/element.css.ts +30 -10
  65. package/src/xyflow/nodes/shared/NavigateToBtn.tsx +1 -1
  66. package/src/xyflow/types.ts +2 -4
  67. package/src/xyflow/utils.ts +3 -6
  68. package/dist/KeepAspectRatioContainer.d.ts +0 -9
  69. package/dist/icons/Clipboard.d.ts +0 -3
  70. package/dist/icons/Link.d.ts +0 -3
  71. package/dist/icons/ZoomIn.d.ts +0 -3
  72. package/dist/icons/index.d.ts +0 -3
  73. package/dist/mantine/theme.d.ts +0 -3
  74. package/dist/state/WhenInitialized.d.ts +0 -3
  75. package/dist/ui/Camera.d.ts +0 -3
  76. package/src/KeepAspectRatioContainer.tsx +0 -36
  77. package/src/icons/Clipboard.tsx +0 -24
  78. package/src/icons/Link.tsx +0 -23
  79. package/src/icons/ZoomIn.tsx +0 -25
  80. package/src/icons/index.ts +0 -3
  81. package/src/mantine/theme.ts +0 -20
  82. package/src/state/WhenInitialized.tsx +0 -24
  83. package/src/ui/Camera.tsx +0 -174
@@ -0,0 +1,157 @@
1
+ import { DefaultRelationshipColor, defaultTheme, type NonEmptyArray, type ThemeColor } from '@likec4/core'
2
+ import { CheckIcon, ColorSwatch, Divider, Flex, rem, Stack, Text, Tooltip, TooltipGroup } from '@mantine/core'
3
+ import { shallowEqual } from 'fast-equals'
4
+ import { memo } from 'react'
5
+ import { hasAtLeast, keys, takeWhile } from 'remeda'
6
+ import { useDiagramStoreApi } from '../../state/hooks'
7
+ import { useXYEdgesData } from '../../xyflow/hooks/useXYFlow'
8
+ import { type XYFlowEdge } from '../../xyflow/types'
9
+
10
+ const {
11
+ primary,
12
+ secondary,
13
+ muted,
14
+ ...otherColors
15
+ } = defaultTheme.elements
16
+
17
+ export const themedColors = [
18
+ { key: 'primary', value: primary.fill },
19
+ { key: 'secondary', value: secondary.fill },
20
+ { key: 'muted', value: muted.fill }
21
+ ] satisfies Array<{ key: ThemeColor; value: string }>
22
+
23
+ export const colors = keys(otherColors).map(key => ({
24
+ key,
25
+ value: defaultTheme.elements[key].fill
26
+ }))
27
+
28
+ export type ThemeColorKey = typeof themedColors[0]['key']
29
+ export type ColorKey = typeof colors[0]['key']
30
+
31
+ type XYEdgeData = Pick<XYFlowEdge, 'id' | 'data'>
32
+
33
+ export const EdgeOptions = memo<{ selectedEdgeIds: string[] }>(({ selectedEdgeIds }) => {
34
+ const diagramApi = useDiagramStoreApi()
35
+ const edges = useXYEdgesData(selectedEdgeIds)
36
+ if (!hasAtLeast(edges, 1)) {
37
+ return null
38
+ }
39
+ if (edges.length !== selectedEdgeIds.length) {
40
+ throw new Error('EdgeOptions: edges and props.edges should have the same length')
41
+ }
42
+ const [firstEdge, ...rest] = edges
43
+
44
+ // // Makes sense to show opacity option only if there is at least one compound node
45
+ // const showOpacityOption = firstNode.type === 'compound'
46
+ // && (rest.length === 0 || rest.every(node => node.type === 'compound'))
47
+
48
+ // const triggerChange = (style: Changes.ChangeElementStyle['style']) => {
49
+ // const targets = edges.map(node => node.data.element.id)
50
+ // invariant(hasAtLeast(targets, 1), 'At least one target is required')
51
+ // diagramApi.getState().triggerChangeElementStyle({
52
+ // op: 'change-element-style',
53
+ // style,
54
+ // targets
55
+ // })
56
+ // }
57
+
58
+ return (
59
+ <Stack gap={'xs'}>
60
+ <div>
61
+ <Text fz={rem(9)} fw={'500'} c={'dimmed'}>
62
+ RELATIONSHIP{rest.length > 0 ? 'S' : ''}
63
+ </Text>
64
+ <Text size="xs" c={rest.length > 0 ? 'dimmed' : ''} truncate>
65
+ {rest.length === 0 ? firstEdge.data.edge.label : `[ multiple ]`}
66
+ </Text>
67
+ </div>
68
+ {
69
+ /* {showShapeOption && (
70
+ <ShapeOption
71
+ nodes={edges}
72
+ onShapeChange={(shape: ElementShape) => {
73
+ triggerChange({ shape })
74
+ }} />
75
+ )} */
76
+ }
77
+ <Colors
78
+ edges={edges}
79
+ onColorChange={(_color: ColorKey | ThemeColorKey) => {
80
+ // triggerChange({ color })
81
+ }} />
82
+ </Stack>
83
+ )
84
+ }, (prevProps, nextProps) => shallowEqual(prevProps.selectedEdgeIds, nextProps.selectedEdgeIds))
85
+
86
+ function Colors({
87
+ edges: [firstEdge, ...edges],
88
+ onColorChange
89
+ }: {
90
+ edges: NonEmptyArray<XYEdgeData>
91
+ onColorChange: (color: ColorKey | ThemeColorKey) => void
92
+ }) {
93
+ let selectedColor = (firstEdge.data.edge.color ?? DefaultRelationshipColor) as ThemeColor | null
94
+ takeWhile(edges, edge => {
95
+ const color = edge.data.edge.color ?? DefaultRelationshipColor
96
+ if (color !== selectedColor) {
97
+ selectedColor = null
98
+ return false
99
+ }
100
+ return true
101
+ })
102
+ const changeColor = (color: ColorKey | ThemeColorKey) => () => {
103
+ if (selectedColor === color) {
104
+ return
105
+ }
106
+ onColorChange(color)
107
+ }
108
+ return (
109
+ <div>
110
+ <TooltipGroup openDelay={400} closeDelay={300}>
111
+ <Divider label="color" labelPosition="left" />
112
+ <Flex mt={'xs'} maw={150} gap="xs" justify="flex-start" align="flex-start" direction="row" wrap="wrap">
113
+ {themedColors.map(({ key, value }) => (
114
+ <Tooltip
115
+ key={key}
116
+ label={key}
117
+ fz={'xs'}
118
+ color="dark"
119
+ offset={2}
120
+ transitionProps={{ duration: 140, transition: 'slide-up' }}>
121
+ <ColorSwatch
122
+ color={value}
123
+ size={20}
124
+ withShadow
125
+ onClick={changeColor(key)}
126
+ style={{ color: '#fff', cursor: 'pointer' }}
127
+ >
128
+ {selectedColor === key && <CheckIcon style={{ width: rem(12), height: rem(12) }} />}
129
+ </ColorSwatch>
130
+ </Tooltip>
131
+ ))}
132
+ </Flex>
133
+
134
+ <Flex mt="sm" maw={150} gap="xs" justify="flex-start" align="flex-start" direction="row" wrap="wrap">
135
+ {colors.map(({ key, value }) => (
136
+ <Tooltip
137
+ key={key}
138
+ label={key}
139
+ fz={'xs'}
140
+ color="dark"
141
+ offset={2}
142
+ transitionProps={{ duration: 140, transition: 'slide-up' }}>
143
+ <ColorSwatch
144
+ color={value}
145
+ size={20}
146
+ onClick={changeColor(key)}
147
+ style={{ color: '#fff', cursor: 'pointer' }}
148
+ >
149
+ {selectedColor === key && <CheckIcon style={{ width: rem(12), height: rem(12) }} />}
150
+ </ColorSwatch>
151
+ </Tooltip>
152
+ ))}
153
+ </Flex>
154
+ </TooltipGroup>
155
+ </div>
156
+ )
157
+ }
@@ -17,16 +17,18 @@ import {
17
17
  SegmentedControl,
18
18
  Select,
19
19
  Slider,
20
+ Space,
20
21
  Stack,
21
22
  Text,
22
23
  Tooltip,
23
24
  TooltipGroup
24
25
  } from '@mantine/core'
25
- import { useState } from 'react'
26
+ import { shallowEqual } from 'fast-equals'
27
+ import { memo, useState } from 'react'
26
28
  import { hasAtLeast, keys, takeWhile } from 'remeda'
27
29
  import { useUpdateEffect } from '../../hooks/useUpdateEffect'
28
30
  import type { Changes } from '../../LikeC4Diagram.props'
29
- import { useDiagramStoreApi } from '../../state'
31
+ import { useDiagramStoreApi } from '../../state/hooks'
30
32
  import { useXYNodesData } from '../../xyflow/hooks'
31
33
  import { XYFlowNode } from '../../xyflow/types'
32
34
 
@@ -43,7 +45,7 @@ export const themedColors = [
43
45
  { key: 'muted', value: muted.fill }
44
46
  ] satisfies Array<{ key: ThemeColor; value: string }>
45
47
 
46
- export const colors = keys.strict(otherColors).map(key => ({
48
+ export const colors = keys(otherColors).map(key => ({
47
49
  key,
48
50
  value: defaultTheme.elements[key].fill
49
51
  }))
@@ -53,7 +55,7 @@ export type ColorKey = typeof colors[0]['key']
53
55
 
54
56
  type XYNodesData = Pick<XYFlowNode, 'id' | 'data' | 'type'>
55
57
 
56
- export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] }) {
58
+ export const NodeOptions = memo<{ selectedNodeIds: string[] }>(({ selectedNodeIds }) => {
57
59
  // export function NodeOptions(props: { nodes: XYFlowNode[] }) {
58
60
  const diagramApi = useDiagramStoreApi()
59
61
  // const nodes = useXYNodesData(props.nodes.map(node => node.id))
@@ -70,7 +72,8 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
70
72
  const [firstNode, ...rest] = nodes
71
73
 
72
74
  // Makes sense to show opacity option only if there is at least one compound node
73
- const showOpacityOption = rest.length === 0 && firstNode.type === 'compound'
75
+ const showOpacityOption = firstNode.type === 'compound'
76
+ && (rest.length === 0 || rest.every(node => node.type === 'compound'))
74
77
 
75
78
  const triggerChange = (style: Changes.ChangeElementStyle['style']) => {
76
79
  const targets = nodes.map(node => node.data.element.id)
@@ -83,8 +86,11 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
83
86
  }
84
87
 
85
88
  return (
86
- <Stack>
89
+ <Stack gap={'xs'}>
87
90
  <div>
91
+ <Text fz={rem(9)} fw={'500'} c={'dimmed'}>
92
+ ELEMENT{rest.length > 0 ? 'S' : ``}
93
+ </Text>
88
94
  <Text size="xs" c={rest.length > 0 ? 'dimmed' : ''} truncate>
89
95
  {rest.length === 0 ? firstNode.data.element.title : `[ multiple ]`}
90
96
  </Text>
@@ -105,11 +111,13 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
105
111
  {showOpacityOption && (
106
112
  <div key={firstNode.id}>
107
113
  <Divider label="opacity and border" labelPosition="left" />
114
+ <Space h={'xs'} />
108
115
  <OpacityOption
109
116
  node={firstNode}
110
117
  onOpacityChange={(opacity: number) => {
111
118
  triggerChange({ opacity })
112
119
  }} />
120
+ <Space h={'sm'} />
113
121
  <BorderStyleOption
114
122
  node={firstNode}
115
123
  onChange={(border: BorderStyle) => {
@@ -121,7 +129,7 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
121
129
  <NavigateToOption nodes={nodes} />
122
130
  </Stack>
123
131
  )
124
- }
132
+ }, (prevProps, nextProps) => shallowEqual(prevProps.selectedNodeIds, nextProps.selectedNodeIds))
125
133
 
126
134
  function Colors({
127
135
  nodes: [firstNode, ...nodes],
@@ -242,26 +250,27 @@ function NavigateToOption({
242
250
  return (
243
251
  <div>
244
252
  <Divider label="navigate to" labelPosition="left" />
245
- <Select
246
- mt={'xs'}
247
- size="xs"
248
- variant="filled"
249
- value={!isMultiple ? (node.data.element.navigateTo ?? null) : null}
250
- disabled={isMultiple}
251
- placeholder={isMultiple ? '[ multiple ]' : 'select'}
252
- data={node.data.element.navigateTo
253
- ? [{ value: node.data.element.navigateTo, label: node.data.element.navigateTo }]
254
- : []}
255
- // data={ElementShapes}
256
- allowDeselect={true}
257
- checkIconPosition="right"
258
- // onChange={(value) => {
259
- // if (!value || value === selectedShape) {
260
- // return
261
- // }
262
- // onShapeChange(value as ElementShape)
263
- // }} />
264
- />
253
+ {isMultiple && (
254
+ <Text size="xs" c={'dimmed'}>
255
+ {'[ multiple ]'}
256
+ </Text>
257
+ )}
258
+ {!isMultiple && (
259
+ <>
260
+ <Space h={'xs'} />
261
+ <Select
262
+ size="xs"
263
+ variant="filled"
264
+ value={node.data.element.navigateTo ?? null}
265
+ placeholder={'select'}
266
+ data={node.data.element.navigateTo
267
+ ? [{ value: node.data.element.navigateTo, label: node.data.element.navigateTo }]
268
+ : []}
269
+ allowDeselect={true}
270
+ checkIconPosition="right"
271
+ />
272
+ </>
273
+ )}
265
274
  </div>
266
275
  )
267
276
  }
@@ -281,7 +290,6 @@ function OpacityOption({
281
290
 
282
291
  return (
283
292
  <Slider
284
- mt={'xs'}
285
293
  size={'sm'}
286
294
  color={'dark'}
287
295
  value={value}
@@ -304,7 +312,7 @@ function BorderStyleOption({
304
312
  }, [selecteBorderStyle])
305
313
 
306
314
  return (
307
- <Box mt={'md'}>
315
+ <Box>
308
316
  <SegmentedControl
309
317
  size="xs"
310
318
  fullWidth
@@ -0,0 +1,148 @@
1
+ import type { SVGProps } from 'react'
2
+
3
+ const Open = (props: SVGProps<SVGMarkerElement>) => (
4
+ <marker
5
+ viewBox="-4 -4 14 16"
6
+ refX={5}
7
+ refY={4}
8
+ markerWidth="7"
9
+ markerHeight="8"
10
+ preserveAspectRatio="xMaxYMid meet"
11
+ orient="auto-start-reverse"
12
+ {...props}
13
+ >
14
+ <path
15
+ d="M0,0 L7,4 L0,8 L4,4 Z"
16
+ stroke="context-stroke"
17
+ fill="context-stroke"
18
+ strokeDasharray={0}
19
+ strokeWidth={1}
20
+ strokeLinecap={'round'} />
21
+ </marker>
22
+ )
23
+
24
+ const Arrow = (props: SVGProps<SVGMarkerElement>) => (
25
+ <marker
26
+ viewBox="-1 -1 12 10"
27
+ refX={4}
28
+ refY={3}
29
+ markerWidth="8"
30
+ markerHeight="6"
31
+ preserveAspectRatio="xMaxYMid meet"
32
+ orient="auto-start-reverse"
33
+ {...props}>
34
+ <path
35
+ d="M 0 0 L 8 3 L 0 6 L 1 3 z"
36
+ fill="context-stroke"
37
+ strokeWidth={0}
38
+ />
39
+ </marker>
40
+ )
41
+
42
+ const OArrow = (props: SVGProps<SVGMarkerElement>) => (
43
+ <marker
44
+ viewBox="-1 -1 12 10"
45
+ refX={4}
46
+ refY={3}
47
+ markerWidth="8"
48
+ markerHeight="6"
49
+ preserveAspectRatio="xMaxYMid meet"
50
+ orient="auto-start-reverse"
51
+ {...props}>
52
+ <path
53
+ d="M 0 0 L 8 3 L 0 6 L 1 3 z"
54
+ stroke="context-stroke"
55
+ fill="var(--likec4-background-color)"
56
+ strokeWidth={1.25}
57
+ strokeLinejoin="miter"
58
+ strokeLinecap={'square'}
59
+ />
60
+ </marker>
61
+ )
62
+
63
+ const Diamond = (props: SVGProps<SVGMarkerElement>) => (
64
+ <marker
65
+ viewBox="-4 -4 16 14"
66
+ refX={5}
67
+ refY={4}
68
+ markerWidth="10"
69
+ markerHeight="8"
70
+ preserveAspectRatio="xMaxYMid meet"
71
+ orient="auto-start-reverse"
72
+ {...props}
73
+ >
74
+ <path
75
+ d="M5,0 L10,4 L5,8 L0,4 Z"
76
+ fill="context-stroke"
77
+ strokeWidth={0}
78
+ strokeLinecap={'round'}
79
+ />
80
+ </marker>
81
+ )
82
+
83
+ const ODiamond = (props: SVGProps<SVGMarkerElement>) => (
84
+ <marker
85
+ viewBox="-4 -4 16 14"
86
+ refX={6}
87
+ refY={4}
88
+ markerWidth="10"
89
+ markerHeight="8"
90
+ preserveAspectRatio="xMaxYMid meet"
91
+ orient="auto-start-reverse"
92
+ {...props}>
93
+ <path
94
+ d="M5,0 L10,4 L5,8 L0,4 Z"
95
+ stroke="context-stroke"
96
+ fill="var(--likec4-background-color)"
97
+ strokeWidth={1.25}
98
+ strokeLinecap={'round'}
99
+ />
100
+ </marker>
101
+ )
102
+
103
+ const Dot = (props: SVGProps<SVGMarkerElement>) => (
104
+ <marker
105
+ viewBox="0 0 10 10"
106
+ refX={4}
107
+ refY={4}
108
+ markerWidth="6"
109
+ markerHeight="6"
110
+ {...props}>
111
+ <circle
112
+ strokeWidth={0}
113
+ fill="context-stroke"
114
+ cx={4}
115
+ cy={4}
116
+ r={3}
117
+ />
118
+ </marker>
119
+ )
120
+
121
+ const ODot = (props: SVGProps<SVGMarkerElement>) => (
122
+ <marker
123
+ viewBox="0 0 10 10"
124
+ refX={4}
125
+ refY={4}
126
+ markerWidth="6"
127
+ markerHeight="6"
128
+ {...props}>
129
+ <circle
130
+ strokeWidth={1.25}
131
+ stroke="context-stroke"
132
+ fill="var(--likec4-background-color)"
133
+ cx={4}
134
+ cy={4}
135
+ r={3}
136
+ />
137
+ </marker>
138
+ )
139
+
140
+ export const EdgeMarkers = {
141
+ Arrow,
142
+ OArrow,
143
+ Open,
144
+ Diamond,
145
+ ODiamond,
146
+ Dot,
147
+ ODot
148
+ }
@@ -2,7 +2,7 @@ import { invariant } from '@likec4/core'
2
2
  import { useDebouncedEffect } from '@react-hookz/web'
3
3
  import { shallowEqual } from 'fast-equals'
4
4
  import { useEffect, useRef } from 'react'
5
- import { type DiagramState, type DiagramStoreApi, useDiagramState, useDiagramStoreApi } from '../state'
5
+ import { type DiagramState, type DiagramStoreApi, useDiagramState, useDiagramStoreApi } from '../state/hooks'
6
6
  import { useXYStore, useXYStoreApi, type XYStoreApi } from './hooks'
7
7
  import type { XYFlowState } from './types'
8
8
  import { toDomPrecision } from './utils'
@@ -24,11 +24,11 @@ function FitViewOnViewportResize({ diagramApi, xyflowApi }: {
24
24
  if (focusedNodeId || prevDimensionsRef.current === dimensions) {
25
25
  return
26
26
  }
27
- fitDiagram(xyflowApi)
27
+ fitDiagram(300)
28
28
  prevDimensionsRef.current = dimensions
29
29
  },
30
30
  [dimensions, diagramApi, xyflowApi],
31
- 250,
31
+ 300,
32
32
  1000
33
33
  )
34
34
 
@@ -77,6 +77,7 @@ export function FitViewOnDiagramChange() {
77
77
  } = useDiagramState(selector, shallowEqual)
78
78
  const diagramApi = useDiagramStoreApi()
79
79
  const processedRef = useRef(pendingViewId)
80
+ const durationRef = useRef(300)
80
81
 
81
82
  useEffect(() => {
82
83
  if (!xyflowSynced || pendingViewId === processedRef.current) {
@@ -97,11 +98,14 @@ export function FitViewOnDiagramChange() {
97
98
  y: toDomPrecision(fromPos.y - toPos.y)
98
99
  }
99
100
  xyflowApi.getState().panBy(diff)
101
+ durationRef.current = 500
100
102
  diagramApi.setState({ lastOnNavigate: null })
101
103
  return
102
104
  }
103
105
  processedRef.current = pendingViewId
104
- diagramApi.getState().fitDiagram(xyflowApi)
106
+ diagramApi.getState().fitDiagram(durationRef.current)
107
+ // Reset duration
108
+ durationRef.current = 300
105
109
  }, [pendingViewId, xyflowSynced, waitCorrection])
106
110
 
107
111
  if (!viewportChanged && xyflowSynced) {
@@ -1,72 +1,76 @@
1
1
  import { nonNullable } from '@likec4/core'
2
2
  import type { EdgeChange, NodeChange } from '@xyflow/react'
3
- import { useUpdateEffect } from '../hooks/useUpdateEffect'
4
- import { useDiagramState, useDiagramStoreApi } from '../state/useDiagramStore'
3
+ import { getBoundsOfRects, getViewportForBounds, nodeToRect } from '@xyflow/system'
4
+ import { useEffect } from 'react'
5
+ import { useDiagramStoreApi } from '../state/hooks'
5
6
  import { MinZoom } from './const'
6
- import { useXYStoreApi } from './hooks/useXYFlow'
7
7
  import type { XYFlowEdge, XYFlowNode } from './types'
8
8
 
9
9
  export function SelectEdgesOnNodeFocus() {
10
- const focusedNodeId = useDiagramState(s => s.focusedNodeId)
11
10
  const diagramStore = useDiagramStoreApi()
12
- const xyflowApi = useXYStoreApi()
13
11
 
14
- useUpdateEffect(() => {
15
- if (!focusedNodeId) {
16
- xyflowApi.getState().resetSelectedElements()
17
- return
18
- }
12
+ useEffect(
13
+ () =>
14
+ diagramStore.subscribe(s => s.focusedNodeId, focusedNodeId => {
15
+ if (!focusedNodeId) {
16
+ return
17
+ }
19
18
 
20
- const edgeChanges = [] as EdgeChange<XYFlowEdge>[]
21
- const nodeChanges = [] as NodeChange<XYFlowNode>[]
22
- const { edgeLookup, nodeLookup, transform, triggerNodeChanges, fitView, triggerEdgeChanges } = xyflowApi.getState()
23
- const fitViewNodes = new Set<XYFlowNode>()
24
- fitViewNodes.add(nonNullable(nodeLookup.get(focusedNodeId)))
19
+ const edgeChanges = [] as EdgeChange<XYFlowEdge>[]
20
+ const nodeChanges = [] as NodeChange<XYFlowNode>[]
21
+ const {
22
+ edgeLookup,
23
+ nodeLookup,
24
+ width,
25
+ height,
26
+ panZoom,
27
+ transform,
28
+ triggerNodeChanges,
29
+ triggerEdgeChanges
30
+ } = diagramStore.getState().xystore.getState()
31
+ let focusBounds = nodeToRect(nonNullable(nodeLookup.get(focusedNodeId)))
25
32
 
26
- for (const [, edge] of edgeLookup) {
27
- if (edge.source === focusedNodeId || edge.target === focusedNodeId) {
28
- edgeChanges.push({
29
- id: edge.id,
30
- type: 'select',
31
- selected: true
32
- })
33
- const anotherNode = nonNullable(nodeLookup.get(edge.source === focusedNodeId ? edge.target : edge.source))
34
- fitViewNodes.add(anotherNode)
35
- } else if (edge.selected) {
36
- edgeChanges.push({
37
- id: edge.id,
38
- type: 'select',
39
- selected: false
40
- })
41
- }
42
- }
43
- for (const [, node] of nodeLookup) {
44
- if (node.selected && node.id !== focusedNodeId) {
33
+ for (const [, edge] of edgeLookup) {
34
+ if (edge.source === focusedNodeId || edge.target === focusedNodeId) {
35
+ const anotherNodeId = edge.source === focusedNodeId ? edge.target : edge.source
36
+ const nd = nodeToRect(nonNullable(nodeLookup.get(anotherNodeId)))
37
+ focusBounds = getBoundsOfRects(focusBounds, nd)
38
+ }
39
+ if (edge.selected) {
40
+ edgeChanges.push({
41
+ id: edge.id,
42
+ type: 'select',
43
+ selected: false
44
+ })
45
+ }
46
+ }
47
+ for (const [, node] of nodeLookup) {
48
+ if (node.selected && node.id !== focusedNodeId) {
49
+ nodeChanges.push({
50
+ id: node.id,
51
+ type: 'select',
52
+ selected: false
53
+ })
54
+ }
55
+ }
45
56
  nodeChanges.push({
46
- id: node.id,
57
+ id: focusedNodeId,
47
58
  type: 'select',
48
- selected: false
59
+ selected: true
49
60
  })
50
- }
51
- }
52
- nodeChanges.push({
53
- id: focusedNodeId,
54
- type: 'select',
55
- selected: true
56
- })
57
- if (edgeChanges.length > 0) {
58
- triggerEdgeChanges(edgeChanges)
59
- }
60
- triggerNodeChanges(nodeChanges)
61
+ if (edgeChanges.length > 0) {
62
+ triggerEdgeChanges(edgeChanges)
63
+ }
64
+ triggerNodeChanges(nodeChanges)
61
65
 
62
- fitView({
63
- duration: 350,
64
- padding: 0.1,
65
- minZoom: MinZoom,
66
- maxZoom: Math.max(1, transform[2]),
67
- nodes: Array.from(fitViewNodes)
68
- })
69
- }, [diagramStore, xyflowApi, focusedNodeId])
66
+ const maxZoom = Math.max(1, transform[2])
67
+ const viewport = getViewportForBounds(focusBounds, width, height, MinZoom, maxZoom, 0.2)
68
+ panZoom?.setViewport(viewport, {
69
+ duration: 350
70
+ })
71
+ }),
72
+ [diagramStore]
73
+ )
70
74
 
71
75
  return null
72
76
  }