@likec4/diagram 1.1.1 → 1.2.1

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 (80) 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 +10 -5
  4. package/dist/StaticLikeC4Diagram.d.ts +1 -1
  5. package/dist/hooks/useUpdateEffect.d.ts +1 -1
  6. package/dist/icons/Clipboard.d.ts +1 -1
  7. package/dist/icons/Link.d.ts +1 -1
  8. package/dist/icons/ZoomIn.d.ts +1 -1
  9. package/dist/index.d.ts +0 -1
  10. package/dist/index.mjs +2604 -1502
  11. package/dist/mantine/EnsureMantine.d.ts +1 -1
  12. package/dist/state/DiagramContext.d.ts +2307 -192
  13. package/dist/state/WhenInitialized.d.ts +1 -1
  14. package/dist/state/diagramStore.d.ts +2220 -97
  15. package/dist/state/useDiagramStore.d.ts +2124 -9
  16. package/dist/style.css +210 -136
  17. package/dist/theme.css.d.ts +1 -0
  18. package/dist/ui/DiagramTitlePanel.d.ts +1 -2
  19. package/dist/ui/DynamicViewWalkthrough.css.d.ts +3 -0
  20. package/dist/ui/DynamicViewWalkthrough.d.ts +1 -0
  21. package/dist/ui/OptionsPanel.d.ts +1 -2
  22. package/dist/ui/index.d.ts +1 -0
  23. package/dist/ui/options/NodeOptions.d.ts +1 -2
  24. package/dist/xyflow/FitviewOnDiagramChange.d.ts +4 -2
  25. package/dist/xyflow/SyncWithDiagram.d.ts +1 -2
  26. package/dist/xyflow/XYFlow.d.ts +5 -8
  27. package/dist/xyflow/XYFlowBackground.d.ts +6 -5
  28. package/dist/xyflow/XYFlowEvents.d.ts +60 -66
  29. package/dist/xyflow/XYFlowInner.d.ts +11 -0
  30. package/dist/xyflow/diagram-to-xyflow.d.ts +2 -2
  31. package/dist/xyflow/edges/RelationshipEdge.d.ts +17 -17
  32. package/dist/xyflow/edges/edges.css.d.ts +4 -2
  33. package/dist/xyflow/edges/utils.d.ts +3 -1
  34. package/dist/xyflow/hooks/useLayoutConstraints.d.ts +1 -1
  35. package/dist/xyflow/hooks/useXYFlow.d.ts +162 -162
  36. package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +1 -0
  37. package/dist/xyflow/nodes/compound/CompoundNode.d.ts +1 -1
  38. package/dist/xyflow/nodes/element/ElementIcon.d.ts +1 -1
  39. package/dist/xyflow/nodes/element/ElementLink.d.ts +1 -1
  40. package/dist/xyflow/nodes/element/ElementNode.d.ts +2 -2
  41. package/dist/xyflow/nodes/element/ElementShapeSvg.d.ts +2 -2
  42. package/dist/xyflow/nodes/element/element.css.d.ts +2 -1
  43. package/dist/xyflow/nodes/shared/NavigateToBtn.d.ts +1 -2
  44. package/dist/xyflow/types.d.ts +23 -23
  45. package/package.json +23 -21
  46. package/src/KeepAspectRatioContainer.tsx +1 -1
  47. package/src/LikeC4Diagram.css.ts +2 -0
  48. package/src/LikeC4Diagram.props.ts +9 -4
  49. package/src/LikeC4Diagram.tsx +17 -11
  50. package/src/StaticLikeC4Diagram.tsx +1 -0
  51. package/src/globals.css.ts +1 -0
  52. package/src/index.ts +0 -3
  53. package/src/state/DiagramContext.tsx +21 -12
  54. package/src/state/diagramStore.ts +177 -38
  55. package/src/theme.css.ts +1 -0
  56. package/src/ui/DiagramTitlePanel.css.ts +2 -1
  57. package/src/ui/DiagramTitlePanel.tsx +3 -4
  58. package/src/ui/DynamicViewWalkthrough.css.ts +46 -0
  59. package/src/ui/DynamicViewWalkthrough.tsx +86 -0
  60. package/src/ui/OptionsPanel.tsx +5 -3
  61. package/src/ui/index.ts +1 -0
  62. package/src/ui/options/NodeOptions.tsx +23 -24
  63. package/src/xyflow/FitviewOnDiagramChange.tsx +89 -69
  64. package/src/xyflow/SelectEdgesOnNodeFocus.tsx +57 -39
  65. package/src/xyflow/SyncWithDiagram.tsx +55 -107
  66. package/src/xyflow/XYFlow.tsx +51 -35
  67. package/src/xyflow/XYFlowBackground.tsx +7 -5
  68. package/src/xyflow/XYFlowEvents.tsx +100 -95
  69. package/src/xyflow/XYFlowInner.tsx +50 -0
  70. package/src/xyflow/diagram-to-xyflow.ts +16 -21
  71. package/src/xyflow/edges/RelationshipEdge.tsx +70 -34
  72. package/src/xyflow/edges/edges.css.ts +68 -34
  73. package/src/xyflow/edges/utils.ts +31 -0
  74. package/src/xyflow/nodes/compound/CompoundNode.css.ts +6 -2
  75. package/src/xyflow/nodes/compound/CompoundNode.tsx +6 -3
  76. package/src/xyflow/nodes/element/ElementLink.tsx +13 -3
  77. package/src/xyflow/nodes/element/ElementNode.tsx +24 -19
  78. package/src/xyflow/nodes/element/element.css.ts +9 -2
  79. package/src/xyflow/nodes/shared/NavigateToBtn.tsx +1 -0
  80. package/src/xyflow/types.ts +7 -8
