@likec4/diagram 1.1.1 → 1.2.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 (74) hide show
  1. package/dist/KeepAspectRatioContainer.d.ts +1 -1
  2. package/dist/LikeC4Diagram.d.ts +1 -1
  3. package/dist/LikeC4Diagram.props.d.ts +7 -2
  4. package/dist/StaticLikeC4Diagram.d.ts +1 -1
  5. package/dist/index.d.ts +0 -1
  6. package/dist/index.mjs +2517 -1326
  7. package/dist/mantine/EnsureMantine.d.ts +1 -1
  8. package/dist/state/DiagramContext.d.ts +2125 -10
  9. package/dist/state/WhenInitialized.d.ts +1 -1
  10. package/dist/state/diagramStore.d.ts +2133 -10
  11. package/dist/state/useDiagramStore.d.ts +2124 -9
  12. package/dist/style.css +210 -136
  13. package/dist/theme.css.d.ts +1 -0
  14. package/dist/ui/DiagramTitlePanel.d.ts +1 -2
  15. package/dist/ui/DynamicViewWalkthrough.css.d.ts +3 -0
  16. package/dist/ui/DynamicViewWalkthrough.d.ts +1 -0
  17. package/dist/ui/OptionsPanel.d.ts +1 -2
  18. package/dist/ui/index.d.ts +1 -0
  19. package/dist/ui/options/NodeOptions.d.ts +1 -2
  20. package/dist/xyflow/FitviewOnDiagramChange.d.ts +4 -2
  21. package/dist/xyflow/SyncWithDiagram.d.ts +1 -2
  22. package/dist/xyflow/XYFlow.d.ts +4 -7
  23. package/dist/xyflow/XYFlowBackground.d.ts +5 -4
  24. package/dist/xyflow/XYFlowEvents.d.ts +57 -63
  25. package/dist/xyflow/XYFlowInner.d.ts +11 -0
  26. package/dist/xyflow/diagram-to-xyflow.d.ts +1 -1
  27. package/dist/xyflow/edges/RelationshipEdge.d.ts +14 -14
  28. package/dist/xyflow/edges/edges.css.d.ts +4 -2
  29. package/dist/xyflow/edges/utils.d.ts +2 -0
  30. package/dist/xyflow/hooks/useXYFlow.d.ts +126 -126
  31. package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +1 -0
  32. package/dist/xyflow/nodes/element/ElementIcon.d.ts +1 -1
  33. package/dist/xyflow/nodes/element/ElementLink.d.ts +1 -1
  34. package/dist/xyflow/nodes/element/ElementNode.d.ts +1 -1
  35. package/dist/xyflow/nodes/element/ElementShapeSvg.d.ts +2 -2
  36. package/dist/xyflow/nodes/element/element.css.d.ts +2 -1
  37. package/dist/xyflow/nodes/shared/NavigateToBtn.d.ts +1 -2
  38. package/dist/xyflow/types.d.ts +18 -18
  39. package/package.json +19 -16
  40. package/src/KeepAspectRatioContainer.tsx +1 -1
  41. package/src/LikeC4Diagram.css.ts +2 -0
  42. package/src/LikeC4Diagram.props.ts +9 -4
  43. package/src/LikeC4Diagram.tsx +17 -11
  44. package/src/StaticLikeC4Diagram.tsx +1 -0
  45. package/src/globals.css.ts +1 -0
  46. package/src/index.ts +0 -3
  47. package/src/state/DiagramContext.tsx +21 -12
  48. package/src/state/diagramStore.ts +177 -38
  49. package/src/theme.css.ts +1 -0
  50. package/src/ui/DiagramTitlePanel.css.ts +2 -1
  51. package/src/ui/DiagramTitlePanel.tsx +3 -4
  52. package/src/ui/DynamicViewWalkthrough.css.ts +46 -0
  53. package/src/ui/DynamicViewWalkthrough.tsx +86 -0
  54. package/src/ui/OptionsPanel.tsx +5 -3
  55. package/src/ui/index.ts +1 -0
  56. package/src/ui/options/NodeOptions.tsx +23 -24
  57. package/src/xyflow/FitviewOnDiagramChange.tsx +89 -69
  58. package/src/xyflow/SelectEdgesOnNodeFocus.tsx +57 -39
  59. package/src/xyflow/SyncWithDiagram.tsx +55 -107
  60. package/src/xyflow/XYFlow.tsx +51 -35
  61. package/src/xyflow/XYFlowBackground.tsx +7 -5
  62. package/src/xyflow/XYFlowEvents.tsx +97 -92
  63. package/src/xyflow/XYFlowInner.tsx +50 -0
  64. package/src/xyflow/diagram-to-xyflow.ts +16 -21
  65. package/src/xyflow/edges/RelationshipEdge.tsx +70 -34
  66. package/src/xyflow/edges/edges.css.ts +68 -34
  67. package/src/xyflow/edges/utils.ts +31 -0
  68. package/src/xyflow/nodes/compound/CompoundNode.css.ts +6 -2
  69. package/src/xyflow/nodes/compound/CompoundNode.tsx +6 -3
  70. package/src/xyflow/nodes/element/ElementLink.tsx +13 -3
  71. package/src/xyflow/nodes/element/ElementNode.tsx +24 -19
  72. package/src/xyflow/nodes/element/element.css.ts +9 -2
  73. package/src/xyflow/nodes/shared/NavigateToBtn.tsx +1 -0
  74. package/src/xyflow/types.ts +7 -8
