@likec4/diagram 1.6.0 → 1.7.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 (49) hide show
  1. package/dist/LikeC4Diagram.d.ts +1 -1
  2. package/dist/LikeC4Diagram.props.d.ts +10 -11
  3. package/dist/hooks/useUpdateEffect.d.ts +1 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.mjs +769 -8587
  6. package/dist/state/DiagramContext.d.ts +746 -181
  7. package/dist/state/diagram-to-xyflow.d.ts +10 -0
  8. package/dist/state/diagramStore.d.ts +782 -202
  9. package/dist/style.css +273 -275
  10. package/dist/types.d.ts +20 -0
  11. package/dist/xyflow/EdgeMarkers.d.ts +1 -0
  12. package/dist/xyflow/FitviewOnDiagramChange.d.ts +1 -1
  13. package/dist/xyflow/XYFlow.d.ts +1 -4
  14. package/dist/xyflow/XYFlowEvents.d.ts +41 -12
  15. package/dist/xyflow/XYFlowInner.d.ts +0 -1
  16. package/dist/xyflow/edges/RelationshipEdge.d.ts +3 -3
  17. package/dist/xyflow/hooks/index.d.ts +0 -1
  18. package/dist/xyflow/hooks/useXYFlow.d.ts +27 -27
  19. package/dist/xyflow/types.d.ts +11 -19
  20. package/dist/xyflow/utils.d.ts +1 -0
  21. package/package.json +11 -11
  22. package/src/LikeC4Diagram.props.ts +17 -13
  23. package/src/LikeC4Diagram.tsx +67 -106
  24. package/src/globals.css.ts +10 -6
  25. package/src/hooks/useUpdateEffect.ts +16 -1
  26. package/src/index.ts +3 -0
  27. package/src/state/DiagramContext.tsx +19 -27
  28. package/src/{xyflow → state}/diagram-to-xyflow.ts +19 -18
  29. package/src/state/diagramStore.ts +404 -192
  30. package/src/types.ts +22 -0
  31. package/src/ui/BackwardForwardPanel.tsx +24 -7
  32. package/src/xyflow/EdgeMarkers.tsx +2 -0
  33. package/src/xyflow/FitviewOnDiagramChange.tsx +42 -75
  34. package/src/xyflow/SelectEdgesOnNodeFocus.tsx +1 -1
  35. package/src/xyflow/XYFlow.tsx +57 -35
  36. package/src/xyflow/XYFlowEvents.tsx +58 -10
  37. package/src/xyflow/XYFlowInner.tsx +9 -8
  38. package/src/xyflow/edges/RelationshipEdge.tsx +43 -29
  39. package/src/xyflow/edges/edges.css.ts +3 -3
  40. package/src/xyflow/edges/utils.ts +0 -1
  41. package/src/xyflow/hooks/index.ts +0 -1
  42. package/src/xyflow/hooks/useLayoutConstraints.ts +42 -65
  43. package/src/xyflow/nodes/element/ElementNode.tsx +8 -11
  44. package/src/xyflow/nodes/element/element.css.ts +8 -6
  45. package/src/xyflow/types.ts +14 -32
  46. package/src/xyflow/utils.ts +4 -0
  47. package/dist/xyflow/SyncWithDiagram.d.ts +0 -4
  48. package/dist/xyflow/diagram-to-xyflow.d.ts +0 -7
  49. package/src/xyflow/SyncWithDiagram.tsx +0 -85
package/src/types.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { Fqn, RelationID, ViewID } from '@likec4/core'
2
+ import type { Simplify } from 'type-fest'
3
+
4
+ type TaggedUnion<
5
+ TagKey extends string,
6
+ UnionMembers extends Record<string, Record<string, unknown>>
7
+ > = {
8
+ [Name in keyof UnionMembers]: Simplify<{ [Key in TagKey]: Name } & UnionMembers[Name]>
9
+ }[keyof UnionMembers]
10
+
11
+ export type DiagramEditorCommand = TaggedUnion<'type', {
12
+ // Sources
13
+ showElement: {
14
+ element: Fqn
15
+ }
16
+ showRelation: {
17
+ relation: RelationID
18
+ }
19
+ showView: {
20
+ view: ViewID
21
+ }
22
+ }>
@@ -2,12 +2,23 @@ import { ActionIcon, Group } from '@mantine/core'
2
2
  import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'