@@ -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
+ }
@@ -1,16 +1,17 @@
1
1
  import { useMantineColorScheme } from '@mantine/core'
2
- import { Controls, PanOnScrollMode, ReactFlow } from '@xyflow/react'
3
- import { DEV } from 'esm-env'
2
+ import { Controls, ReactFlow, useOnViewportChange } from '@xyflow/react'
3
+ import { deepEqual as eq, shallowEqual } from 'fast-equals'
4
4
  import { type CSSProperties, memo, type PropsWithChildren } from 'react'
5
+ import { omit, omitBy } from 'remeda'
5
6
  import type { SetNonNullable, Simplify } from 'type-fest'
6
7
  import type { LikeC4DiagramProperties } from '../LikeC4Diagram.props'
7
8
  import { type DiagramState, useDiagramState, useDiagramStoreApi } from '../state'
8
9
  import { MinZoom } from './const'
9
10
  import { RelationshipEdge } from './edges/RelationshipEdge'
10
11
  import { useLayoutConstraints } from './hooks/useLayoutConstraints'
12
+ import { useXYStoreApi } from './hooks/useXYFlow'
11
13
  import { CompoundNode } from './nodes/compound'
12
14
  import { ElementNode } from './nodes/element'
13
- import { SelectEdgesOnNodeFocus } from './SelectEdgesOnNodeFocus'
14
15
  import { XYFlowEdge, XYFlowNode } from './types'
15
16
  import { XYFlowBackground } from './XYFlowBackground'
16
17
  import { useXYFlowEvents } from './XYFlowEvents'
@@ -23,24 +24,22 @@ const edgeTypes = {
23
24
  relationship: RelationshipEdge
24
25
  }
25
26
 
26
- type OnlyExpectedProps = Required<
27
- Pick<
28
- LikeC4DiagramProperties,
29
- | 'className'
30
- | 'controls'
31
- | 'background'
32
- >
33
- >
27
+ type XYFlowWrapperProps = PropsWithChildren<{
28
+ className?: string | undefined
29
+ defaultNodes: XYFlowNode[]
30
+ defaultEdges: XYFlowEdge[]
31
+ style?: CSSProperties | undefined
32
+ }>
34
33
 
35
- type XYFlowWrapperProps = Simplify<
36
- PropsWithChildren<
37
- SetNonNullable<OnlyExpectedProps> & {
38
- defaultNodes: XYFlowNode[]
39
- defaultEdges: XYFlowEdge[]
40
- style?: CSSProperties | undefined
41
- }
42
- >
43
- >
34
+ // const propsAreEqual = (
35
+ // prev: XYFlowWrapperProps,
36
+ // next: XYFlowWrapperProps
37
+ // ) => {
38
+ // return shallowEqual(
39
+ // omit(prev, ['children']),
40
+ // omit(next, ['children'])
41
+ // )
42
+ // }
44
43
 