@@ -0,0 +1,86 @@
1
+ import { ActionIcon, Box, Button, rem, useComputedColorScheme } from '@mantine/core'
2
+ import {
3
+ IconHeart,
4
+ IconPhoto,
5
+ IconPlayerPlayFilled,
6
+ IconPlayerSkipBackFilled,
7
+ IconPlayerSkipForwardFilled,
8
+ IconPlayerStopFilled,
9
+ IconSettings
10
+ } from '@tabler/icons-react'
11
+ import clsx from 'clsx'
12
+ import { isNullish, isNumber } from 'remeda'
13
+ import { useDiagramState } from '../state/useDiagramStore'
14
+ import * as css from './DynamicViewWalkthrough.css'
15
+
16
+ export function DynamicViewWalkthrough() {
17
+ const {
18
+ nextDynamicStep,
19
+ stopDynamicView,
20
+ activeDynamicViewStep,
21
+ hasNextSteps
22
+ } = useDiagramState(s => ({
23
+ nextDynamicStep: s.nextDynamicStep,
24
+ stopDynamicView: s.stopDynamicView,
25
+ activeDynamicViewStep: s.activeDynamicViewStep,
26
+ hasNextSteps: (s.activeDynamicViewStep ?? 1) < s.view.edges.length
27
+ }))
28
+
29
+ const buttonProps = {
30
+ className: css.btn,
31
+ size: 'compact-xl',
32
+ radius: 'xl'
33
+ }
34
+
35
+ const nextStep = (increment = 1) => (e: React.MouseEvent) => {
36
+ e.stopPropagation()
37
+ nextDynamicStep(increment)
38
+ }
39
+
40
+ return (
41
+ <Box
42
+ className={clsx('react-flow__panel', css.container)}
43
+ >
44
+ {isNullish(activeDynamicViewStep)
45
+ && (
46
+ <Button
47
+ {...buttonProps}
48
+ className={clsx(css.buttons, css.btn)}
49
+ rightSection={<IconPlayerPlayFilled />}
50
+ onClick={nextStep()}
51
+ px={'xl'}>
52
+ Start
53
+ </Button>
54
+ )}
55
+ {isNumber(activeDynamicViewStep) && (
56
+ <Button.Group className={css.buttons}>
57
+ <Button
58
+ {...buttonProps}
59
+ pl={'lg'}
60
+ disabled={activeDynamicViewStep === 1}
61
+ onClick={nextStep(-1)}>
62
+ <IconPlayerSkipBackFilled />
63
+ </Button>
64
+
65
+ <Button
66
+ {...buttonProps}
67
+ px={'sm'}
68
+ onClick={e => {
69
+ e.stopPropagation()
70
+ stopDynamicView()
71
+ }}>
72
+ <IconPlayerStopFilled />
73
+ </Button>
74
+
75
+ <Button
76
+ {...buttonProps}
77
+ pr={'lg'}
78
+ disabled={!hasNextSteps}
79
+ onClick={nextStep()}>
80
+ <IconPlayerSkipForwardFilled />
81
+ </Button>
82
+ </Button.Group>
83
+ )}
84
+ </Box>
85
+ )
86
+ }
@@ -4,10 +4,13 @@ import clsx from 'clsx'
4
4
  import { shallowEqual } from 'fast-equals'