3
3
  import clsx from 'clsx'
4
4
  import { AnimatePresence, m } from 'framer-motion'
5
- import { useNavigationHistory } from '../state/useNavigationHistory'
5
+ import { type DiagramState, useDiagramState } from '../state/hooks'
6
6
  import * as css from './BackwardForwardPanel.css'
7
7
 
8
- export function BackwardForwardPanel() {
9
- const { hasBack, hasForward, ops } = useNavigationHistory()
8
+ const historySelector = (s: DiagramState) => ({
9
+ hasStepBack: s.navigationHistoryIndex > 0,
10
+ hasStepForward: s.navigationHistoryIndex < s.navigationHistory.length - 1,
11
+ goBack: s.goBack,
12
+ goForward: s.goForward
13
+ })
10
14
 
15
+ export function BackwardForwardPanel() {
16
+ const {
17
+ hasStepBack,
18
+ hasStepForward,
19
+ goBack,
20
+ goForward
21
+ } = useDiagramState(historySelector)
11
22
  return (
12
23
  <Group
13
24
  className={clsx(
@@ -17,7 +28,7 @@ export function BackwardForwardPanel() {
17
28
  )}
18
29
  gap={'xs'}>
19
30
  <AnimatePresence>
20
- {hasBack && (
31
+ {hasStepBack && (
21
32
  <m.div
22
33
  layout
23
34
  initial={{ opacity: 0.05, transform: 'translateX(-10px)' }}
@@ -30,12 +41,15 @@ export function BackwardForwardPanel() {
30
41
  <ActionIcon
31
42
  variant="light"
32
43
  color="gray"
33
- onClick={() => ops.back()}>
44
+ onClick={e => {
45
+ e.stopPropagation()
46
+ goBack()
47
+ }}>
34
48
  <IconChevronLeft />
35
49
  </ActionIcon>
36
50
  </m.div>
37
51
  )}
38
- {hasForward && (
52
+ {hasStepForward && (
39
53
  <m.div
40
54
  layout
41
55
  initial={{ opacity: 0.05, transform: 'translateX(10px)' }}
@@ -48,7 +62,10 @@ export function BackwardForwardPanel() {
48
62
  <ActionIcon
49
63
  variant="light"
50
64
  color="gray"
51
- onClick={() => ops.forward()}>
65
+ onClick={e => {
66
+ e.stopPropagation()
67
+ goForward()
68
+ }}>
52
69
  <IconChevronRight />
53
70
  </ActionIcon>
54
71
  </m.div>
@@ -146,3 +146,5 @@ export const EdgeMarkers = {
146
146
  Dot,
147
147
  ODot
148
148
  }
149
+
150
+ export type EdgeMarkerType = keyof typeof EdgeMarkers
@@ -1,21 +1,29 @@
1
- import { invariant } from '@likec4/core'
2
1
  import { useDebouncedEffect } from '@react-hookz/web'
3
2
  import { shallowEqual } from 'fast-equals'
4
- import { useEffect, useRef } from 'react'
3
+ import { useRef, useState } from 'react'
5
4
  import { type DiagramState, type DiagramStoreApi, useDiagramState, useDiagramStoreApi } from '../state/hooks'
6
- import { useXYStore, useXYStoreApi, type XYStoreApi } from './hooks/useXYFlow'
7
- import type { XYFlowState } from './types'
8
- import { toDomPrecision } from './utils'
5
+ import { useXYStore } from './hooks/useXYFlow'
9
6
 
10
- function selectDimensions(state: XYFlowState) {
7
+ function selectXYFlowSize(state: { width: number; height: number }): string {
11
8
  return `${Math.round(state.width)}:${Math.round(state.height)}`
12
9
  }
13
10
 
14
- function FitViewOnViewportResize({ diagramApi, xyflowApi }: {
15
- xyflowApi: XYStoreApi
11
+ function selectDiagramsize({ view, fitViewPadding }: DiagramState) {
12
+ return [
13
+ view.bounds.x,
14
+ view.bounds.y,
15
+ view.bounds.width,
16
+ view.bounds.height,
17
+ fitViewPadding
18
+ ].map(Math.round).join(':')
19
+ }
20
+
21
+ function FitViewOnViewportResize({ diagramApi }: {
16
22
  diagramApi: DiagramStoreApi
17
23
  }) {
18
- const dimensions = useXYStore(selectDimensions)
24
+ const xyflowsize = useXYStore(selectXYFlowSize)
25
+ const viewsize = useDiagramState(selectDiagramsize)
26
+ const dimensions = xyflowsize + viewsize
19
27
  const prevDimensionsRef = useRef(dimensions)
20
28
 
21
29
  useDebouncedEffect(
@@ -24,92 +32,51 @@ function FitViewOnViewportResize({ diagramApi, xyflowApi }: {
24
32
  if (focusedNodeId || prevDimensionsRef.current === dimensions) {
25
33
  return
26
34
  }
27
- fitDiagram(300)
28
35
  prevDimensionsRef.current = dimensions
36
+ fitDiagram(250)
29
37
  },
30
- [dimensions, diagramApi, xyflowApi],
31
- 300,
38
+ [dimensions, diagramApi],
39
+ 250,
32
40
  1000
33
41
  )
34
42
 
35
43
  return null
36
44
  }
37
45
 
38
- function selector({ view, xyflowSynced, viewportChanged, lastOnNavigate, ...s }: DiagramState) {
39
- const viewId = view.id + '_' + view.autoLayout + '_' + s.fitViewPadding
40
-
41
- const waitCorrection = lastOnNavigate && lastOnNavigate.toView === view.id
42
- && lastOnNavigate.positionCorrected === false
43
- const elTo = waitCorrection ? view.nodes.find(n => n.id === lastOnNavigate.element.id) : undefined
44
- if (waitCorrection && elTo) {
45
- return {
46
- viewId,
47
- xyflowSynced,
48
- viewportChanged,
49
- waitCorrection: true,
50
- elFrom: lastOnNavigate.element,
51
- elTo
52
- }
53
- }
54
-
46
+ function selector({ view, viewportChanged, fitViewPadding }: DiagramState) {
55
47
  return {
56
- viewId,
57
- xyflowSynced,
58
- viewportChanged,
59
- waitCorrection: false,
60
- fromElement: null,
61
- toElement: null
48
+ layoutId: view.id + '_' + view.autoLayout + '_' + fitViewPadding,
49
+ viewportNotMoved: !viewportChanged
62
50
  }
63
51
  }
64
52
 
65
53
  /**
66
- * Fits the view when the view changes and nodes are initialized
54
+ * Fits the view when diagram changes
67
55
  */
68
56
  export function FitViewOnDiagramChange() {
69
- const xyflowApi = useXYStoreApi()
70
57
  const {
71
- viewId: pendingViewId,
72
- xyflowSynced,
73
- viewportChanged,
74
- waitCorrection,
75
- elFrom,
76
- elTo
58
+ layoutId,
59
+ viewportNotMoved
77
60
  } = useDiagramState(selector, shallowEqual)
78
61
  const diagramApi = useDiagramStoreApi()
79
- const processedRef = useRef(pendingViewId)
80
- const durationRef = useRef(300)
62
+ const [currentLayoutId, setCurrent] = useState(layoutId)
63
+
64
+ const requiresFit = layoutId !== currentLayoutId
81
65
 
82
- useEffect(() => {
83
- if (!xyflowSynced || pendingViewId === processedRef.current) {
84
- return
85
- }
86
- if (waitCorrection) {
87
- invariant(elTo, 'elTo should be defined')
88
- invariant(elFrom, 'elFrom should be defined')
89
- const { xyflow, lastOnNavigate } = diagramApi.getState()
90
- invariant(lastOnNavigate, 'lastOnNavigate should be defined')
91
- const fromPos = lastOnNavigate.elementScreenPosition
92
- const toPos = xyflow.flowToScreenPosition({
93
- x: elTo.position[0], // + elFrom.width / 2,
94
- y: elTo.position[1] // + elFrom.height / 2
95
- }),
96
- diff = {
97
- x: toDomPrecision(fromPos.x - toPos.x),
98
- y: toDomPrecision(fromPos.y - toPos.y)
99
- }
100
- xyflowApi.getState().panBy(diff)
101
- durationRef.current = 500
102
- diagramApi.setState({ lastOnNavigate: null })
103
- return
104
- }
105
- processedRef.current = pendingViewId
106
- diagramApi.getState().fitDiagram(durationRef.current)
107
- // Reset duration
108
- durationRef.current = 300
109
- }, [pendingViewId, xyflowSynced, waitCorrection])
66
+ useDebouncedEffect(
67
+ () => {
68
+ if (layoutId === currentLayoutId) {
69
+ return
70
+ }
71
+ setCurrent(layoutId)
72
+ diagramApi.getState().fitDiagram(450)
73
+ },
74
+ [requiresFit, diagramApi],
75
+ 50
76
+ )
110
77
 
111
- if (!viewportChanged && xyflowSynced) {
112
- return <FitViewOnViewportResize xyflowApi={xyflowApi} diagramApi={diagramApi} />
78
+ if (viewportNotMoved && !requiresFit) {
79
+ return <FitViewOnViewportResize diagramApi={diagramApi} />
113
80
  }
114
81
  return null
115
82
  }
@@ -1,6 +1,6 @@
1
1
  import { nonNullable } from '@likec4/core'
2
2
  import type { EdgeChange, NodeChange } from '@xyflow/react'
3
- import { getBoundsOfRects, getViewportForBounds, nodeToRect } from '@xyflow/system'
3
+ import { getBoundsOfRects, getNodesBounds, getViewportForBounds, nodeToRect } from '@xyflow/system'
4
4
  import { useEffect } from 'react'
5
5
  import { useDiagramStoreApi } from '../state/useDiagramStore'
6
6
  import { MinZoom } from './const'
@@ -1,8 +1,6 @@
1
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'
2
+ import { deepEqual as eq, shallowEqual } from 'fast-equals'
3
+ import { type CSSProperties, type PropsWithChildren, useCallback, useMemo } from 'react'
6
4
  import type { DiagramState } from '../state/diagramStore'
7
5
  import { useDiagramState, useDiagramStoreApi } from '../state/useDiagramStore'
8
6
  import { MinZoom } from './const'
@@ -13,6 +11,7 @@ import { CompoundNode } from './nodes/compound'
13
11
  import { ElementNode } from './nodes/element'
14
12
  import { XYFlowEdge, XYFlowNode } from './types'
15
13
  import { useXYFlowEvents } from './XYFlowEvents'
14
+ // import { useLogger } from '@mantine/hooks'
16
15
 
17
16
  const nodeTypes = {
18
17
  element: ElementNode,
@@ -25,8 +24,6 @@ const edgeTypes = {
25
24
  type XYFlowWrapperProps = PropsWithChildren<{
26
25
  colorMode?: 'system' | 'light' | 'dark'
27
26
  className?: string | undefined
28
- defaultNodes: XYFlowNode[]
29
- defaultEdges: XYFlowEdge[]
30
27
  style?: CSSProperties | undefined
31
28
  }>
32
29
 
@@ -41,15 +38,15 @@ type XYFlowWrapperProps = PropsWithChildren<{
41
38
  // }
42
39
 
43
40
  const selector = (s: DiagramState) => ({
41
+ nodes: s.xynodes,
42
+ edges: s.xyedges,
43
+ onInit: s.onInit,
44
+ onNodesChange: s.onNodesChange,
45
+ onEdgesChange: s.onEdgesChange,
44
46
  nodesSelectable: s.nodesSelectable || s.focusedNodeId !== null,
45
47
  nodesDraggable: s.nodesDraggable,
46
48
  fitView: s.fitViewEnabled,
47
- fitViewOptions: {
48
- minZoom: MinZoom,
49
- maxZoom: 1,
50
- padding: s.fitViewPadding,
51
- includeHiddenNodes: true
52
- },
49
+ fitViewPadding: s.fitViewPadding,
53
50
  hasOnNavigateTo: !!s.onNavigateTo,
54
51
  hasOnNodeClick: !!s.onNodeClick,
55
52
  hasOnNodeContextMenu: !!s.onNodeContextMenu,
@@ -57,25 +54,30 @@ const selector = (s: DiagramState) => ({
57
54
  hasOnEdgeContextMenu: !!s.onEdgeContextMenu,
58
55
  hasOnEdgeClick: !!s.onEdgeClick,
59
56
  zoomable: s.zoomable,
60
- pannable: s.pannable
57
+ pannable: s.pannable,
58
+ // If fitView is not enabled
59
+ // And diagram starts with a negative x or y - we need to translate viewprot
60
+ translateX: s.fitViewEnabled ? 0 : -Math.min(s.view.bounds.x, 0),
61
+ translateY: s.fitViewEnabled ? 0 : -Math.min(s.view.bounds.y, 0)
61
62
  })
62
63
 
63
64
  export function XYFlow({
64
65
  colorMode = 'system',
65
66
  className,
66
67
  children,
67
- defaultNodes,
68
- defaultEdges,
69
68
  style
70
69
  }: XYFlowWrapperProps) {
71
- DEV && useTilg()
72
70
  const xyflowApi = useXYStoreApi()
73
- const diagramApi = useDiagramStoreApi()
74
71
  const {
72
+ nodes,
73
+ edges,
74
+ onInit,
75
+ onNodesChange,
76
+ onEdgesChange,
75
77
  nodesSelectable,
76
78
  nodesDraggable,
77
79
  fitView,
78
- fitViewOptions,
80
+ fitViewPadding,
79
81
  pannable,
80
82
  zoomable,
81
83
  hasOnNodeClick,
@@ -83,8 +85,14 @@ export function XYFlow({
83
85
  hasOnNodeContextMenu,
84
86
  hasOnCanvasContextMenu,
85
87
  hasOnEdgeContextMenu,
86
- hasOnEdgeClick
87
- } = useDiagramState(selector, eq)
88
+ translateX,
89
+ translateY
90
+ } = useDiagramState(selector, shallowEqual)
91
+
92
+ // useLogger('XYFlow',[
93
+ // nodes,
94
+ // edges,
95
+ // ])
88
96
 
89
97
  const layoutConstraints = useLayoutConstraints()
90
98
 
@@ -98,12 +106,10 @@ export function XYFlow({
98
106
  */
99
107
  useOnViewportChange({
100
108
  onEnd: ({ x, y, zoom }) => {
101
- const rounded = {
102
- x: Math.round(x),
103
- y: Math.round(y)
104
- }
105
- if (x !== rounded.x || y !== rounded.y) {
106
- xyflowApi.setState({ transform: [rounded.x, rounded.y, zoom] })
109
+ const roundedX = Math.round(x),
110
+ roundedY = Math.round(y)
111
+ if (x !== roundedX || y !== roundedY) {
112
+ xyflowApi.setState({ transform: [roundedX, roundedY, zoom] })
107
113
  }
108
114
  }
109
115
  })
@@ -113,8 +119,8 @@ export function XYFlow({
113
119
  className={className}
114
120
  style={style}
115
121
  {...colorMode && { colorMode }}
116
- defaultNodes={defaultNodes}
117
- defaultEdges={defaultEdges}
122
+ nodes={nodes}
123
+ edges={edges}
118
124
  nodeTypes={nodeTypes}
119
125
  edgeTypes={edgeTypes}
120
126
  zoomOnPinch={zoomable}
@@ -125,26 +131,43 @@ export function XYFlow({
125
131
  maxZoom={zoomable ? 1.9 : 1}
126
132
  minZoom={zoomable ? MinZoom : 1}
127
133
  fitView={fitView}
128
- fitViewOptions={fitViewOptions}
134
+ fitViewOptions={useMemo(() => ({
135
+ minZoom: MinZoom,
136
+ maxZoom: 1,
137
+ padding: fitViewPadding,
138
+ includeHiddenNodes: true
139
+ }), [fitViewPadding])}
140
+ {...(!fitView && {
141
+ viewport: {
142
+ x: translateX,
143
+ y: translateY,
144
+ zoom: 1
145
+ }
146
+ })}
129
147
  preventScrolling={zoomable || pannable}
130
148
  defaultMarkerColor="var(--xy-edge-stroke)"
131
149
  noDragClassName="nodrag"
132
150
  noPanClassName="nopan"
133
151
  panOnScroll={pannable}
134
152
  panOnDrag={pannable}
153
+ {...(!pannable && {
154
+ selectionKeyCode: null
155
+ })}
135
156
  elementsSelectable={nodesSelectable}
136
157
  nodesFocusable={nodesDraggable || nodesSelectable || hasOnNodeClick || hasOnNavigateTo}
137
- edgesFocusable={hasOnEdgeClick}
158
+ edgesFocusable={false}
138
159
  nodesDraggable={nodesDraggable}
139
160
  {...nodesDraggable && {
140
161
  onNodeDragStart: layoutConstraints.onNodeDragStart,
141
162
  onNodeDrag: layoutConstraints.onNodeDrag,
142
163
  onNodeDragStop: layoutConstraints.onNodeDragStop
143
164
  }}
165
+ nodeDragThreshold={3}
144
166
  zoomOnDoubleClick={false}
145
- elevateNodesOnSelect={false} // or edges are not visible after select
167
+ elevateNodesOnSelect={false} // or edges are not visible after select\
146
168
  selectNodesOnDrag={false} // or weird camera movement
147
- selectionKeyCode={null}
169
+ onNodesChange={onNodesChange}
170
+ onEdgesChange={onEdgesChange}
148
171
  onDoubleClick={handlers.onDoubleClick}
149
172
  onPaneClick={handlers.onPaneClick}
150
173
  onMoveEnd={handlers.onMoveEnd}
@@ -155,9 +178,8 @@ export function XYFlow({
155
178
  onNodeClick={handlers.onNodeClick}
156
179
  onNodeDoubleClick={handlers.onNodeDoubleClick}
157
180
  onEdgeClick={handlers.onEdgeClick}
158
- onInit={useCallback(() => {
159
- diagramApi.setState({ initialized: true }, false, 'initialized')
160
- }, [diagramApi])}
181
+ onEdgeDoubleClick={handlers.onEdgeDoubleClick}
182
+ onInit={onInit}
161
183
  {...(hasOnNodeContextMenu && {
162
184
  onNodeContextMenu: handlers.onNodeContextMenu
163
185
  })}
@@ -1,5 +1,7 @@
1
+ import { extractStep } from '@likec4/core'
1
2
  import type { ReactFlowProps } from '@xyflow/react'
2
3
  import { useMemo, useRef } from 'react'
4
+ import { isNonNullish, isTruthy } from 'remeda'
3
5
  import type { Simplify } from 'type-fest'
4
6
  import { useDiagramStoreApi } from '../state/useDiagramStore'
5
7
  import type { XYFlowEdge, XYFlowNode } from './types'
@@ -13,6 +15,7 @@ export type XYFlowEventHandlers = Simplify<
13
15
  | 'onNodeClick'
14
16
  | 'onNodeDoubleClick'
15
17
  | 'onEdgeClick'
18
+ | 'onEdgeDoubleClick'
16
19
  | 'onMoveEnd'
17
20
  | 'onNodeContextMenu'
18
21
  | 'onEdgeContextMenu'
@@ -110,22 +113,20 @@ export function useXYFlowEvents() {
110
113
  },
111
114
  onNodeClick: (event, xynode) => {
112
115
  const {
113
- zoomable,
114
- fitViewEnabled,
115
116
  focusedNodeId,
116
117
  fitDiagram,
117
118
  focusOnNode,
118
119
  onNodeClick,
120
+ enableFocusMode,
119
121
  lastClickedNodeId,
120
122
  setLastClickedNode
121
123
  } = diagramApi.getState()
122
124
  setLastClickedNode(xynode.id)
123
- const focusPossible = zoomable && fitViewEnabled
124
125
  // if we focused on a node, and clicked on another node - focus on the clicked node
125
126
  const shallChangeFocus = !!focusedNodeId && focusedNodeId !== xynode.id
126
127
  // if user clicked on the same node twice in a short time, focus on it
127
128
  const clickedRecently = lastClickedNodeId === xynode.id && lastClickWasRecent()
128
- if (focusPossible) {
129
+ if (enableFocusMode) {
129
130
  let stopPropagation = false
130
131
  switch (true) {
131
132
  case !focusedNodeId && clickedRecently:
@@ -153,15 +154,14 @@ export function useXYFlowEvents() {
153
154
  onNodeDoubleClick: (event, xynode) => {
154
155
  const {
155
156
  focusedNodeId,
156
- zoomable,
157
- fitViewEnabled,
157
+ enableFocusMode,
158
158
  fitDiagram,
159
159
  focusOnNode,
160
160
  setLastClickedNode
161
161
  } = diagramApi.getState()
162
162
  setLastClickedNode(xynode.id)
163
163
  lastClickTimestamp.current = Date.now()
164
- if (!!focusedNodeId || (zoomable && fitViewEnabled)) {
164
+ if (isNonNullish(focusedNodeId) || enableFocusMode) {
165
165
  // if we are already focused on the node, cancel
166
166
  if (focusedNodeId === xynode.id) {
167
167
  fitDiagram()
@@ -172,14 +172,36 @@ export function useXYFlowEvents() {
172
172
  }
173
173
  },
174
174
  onEdgeClick: (event, xyedge) => {
175
- diagramApi.getState().setLastClickedEdge(xyedge.id)
176
175
  const {
176
+ lastClickedEdgeId,
177
+ isDynamicView,
178
+ enableDynamicViewWalkthrough,
179
+ activateDynamicStep,
180
+ activeDynamicViewStep,
177
181
  focusedNodeId,
178
182
  focusOnNode,
179
- onEdgeClick
183
+ onEdgeClick,
184
+ setLastClickedEdge
180
185
  } = diagramApi.getState()
186
+ if (lastClickedEdgeId !== xyedge.id) {
187
+ setLastClickedEdge(xyedge.id)
188
+ }
189
+ const isNotAFirstClick = lastClickedEdgeId === xyedge.id
190
+ const isEdgeOfFocusedNode = isTruthy(focusedNodeId)
191
+ && (focusedNodeId === xyedge.source || focusedNodeId === xyedge.target)
192
+ if (
193
+ isDynamicView && enableDynamicViewWalkthrough
194
+ && (isEdgeOfFocusedNode || isNotAFirstClick || isNonNullish(activeDynamicViewStep))
195
+ ) {
196
+ const nextStep = extractStep(xyedge.data.edge.id)
197
+ if (activeDynamicViewStep !== nextStep) {
198
+ activateDynamicStep(nextStep)
199
+ event.stopPropagation()
200
+ return
201
+ }
202
+ }
181
203
  // if we focused on a node, and clicked on an edge connected to it - focus on the other node
182
- if (focusedNodeId && (focusedNodeId === xyedge.source || focusedNodeId === xyedge.target)) {
204
+ if (isEdgeOfFocusedNode) {
183
205
  focusOnNode(focusedNodeId === xyedge.source ? xyedge.target : xyedge.source)
184
206
  if (!onEdgeClick) {
185
207
  // user did not provide a custom handler, stop propagation
@@ -192,6 +214,32 @@ export function useXYFlowEvents() {
192
214
  event
193
215
  })
194
216
  },
217
+ onEdgeDoubleClick: (event, xyedge) => {
218
+ diagramApi.getState().setLastClickedEdge(xyedge.id)
219
+ const {
220
+ enableFocusMode,
221
+ isDynamicView,
222
+ enableDynamicViewWalkthrough,
223
+ focusOnNode,
224
+ activateDynamicStep,
225
+ activeDynamicViewStep
226
+ } = diagramApi.getState()
227
+ // if we are in dynamic view, and clicked on an edge, activate the step
228
+ if (isDynamicView && enableDynamicViewWalkthrough) {
229
+ const nextStep = extractStep(xyedge.data.edge.id)
230
+ if (activeDynamicViewStep !== nextStep) {
231
+ activateDynamicStep(extractStep(xyedge.data.edge.id))
232
+ event.stopPropagation()
233
+ }
234
+ return
235
+ }
236
+
237
+ if (enableFocusMode) {
238
+ focusOnNode(xyedge.source)
239
+ event.stopPropagation()
240
+ return
241
+ }
242
+ },
195
243
  onMoveEnd: (event, _viewport) => {
196
244
  // if event is present, the move was triggered by user
197
245
  const viewportChanged = !!event
@@ -1,7 +1,7 @@
1
1
  import { Controls } from '@xyflow/react'
2
2
  import { shallowEqual } from 'fast-equals'
3
3
  import { memo, type PropsWithChildren } from 'react'
4
- import { isNonNull } from 'remeda'
4
+ import { isNonNullish } from 'remeda'
5
5
  import type { LikeC4DiagramProperties } from '../LikeC4Diagram.props'
6
6
  import { useDiagramState } from '../state/useDiagramStore'
7
7
  import { BackwardForwardPanel } from '../ui/BackwardForwardPanel'
@@ -15,7 +15,6 @@ type XYFlowInnerProps = PropsWithChildren<{
15
15
  controls: boolean
16
16
  showDiagramTitle: boolean
17
17
  showNavigationButtons: boolean
18
- enableDynamicViewWalkthrough: boolean
19
18
  }>
20
19
 
21
20
  export const XYFlowInner = memo(function XYFlowInnerR({
@@ -23,22 +22,23 @@ export const XYFlowInner = memo(function XYFlowInnerR({
23
22
  background,
24
23
  controls,
25
24
  showDiagramTitle,
26
- showNavigationButtons,
27
- enableDynamicViewWalkthrough
25
+ showNavigationButtons
28
26
  }: XYFlowInnerProps) {
29
27
  const {
30
28
  isDynamicView,
31
29
  isDynamicViewActive,
32
- readonly
30
+ readonly,
31
+ enableDynamicViewWalkthrough
33
32
  } = useDiagramState(s => ({
34
- isDynamicView: s.isDynamicView(),
33
+ isDynamicView: s.isDynamicView,
35
34
  readonly: s.readonly,
36
- isDynamicViewActive: isNonNull(s.activeDynamicViewStep)
35
+ enableDynamicViewWalkthrough: s.enableDynamicViewWalkthrough,
36
+ isDynamicViewActive: isNonNullish(s.activeDynamicViewStep)
37
37
  }))
38
38
 
39
39
  const isBgWithPattern = background !== 'transparent' && background !== 'solid'
40
40
 
41
- const isDiagramTitleVisible = !isDynamicViewActive && showDiagramTitle === true
41
+ const isDiagramTitleVisible = showDiagramTitle === true && !isDynamicViewActive
42
42
 
43
43
  return (
44
44
  <>
@@ -52,3 +52,4 @@ export const XYFlowInner = memo(function XYFlowInnerR({
52
52
  </>
53
53
  )
54
54
  }, shallowEqual)
55
+ XYFlowInner.displayName = 'XYFlowInner'