45
44
  const selector = (s: DiagramState) => ({
46
45
  nodesSelectable: s.nodesSelectable || s.focusedNodeId !== null,
@@ -57,15 +56,14 @@ const selector = (s: DiagramState) => ({
57
56
  pannable: s.pannable
58
57
  })
59
58
 
60
- function XYFlowWrapper({
59
+ export function XYFlow({
61
60
  className,
62
61
  children,
63
62
  defaultNodes,
64
63
  defaultEdges,
65
- controls,
66
- background,
67
64
  style
68
65
  }: XYFlowWrapperProps) {
66
+ const xyflowApi = useXYStoreApi()
69
67
  const diagramApi = useDiagramStoreApi()
70
68
  const {
71
69
  nodesSelectable,
@@ -74,17 +72,39 @@ function XYFlowWrapper({
74
72
  fitViewPadding,
75
73
  pannable,
76
74
  zoomable,
77
- ...editor
75
+ hasOnNodeClick,
76
+ hasOnNavigateTo,
77
+ hasOnNodeContextMenu,
78
+ hasOnCanvasContextMenu,
79
+ hasOnEdgeContextMenu,
80
+ hasOnEdgeClick
78
81
  } = useDiagramState(selector)
79
82
 
80
83
  const layoutConstraints = useLayoutConstraints()
81
84
 
82
85
  const handlers = useXYFlowEvents()
83
- const isBgWithPattern = background !== 'transparent' && background !== 'solid'
84
86
 
85
87
  const { colorScheme } = useMantineColorScheme()
86
88
  const colorMode = colorScheme !== 'auto' ? colorScheme : undefined
87
89
 
90
+ /**
91
+ * WORKAROUND - Called on viewport change
92
+ * Viewport transform is not rounded to integers which results in blurry nodes on some resolution
93
+ * https://github.com/xyflow/xyflow/issues/3282
94
+ * https://github.com/likec4/likec4/issues/734
95
+ */
96
+ useOnViewportChange({
97
+ onEnd: ({ x, y, zoom }) => {
98
+ const rounded = {
99
+ x: Math.round(x),
100
+ y: Math.round(y)
101
+ }
102
+ if (x !== rounded.x || y !== rounded.y) {
103
+ xyflowApi.setState({ transform: [rounded.x, rounded.y, zoom] })
104
+ }
105
+ }
106
+ })
107
+
88
108
  return (
89
109
  <ReactFlow<XYFlowNode, XYFlowEdge>
90
110
  className={className}
@@ -115,8 +135,8 @@ function XYFlowWrapper({
115
135
  panOnScroll={pannable}
116
136
  panOnDrag={pannable}
117
137
  elementsSelectable={nodesSelectable}
118
- nodesFocusable={nodesDraggable || nodesSelectable || editor.hasOnNodeClick || editor.hasOnNavigateTo}
119
- edgesFocusable={editor.hasOnEdgeClick}
138
+ nodesFocusable={nodesDraggable || nodesSelectable || hasOnNodeClick || hasOnNavigateTo}
139
+ edgesFocusable={hasOnEdgeClick}
120
140
  {...(!nodesSelectable && {
121
141
  selectionKeyCode: null
122
142
  })}
@@ -139,25 +159,21 @@ function XYFlowWrapper({
139
159
  onNodeClick={handlers.onNodeClick}
140
160
  onNodeDoubleClick={handlers.onNodeDoubleClick}
141
161
  onEdgeClick={handlers.onEdgeClick}
142
- onViewportChange={handlers.onViewportChange}
143
162
  onInit={() => {
144
163
  diagramApi.setState({ initialized: true }, false, 'initialized')
145
164
  }}
146
- {...(editor.hasOnNodeContextMenu && {
165
+ {...(hasOnNodeContextMenu && {
147
166
  onNodeContextMenu: handlers.onNodeContextMenu
148
167
  })}
149
- {...(editor.hasOnEdgeContextMenu && {
168
+ {...(hasOnEdgeContextMenu && {
150
169
  onEdgeContextMenu: handlers.onEdgeContextMenu
151
170
  })}
152
- {...(editor.hasOnCanvasContextMenu && {
171
+ {...(hasOnCanvasContextMenu && {
153
172
  onPaneContextMenu: handlers.onPaneContextMenu
154
173
  })}>
155
- {isBgWithPattern && <XYFlowBackground background={background} />}
156
- {controls && <Controls position={'bottom-right'} />}
157
- {zoomable && !editor.hasOnNodeClick && <SelectEdgesOnNodeFocus />}
158
174
  {children}
159
175
  </ReactFlow>
160
176
  )
161
177
  }
162
178
 
163
- export const XYFlow = memo(XYFlowWrapper) as typeof XYFlowWrapper
179
+ // export const XYFlow = memo(XYFlowWrapper, propsAreEqual) as typeof XYFlowWrapper
@@ -1,16 +1,18 @@
1
1
  import { nonexhaustive } from '@likec4/core'
2
2
  import { Background, type BackgroundProps, BackgroundVariant } from '@xyflow/react'
3
- import { deepEqual } from 'fast-equals'
4
- import { memo } from 'react'
5
3
  import type { OverrideProperties } from 'type-fest'
6
4
 
7
5
  export type XYBackgroundVariant = 'dots' | 'lines' | 'cross'
8
- export type XYBackgroundProps =
6
+ export type XYBackground =
9
7
  | XYBackgroundVariant
10
8
  | OverrideProperties<BackgroundProps, {
11
9
  variant: XYBackgroundVariant
12
10
  }>
13
11
 
12
+ export type XYBackgroundProps = {
13
+ background: XYBackground
14
+ }
15
+
14
16
  function literalToEnum(value: XYBackgroundVariant): BackgroundVariant {
15
17
  switch (value) {
16
18
  case 'dots':
@@ -24,10 +26,10 @@ function literalToEnum(value: XYBackgroundVariant): BackgroundVariant {
24
26
  }
25
27
  }
26
28
 
27
- export const XYFlowBackground = memo<{ background: XYBackgroundProps }>(({ background }) => {
29
+ export function XYFlowBackground({ background }: XYBackgroundProps) {
28
30
  if (typeof background === 'string') {
29
31
  return <Background variant={literalToEnum(background)} />
30
32
  }
31
33
  const { variant, ...rest } = background
32
34
  return <Background variant={literalToEnum(variant)} {...rest} />
33
- }, deepEqual)
35
+ }
@@ -1,9 +1,9 @@
1
1
  import type { ReactFlowProps } from '@xyflow/react'
2
- import { useEffect, useMemo, useRef } from 'react'
2
+ import { useMemo, useRef } from 'react'
3
3
  import type { Simplify } from 'type-fest'
4
4
  import { useDiagramStoreApi } from '../state'
5
5
  import { useXYStoreApi } from './hooks'
6
- import type { XYFlowEdge, XYFlowNode, XYFlowState } from './types'
6
+ import type { XYFlowEdge, XYFlowNode } from './types'
7
7
 
8
8
  type XYFlowEventHandlers = Simplify<
9
9
  Required<
@@ -22,23 +22,17 @@ type XYFlowEventHandlers = Simplify<
22
22
  | 'onNodeMouseLeave'
23
23
  | 'onEdgeMouseEnter'
24
24
  | 'onEdgeMouseLeave'
25
- | 'onViewportChange'
26
25
  >
27
26
  >
28
27
  >
29
28
 
30
- const viewportDomNode = (state: XYFlowState) =>
31
- state.domNode?.querySelector<HTMLDivElement>('.react-flow__viewport') ?? null
32
-
33
29
  export function useXYFlowEvents() {
34
30
  const diagramApi = useDiagramStoreApi()
35
31
  const xyflowApi = useXYStoreApi()
36
32
 
37
33
  const dblclickTimeout = useRef<number>()
38
34
 
39
- const viewportRef = useRef(viewportDomNode(xyflowApi.getState()))
40
-
41
- const dbclickGuarg = () => {
35
+ const dbclickLock = () => {
42
36
  if (dblclickTimeout.current !== undefined) {
43
37
  return true
44
38
  }
@@ -48,46 +42,41 @@ export function useXYFlowEvents() {
48
42
  return false
49
43
  }
50
44
 
51
- const transformRef = useRef('')
52
- const cbRef = useRef<number>()
53
-
54
- useEffect(() =>
55
- xyflowApi.subscribe((state, prev) => {
56
- if (state.domNode !== prev.domNode || (state.domNode && !viewportRef.current)) {
57
- viewportRef.current = viewportDomNode(state)
58
- }
59
- }), [xyflowApi])
45
+ // If we are in focused mode, on edge enter we want to "highlight" the other node
46
+ // This ref contains the id of this node
47
+ const hoveredNodeFromOnEdgeEnterRef = useRef('')
60
48
 
61
49
  return useMemo(() =>
62
50
  ({
63
51
  onDoubleClick: (event) => {
64
- diagramApi.getState().resetLastClicked()
65
- const diagramState = diagramApi.getState()
66
- if (diagramState.onCanvasDblClick) {
67
- diagramState.onCanvasDblClick(event)
68
- return
69
- }
70
- if (diagramState.fitViewEnabled && diagramState.zoomable) {
71
- diagramState.fitDiagram()
72
- event.stopPropagation()
73
- return
52
+ const {
53
+ fitViewEnabled,
54
+ onCanvasDblClick,
55
+ resetLastClicked,
56
+ fitDiagram
57
+ } = diagramApi.getState()
58
+ if (fitViewEnabled) {
59
+ fitDiagram(xyflowApi)
60
+ if (!onCanvasDblClick) {
61
+ event.stopPropagation()
62
+ }
74
63
  }
64
+ resetLastClicked()
65
+ onCanvasDblClick?.(event)
75
66
  },
76
67
  onPaneClick: (event) => {
77
- if (dbclickGuarg()) {
68
+ if (dbclickLock()) {
78
69
  return
79
70
  }
80
71
  const { focusedNodeId, fitDiagram, onCanvasClick, resetLastClicked } = diagramApi.getState()
81
- resetLastClicked()
82
- if (onCanvasClick) {
83
- onCanvasClick(event)
84
- return
85
- }
86
- // reset focus if clicked on empty space
87
- if (focusedNodeId) {
88
- fitDiagram()
89
- event.stopPropagation()
72
+ if (!!focusedNodeId) {
73
+ fitDiagram(xyflowApi)
74
+ if (!onCanvasClick) {
75
+ event.stopPropagation()
76
+ }
90
77
  }
78
+ resetLastClicked()
79
+ onCanvasClick?.(event)
91
80
  },
92
81
  onNodeContextMenu: (event, xynode) => {
93
82
  diagramApi.getState().setLastClickedNode(xynode.id)
@@ -110,49 +99,63 @@ export function useXYFlowEvents() {
110
99
  })
111
100
  },
112
101
  onNodeClick: (event, xynode) => {
113
- diagramApi.getState().setLastClickedNode(xynode.id)
114
- const { focusedNodeId, fitDiagram, onNodeClick } = diagramApi.getState()
115
- if (onNodeClick) {
116
- onNodeClick({
117
- element: xynode.data.element,
118
- xynode,
119
- event
120
- })
121
- return
122
- }
123
- // if we clicked on a node that is not focused, fit the diagram
102
+ const {
103
+ focusedNodeId,
104
+ focusOnNode,
105
+ onNodeClick,
106
+ setLastClickedNode
107
+ } = diagramApi.getState()
108
+ setLastClickedNode(xynode.id)
109
+ // if we focused on a node, and clicked on another node - focus on the clicked node
124
110
  if (!!focusedNodeId && focusedNodeId !== xynode.id) {
125
- fitDiagram(xynode)
126
- event.stopPropagation()
111
+ focusOnNode(xynode.id)
112
+ if (!onNodeClick) {
113
+ // user did not provide a custom handler, stop propagation
114
+ event.stopPropagation()
115
+ }
127
116
  }
117
+ onNodeClick?.({
118
+ element: xynode.data.element,
119
+ xynode,
120
+ event
121
+ })
128
122
  },
129
123
  onNodeDoubleClick: (event, xynode) => {
130
- diagramApi.getState().setLastClickedNode(xynode.id)
131
- const { zoomable, fitViewEnabled, fitDiagram, onNodeClick } = diagramApi.getState()
132
- if (onNodeClick) {
133
- return
134
- }
135
- if (zoomable && fitViewEnabled) {
136
- fitDiagram(xynode)
124
+ const {
125
+ focusedNodeId,
126
+ zoomable,
127
+ fitViewEnabled,
128
+ fitDiagram,
129
+ focusOnNode,
130
+ setLastClickedNode
131
+ } = diagramApi.getState()
132
+ setLastClickedNode(xynode.id)
133
+ if (!!focusedNodeId || (zoomable && fitViewEnabled)) {
134
+ // if we are already focused on the node, cancel
135
+ if (focusedNodeId === xynode.id) {
136
+ fitDiagram(xyflowApi)
137
+ } else {
138
+ focusOnNode(xynode.id)
139
+ }
137
140
  event.stopPropagation()
138
141
  }
139
142
  },
140
143
  onEdgeClick: (event, xyedge) => {
141
144
  diagramApi.getState().setLastClickedEdge(xyedge.id)
142
- const { focusedNodeId, fitDiagram, onEdgeClick, xyflow } = diagramApi.getState()
143
- if (onEdgeClick) {
144
- onEdgeClick({
145
- relation: xyedge.data.edge,
146
- xyedge,
147
- event
148
- })
149
- return
150
- }
145
+ const { focusedNodeId, focusOnNode, onEdgeClick } = diagramApi.getState()
146
+ // if we focused on a node, and clicked on an edge connected to it - focus on the other node
151
147
  if (focusedNodeId && (focusedNodeId === xyedge.source || focusedNodeId === xyedge.target)) {
152
- const nextFocus = focusedNodeId === xyedge.source ? xyedge.target : xyedge.source
153
- fitDiagram(xyflow.getNode(nextFocus))
154
- event.stopPropagation()
148
+ focusOnNode(focusedNodeId === xyedge.source ? xyedge.target : xyedge.source)
149
+ if (!onEdgeClick) {
150
+ // user did not provide a custom handler, stop propagation
151
+ event.stopPropagation()
152
+ }
155
153
  }
154
+ onEdgeClick?.({
155
+ relation: xyedge.data.edge,
156
+ xyedge,
157
+ event
158
+ })
156
159
  },
157
160
  onMoveEnd: (event, _viewport) => {
158
161
  // if event is present, the move was triggered by user
@@ -161,34 +164,36 @@ export function useXYFlowEvents() {
161
164
  diagramApi.setState({ viewportChanged }, false, `viewport-changed: ${viewportChanged}`)
162
165
  }
163
166
  },
164
- onNodeMouseEnter: (event, xynode) => {
165
- diagramApi.getState().setHoveredNode(xynode.id)
166
- },
167
- onNodeMouseLeave: (event, xynode) => {
168
- diagramApi.getState().setHoveredNode(null)
167
+ onNodeMouseEnter: (_event, xynode) => {
168
+ hoveredNodeFromOnEdgeEnterRef.current = ''
169
+ const { hoveredNodeId, setHoveredNode } = diagramApi.getState()
170
+ if (hoveredNodeId !== xynode.id) {
171
+ setHoveredNode(xynode.id)
172
+ }
169
173
  },
170
- onEdgeMouseEnter: (event, xyedge) => {
171
- diagramApi.getState().setHoveredEdge(xyedge.id)
174
+ onNodeMouseLeave: (_event, xynode) => {
175
+ const { hoveredNodeId, setHoveredNode } = diagramApi.getState()
176
+ if (hoveredNodeId === xynode.id) {
177
+ setHoveredNode(null)
178
+ }
172
179
  },
173
- onEdgeMouseLeave: (event, xyedge) => {
174
- diagramApi.getState().setHoveredEdge(null)
180
+ onEdgeMouseEnter: (_event, { id, source, target }) => {
181
+ const { hoveredNodeId, focusedNodeId, setHoveredEdge, setHoveredNode } = diagramApi.getState()
182
+ setHoveredEdge(id)
183
+ if ((focusedNodeId === source || focusedNodeId === target) && focusedNodeId !== hoveredNodeId) {
184
+ const next = hoveredNodeFromOnEdgeEnterRef.current = source === focusedNodeId ? target : source
185
+ setHoveredNode(next)
186
+ }
175
187
  },
176
- /**
177
- * WOKAROUND:
178
- * Viewport transform is not rounded to integers which results in blurry nodes on some resolution
179
- * https://github.com/xyflow/xyflow/issues/3282
180
- */
181
- onViewportChange: ({ x, y, zoom }) => {
182
- const scale = zoom != 1 ? zoom.toFixed(6) : '1'
183
- transformRef.current = `translate(${Math.round(x)}px, ${Math.round(y)}px) scale(${scale})`
184
- cbRef.current ??= requestIdleCallback(() => {
185
- cbRef.current = undefined
186
- if (viewportRef.current && viewportRef.current.style.transform !== transformRef.current) {
187
- viewportRef.current.style.transform = transformRef.current
188
- }
189
- }, {
190
- timeout: 1000
191
- })
188
+ onEdgeMouseLeave: (_event, xyedge) => {
189
+ const { hoveredEdgeId, setHoveredEdge, hoveredNodeId, setHoveredNode } = diagramApi.getState()
190
+ if (hoveredEdgeId === xyedge.id) {
191
+ setHoveredEdge(null)
192
+ }
193
+ if (hoveredNodeId === hoveredNodeFromOnEdgeEnterRef.current) {
194
+ hoveredNodeFromOnEdgeEnterRef.current = ''
195
+ setHoveredNode(null)
196
+ }
192
197
  }
193
198
  }) satisfies XYFlowEventHandlers, [diagramApi, xyflowApi])
194
199
  }