5
5
  import { AnimatePresence, motion } from 'framer-motion'
6
6
  import { useState } from 'react'
7
- import * as styles from './OptionsPanel.css'
7
+ import { isNullish, isTruthy } from 'remeda'
8
+ import { useDiagramState } from '../state'
8
9
  import { NodeOptions } from './options/NodeOptions'
10
+ import * as styles from './OptionsPanel.css'
9
11
 
10
12
  export default function OptionsPanel() {
13
+ const isFocusDisabled = useDiagramState(s => isNullish(s.focusedNodeId))
11
14
  const [selectedNodes, setSelectedNodes] = useState([] as string[])
12
15
 
13
16
  useOnSelectionChange({
@@ -19,10 +22,9 @@ export default function OptionsPanel() {
19
22
  }
20
23
  })
21
24
 
22
-
23
25
  return (
24
26
  <AnimatePresence mode="wait">
25
- {selectedNodes.length > 0 && (
27
+ {isFocusDisabled && selectedNodes.length > 0 && (
26
28
  <motion.div
27
29
  initial={{ opacity: 0, scale: 0.85 }}
28
30
  animate={{ opacity: 1, scale: 1 }}
package/src/ui/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  // export { default as Camera } from './Camera'
2
+ export * from './DynamicViewWalkthrough'
2
3
  export { default as OptionsPanel } from './OptionsPanel'
@@ -1,9 +1,9 @@
1
1
  import {
2
- ElementShapes,
3
- defaultTheme,
4
- invariant,
5
2
  type BorderStyle,
3
+ defaultTheme,
6
4
  type ElementShape,
5
+ ElementShapes,
6
+ invariant,
7
7
  type NonEmptyArray,
8
8
  type ThemeColor
9
9
  } from '@likec4/core'
@@ -13,14 +13,14 @@ import {
13
13
  ColorSwatch,
14
14
  Divider,
15
15
  Flex,
16
+ rem,
16
17
  SegmentedControl,
17
18
  Select,
18
19
  Slider,
19
20
  Stack,
20
21
  Text,
21
22
  Tooltip,
22
- TooltipGroup,
23
- rem
23
+ TooltipGroup
24
24
  } from '@mantine/core'
25
25
  import { useEffect, useState } from 'react'
26
26
  import { hasAtLeast, keys, takeWhile } from 'remeda'
@@ -57,7 +57,7 @@ export type ColorKey = typeof colors[0]['key']
57
57
  type XYNodesData = Pick<XYFlowNode, 'id' | 'data' | 'type'>
58
58
 
59
59
  export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] }) {
60
- // export function NodeOptions(props: { nodes: XYFlowNode[] }) {
60
+ // export function NodeOptions(props: { nodes: XYFlowNode[] }) {
61
61
  const diagramApi = useDiagramStoreApi()
62
62
  // const nodes = useXYNodesData(props.nodes.map(node => node.id))
63
63
  const nodes = useXYNodesData(selectedNodeIds)
@@ -104,25 +104,24 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
104
104
  onColorChange={(color: ColorKey | ThemeColorKey) => {
105
105
  triggerChange({ color })
106
106
  }} />
107
+
108
+ {showOpacityOption && (
109
+ <Box key={firstNode.id}>
110
+ <Divider label="opacity and border" labelPosition="left" />
111
+ <OpacityOption
112
+ node={firstNode}
113
+ onOpacityChange={(opacity: number) => {
114
+ triggerChange({ opacity })
115
+ }} />
116
+ <BorderStyleOption
117
+ node={firstNode}
118
+ onChange={(border: BorderStyle) => {
119
+ triggerChange({ border })
120
+ }}
121
+ />
122
+ </Box>
123
+ )}
107
124
  <NavigateToOption nodes={nodes} />
108
- <Box key={firstNode.id}>
109
- {showOpacityOption && (
110
- <>
111
- <Divider label="opacity and border" labelPosition="left" />
112
- <OpacityOption
113
- node={firstNode}
114
- onOpacityChange={(opacity: number) => {
115
- triggerChange({ opacity })
116
- }} />
117
- <BorderStyleOption
118
- node={firstNode}
119
- onChange={(border: BorderStyle) => {
120
- triggerChange({ border })
121
- }}
122
- />
123
- </>
124
- )}
125
- </Box>
126
125
  </Stack>
127
126
  )
128
127
  }
@@ -1,99 +1,119 @@
1
- import { useDebouncedCallback, useDebouncedEffect, useRafEffect } from '@react-hookz/web'
2
- import { useNodesInitialized } from '@xyflow/react'
3
- import { memo, useEffect, useRef, useState } from 'react'
1
+ import { useDebouncedEffect } from '@react-hookz/web'
2
+ import { useEffect, useRef } from 'react'
4
3
  import { type DiagramState, type DiagramStoreApi, useDiagramState, useDiagramStoreApi } from '../state'
5
- import { useXYStore } from './hooks'
4
+ import { useXYStore, useXYStoreApi, type XYStoreApi } from './hooks'
6
5
  import type { XYFlowState } from './types'
6
+ import { toDomPrecision } from './utils'
7
7
 
8
8
  function selectDimensions(state: XYFlowState) {
9
9
  return `${Math.round(state.width)}:${Math.round(state.height)}`
10
10
  }
11
11
 
12
- function FitViewOnViewportResize({ diagramApi }: { diagramApi: DiagramStoreApi }) {
12
+ function FitViewOnViewportResize({ diagramApi, xyflowApi }: {
13
+ xyflowApi: XYStoreApi
14
+ diagramApi: DiagramStoreApi
15
+ }) {
13
16
  const dimensions = useXYStore(selectDimensions)
14
17
  const prevDimensionsRef = useRef(dimensions)
15
18
 
16
19
  useDebouncedEffect(
17
20
  () => {
18
- if (prevDimensionsRef.current === dimensions) {
21
+ const { focusedNodeId, fitDiagram } = diagramApi.getState()
22
+ if (focusedNodeId || prevDimensionsRef.current === dimensions) {
19
23
  return
20
24
  }
21
- diagramApi.getState().fitDiagram()
25
+ fitDiagram(xyflowApi)
22
26
  prevDimensionsRef.current = dimensions
23
27
  },
24
- [dimensions],
25
- 150
28
+ [dimensions, diagramApi, xyflowApi],
29
+ 200,
30
+ 800
26
31
  )
27
32
 
28
33
  return null
29
34
  }
30
35
 
31
- const diagramChangeId = (s: DiagramState) => s.view.id + '_' + s.view.autoLayout + '_' + s.fitViewPadding
36
+ function selector({ view, xyflowSynced, viewportChanged, lastOnNavigate, ...s }: DiagramState) {
37
+ const viewId = view.id + '_' + view.autoLayout + '_' + s.fitViewPadding
38
+
39
+ const waitCorrection = lastOnNavigate && lastOnNavigate.toView === view.id
40
+ && lastOnNavigate.positionCorrected === false
41
+ const elTo = waitCorrection ? view.nodes.find(n => n.id === lastOnNavigate.element.id) : undefined
42
+ if (waitCorrection && elTo) {
43
+ return {
44
+ viewId,
45
+ xyflowSynced,
46
+ viewportChanged,
47
+ waitCorrection: true,
48
+ elFrom: lastOnNavigate.element,
49
+ elTo
50
+ }
51
+ }
52
+
53
+ return {
54
+ viewId,
55
+ xyflowSynced,
56
+ viewportChanged,
57
+ waitCorrection: false,
58
+ fromElement: null,
59
+ toElement: null
60
+ }
61
+ }
32
62
 
33
63
  /**
34
64
  * Fits the view when the view changes and nodes are initialized
35
65
  */
36
- function FitViewOnDiagramChanges() {
66
+ export function FitViewOnDiagramChange() {
67
+ const xyflowApi = useXYStoreApi()
68
+ const {
69
+ viewId: pendingViewId,
70
+ xyflowSynced,
71
+ viewportChanged,
72
+ waitCorrection,
73
+ elFrom,
74
+ elTo
75
+ } = useDiagramState(selector)
37
76
  const diagramApi = useDiagramStoreApi()
38
- const [viewportMoved, setViewportMoved] = useState(diagramApi.getState().viewportChanged)
39
- const [processedChangeId, setProcessed] = useState(diagramChangeId(diagramApi.getState()))
40
- const [pendingChangeId, setPending] = useState(processedChangeId)
41
-
42
- const isReady = useNodesInitialized({
43
- includeHiddenNodes: true
44
- })
45
-
46
- const schedulePending = useDebouncedCallback(
47
- (pendingChangeId: string) => {
48
- setPending(pendingChangeId)
49
- },
50
- [setPending],
51
- 75
52
- )
53
-
54
- useEffect(
55
- () =>
56
- diagramApi.subscribe(
57
- s => diagramChangeId(s),
58
- schedulePending
59
- ),
60
- [diagramApi, schedulePending]
61
- )
62
-
63
- useEffect(
64
- () =>
65
- diagramApi.subscribe(
66
- s => s.viewportChanged,
67
- setViewportMoved
68
- ),
69
- [diagramApi, setViewportMoved]
70
- )
71
-
72
- useRafEffect(
73
- () => {
74
- if (!isReady || processedChangeId === pendingChangeId) {
75
- return
77
+ const processedRef = useRef(pendingViewId)
78
+
79
+ useEffect(() => {
80
+ if (!xyflowSynced || pendingViewId === processedRef.current) {
81
+ return
82
+ }
83
+ if (waitCorrection && elTo && elFrom) {
84
+ const [x, y, zoom] = xyflowApi.getState().transform
85
+ 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
+ })
76
98
  }
77
- diagramApi.getState().fitDiagram()
78
- setProcessed(pendingChangeId)
79
- },
80
- [isReady, processedChangeId, pendingChangeId, diagramApi]
81
- )
82
-
83
- if (!viewportMoved && isReady) {
84
- return <FitViewOnViewportResize diagramApi={diagramApi} />
99
+ const centerTo = xyflow.flowToScreenPosition({
100
+ x: elTo.position[0] + elTo.width / 2,
101
+ y: elTo.position[1] + elTo.height / 2
102
+ }),
103
+ diff = {
104
+ x: toDomPrecision(centerFrom.x - centerTo.x),
105
+ y: toDomPrecision(centerFrom.y - centerTo.y)
106
+ }
107
+ xyflowApi.getState().panBy(diff)
108
+ diagramApi.setState({ lastOnNavigate: null })
109
+ return
110
+ }
111
+ processedRef.current = pendingViewId
112
+ diagramApi.getState().fitDiagram(xyflowApi)
113
+ }, [pendingViewId, xyflowSynced, waitCorrection])
114
+
115
+ if (!viewportChanged && xyflowSynced) {
116
+ return <FitViewOnViewportResize xyflowApi={xyflowApi} diagramApi={diagramApi} />
85
117
  }
86
118
  return null
87
119
  }
88
-
89
- const selectFitViewFlag = (s: DiagramState) => s.fitViewEnabled
90
-
91
- export const FitViewOnDiagramChange = memo(() => {
92
- const fitView = useDiagramState(selectFitViewFlag)
93
-
94
- if (!fitView) {
95
- return null
96
- }
97
-
98
- return <FitViewOnDiagramChanges />
99
- })
@@ -1,57 +1,75 @@
1
+ import { nonNullable } from '@likec4/core'
1
2
  import type { EdgeChange, NodeChange } from '@xyflow/react'
2
- import { useUpdateEffect } from '../hooks'
3
- import { useDiagramState } from '../state/useDiagramStore'
3
+ import { useUpdateEffect } from '../hooks/useUpdateEffect'
4
+ import { useDiagramState, useDiagramStoreApi } from '../state/useDiagramStore'
5
+ import { MinZoom } from './const'
4
6
  import { useXYStoreApi } from './hooks/useXYFlow'
5
7
  import type { XYFlowEdge, XYFlowNode } from './types'
6
8
 
7
9
  export function SelectEdgesOnNodeFocus() {
8
10
  const focusedNodeId = useDiagramState(s => s.focusedNodeId)
11
+ const diagramStore = useDiagramStoreApi()
9
12
  const xyflowApi = useXYStoreApi()
10
13
 
11
14
  useUpdateEffect(() => {
15
+ if (!focusedNodeId) {
16
+ xyflowApi.getState().resetSelectedElements()
17
+ return
18
+ }
19
+
12
20
  const edgeChanges = [] as EdgeChange<XYFlowEdge>[]
13
21
  const nodeChanges = [] as NodeChange<XYFlowNode>[]
14
- const { edges, nodes, triggerNodeChanges, triggerEdgeChanges } = xyflowApi.getState()
15
- if (focusedNodeId) {
16
- for (const edge of edges) {
17
- if (edge.source === focusedNodeId || edge.target === focusedNodeId) {
18
- edgeChanges.push({
19
- id: edge.id,
20
- type: 'select',
21
- selected: true
22
- })
23
- } else if (edge.selected) {
24
- edgeChanges.push({
25
- id: edge.id,
26
- type: 'select',
27
- selected: false
28
- })
29
- }
30
- }
31
- for (const node of nodes) {
32
- if (node.selected && node.id !== focusedNodeId) {
33
- nodeChanges.push({
34
- id: node.id,
35
- type: 'select',
36
- selected: false
37
- })
22
+ const { edgeLookup, nodeLookup, transform, triggerNodeChanges, fitView, triggerEdgeChanges } = xyflowApi.getState()
23
+ const fitViewNodes = new Set<XYFlowNode>()
24
+ fitViewNodes.add(nonNullable(nodeLookup.get(focusedNodeId)))
25
+
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 = nodeLookup.get(edge.source === focusedNodeId ? edge.target : edge.source)
34
+ if (anotherNode) {
35
+ fitViewNodes.add(anotherNode)
38
36
  }
37
+ } else if (edge.selected) {
38
+ edgeChanges.push({
39
+ id: edge.id,
40
+ type: 'select',
41
+ selected: false
42
+ })
39
43
  }
40
- nodeChanges.push({
41
- id: focusedNodeId,
42
- type: 'select',
43
- selected: true
44
- })
45
- if (edgeChanges.length > 0) {
46
- triggerEdgeChanges(edgeChanges)
47
- }
48
- if (nodeChanges.length > 0) {
49
- triggerNodeChanges(nodeChanges)
44
+ }
45
+ for (const [, node] of nodeLookup) {
46
+ if (node.selected && node.id !== focusedNodeId) {
47
+ nodeChanges.push({
48
+ id: node.id,
49
+ type: 'select',
50
+ selected: false
51
+ })
50
52
  }
51
- } else {
52
- xyflowApi.getState().resetSelectedElements()
53
53
  }
54
- }, [focusedNodeId])
54
+ nodeChanges.push({
55
+ id: focusedNodeId,
56
+ type: 'select',
57
+ selected: true
58
+ })
59
+ if (edgeChanges.length > 0) {
60
+ triggerEdgeChanges(edgeChanges)
61
+ }
62
+ triggerNodeChanges(nodeChanges)
63
+
64
+ fitView({
65
+ includeHiddenNodes: true,
66
+ duration: 300,
67
+ padding: 0.1,
68
+ minZoom: MinZoom,
69
+ maxZoom: Math.max(1, transform[2]),
70
+ nodes: Array.from(fitViewNodes)
71
+ })
72
+ }, [diagramStore, xyflowApi, focusedNodeId])
55
73
 
56
74
  return null
57
75
  }
@@ -1,125 +1,73 @@
1
- import { deepEqual as eq, shallowEqual } from 'fast-equals'
2
- import { useXYFlow, useXYStoreApi } from './hooks'
3
- import type { XYFlowNode } from './types'
1
+ import { deepEqual as eq } from 'fast-equals'
2
+ import { useXYStoreApi } from './hooks'
4
3
 
5
- import { memo, useEffect, useRef } from 'react'
6
- import { useDiagramStoreApi } from '../state'
4
+ import { useEffect } from 'react'
5
+ import { useDiagramState, useDiagramStoreApi } from '../state'
7
6
  import { diagramViewToXYFlowData } from './diagram-to-xyflow'
8
- import { toDomPrecision } from './utils'
9
-
10
- const isSameNode = (a: XYFlowNode) => (b: XYFlowNode) =>
11
- a.id === b.id && a.type === b.type && Object.is(a.parentId, b.parentId)
12
7
 
13
8
  /**
14
9
  * Syncs the diagram state with the XYFlow instance
15
10
  */
16
- export const SyncWithDiagram = memo(() => {
17
- const _xyflow = useXYFlow()
18
- const xyflowRef = useRef(_xyflow)
19
- xyflowRef.current = _xyflow
20
-
11
+ export function SyncWithDiagram() {
12
+ const xyflowSynced = useDiagramState(s => s.xyflowSynced)
21
13
  const xyflowApi = useXYStoreApi()
22
14
  const diagramStoreApi = useDiagramStoreApi()
23
15
 
24
16
  useEffect(() => {
25
- return diagramStoreApi.subscribe(
26
- // selector
27
- state => ({
28
- viewId: state.view.id,
29
- nodes: state.view.nodes,
30
- edges: state.view.edges
31
- }),
32
- // listener
33
- ({ viewId, nodes, edges }) => {
34
- const {
35
- lastOnNavigate,
36
- nodesDraggable,
37
- nodesSelectable,
38
- focusedNodeId
39
- } = diagramStoreApi.getState()
40
- const updates = diagramViewToXYFlowData({ nodes, edges }, {
41
- draggable: nodesDraggable,
42
- selectable: nodesSelectable || focusedNodeId !== null
43
- })
44
-
45
- xyflowRef.current.setNodes(prev =>
46
- updates.nodes.map(update => {
47
- const existing = prev.find(isSameNode(update))
48
- if (existing) {
49
- if (eq(existing.data.element, update.data.element)) {
50
- return existing
51
- }
52
- return {
53
- ...existing,
54
- ...update
55
- }
56
- }
57
- return update
58
- })
59
- )
17
+ if (xyflowSynced) {
18
+ return
19
+ }
20
+ const {
21
+ view,
22
+ nodesDraggable,
23
+ nodesSelectable,
24
+ focusedNodeId
25
+ } = diagramStoreApi.getState()
26
+ const updates = diagramViewToXYFlowData(view, {
27
+ draggable: nodesDraggable,
28
+ selectable: nodesSelectable || focusedNodeId !== null
29
+ })
60
30
 
61
- xyflowRef.current.setEdges(prev =>
62
- updates.edges.map(update => {
63
- const existing = prev.find(e => e.id === update.id)
64
- if (existing) {
65
- return eq(existing.data, update.data) ? existing : {
66
- ...existing,
67
- ...update
68
- }
69
- }
70
- return update
71
- })
72
- )
31
+ const {
32
+ nodeLookup,
33
+ edgeLookup,
34
+ setNodes,
35
+ setEdges
36
+ } = xyflowApi.getState()
73
37
 
74
- if (lastOnNavigate?.toView === viewId && lastOnNavigate.positionCorrected !== true) {
75
- const elFrom = lastOnNavigate.element
76
- const elTo = nodes.find(n => n.id === elFrom.id)
77
- if (elTo) {
78
- const centerFrom = lastOnNavigate.elementCenterScreenPosition
79
- let nextZoom = Math.min(elFrom.width / elTo.width, elFrom.height / elTo.height)
80
-
81
- // TODO: investigate why 2 animation frames are needed
82
- requestAnimationFrame(() => {
83
- requestAnimationFrame(() => {
84
- const v = xyflowRef.current.getViewport()
85
- if (nextZoom < v.zoom) {
86
- nextZoom = Math.max(nextZoom, v.zoom * 0.5)
87
- xyflowRef.current.setViewport({
88
- x: v.x,
89
- y: v.y,
90
- zoom: nextZoom
91
- })
92
- }
38
+ setNodes(updates.nodes.map(update => {
39
+ const existing = nodeLookup.get(update.id)?.internals.userNode
40
+ if (existing && existing.type === update.type && eq(existing.parentId, update.parentId)) {
41
+ if (eq(existing.data.element, update.data.element)) {
42
+ return existing
43
+ }
44
+ return {
45
+ ...existing,
46
+ ...update
47
+ }
48
+ }
49
+ return update
50
+ }))
93
51
 
94
- const centerTo = xyflowRef.current.flowToScreenPosition({
95
- x: elTo.position[0] + elTo.width / 2,
96
- y: elTo.position[1] + elTo.height / 2
97
- }),
98
- diff = {
99
- x: toDomPrecision(centerFrom.x - centerTo.x),
100
- y: toDomPrecision(centerFrom.y - centerTo.y)
101
- }
102
- xyflowApi.getState().panBy(diff)
103
- })
104
- })
105
- }
106
- diagramStoreApi.setState(
107
- {
108
- lastOnNavigate: {
109
- ...lastOnNavigate,
110
- positionCorrected: true
111
- }
112
- },
113
- false,
114
- 'positionCorrected'
115
- )
52
+ setEdges(updates.edges.map(update => {
53
+ const existing = edgeLookup.get(update.id)
54
+ if (existing) {
55
+ return eq(existing.data.edge, update.data.edge) ? existing : {
56
+ ...existing,
57
+ ...update
116
58
  }
117
- },
118
- {
119
- equalityFn: shallowEqual
120
59
  }
60
+ return update
61
+ }))
62
+
63
+ diagramStoreApi.setState(
64
+ {
65
+ xyflowSynced: true
66
+ },
67
+ false,
68
+ 'xyflowSynced'
121
69
  )
122
- }, [xyflowApi, diagramStoreApi])
70
+ }, [xyflowSynced, xyflowApi, diagramStoreApi])
123
71
 
124
72
  return null
125
- })
73
+ }