@likec4/diagram 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/LikeC4Diagram.d.ts +1 -1
  2. package/dist/LikeC4Diagram.props.d.ts +21 -3
  3. package/dist/index.mjs +1335 -1240
  4. package/dist/state/DiagramContext.d.ts +2263 -175
  5. package/dist/state/diagramStore.d.ts +2272 -180
  6. package/dist/state/useDiagramStore.d.ts +2409 -321
  7. package/dist/style.css +123 -92
  8. package/dist/theme.css.d.ts +0 -1
  9. package/dist/ui/OptionsPanel.d.ts +3 -1
  10. package/dist/xyflow/XYFlow.d.ts +2 -1
  11. package/dist/xyflow/XYFlowInner.d.ts +1 -1
  12. package/dist/xyflow/const.d.ts +1 -1
  13. package/dist/xyflow/diagram-to-xyflow.d.ts +1 -1
  14. package/dist/xyflow/edges/RelationshipEdge.d.ts +3 -4
  15. package/dist/xyflow/edges/utils.d.ts +4 -2
  16. package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +3 -1
  17. package/dist/xyflow/nodes/element/ElementNode.d.ts +1 -1
  18. package/dist/xyflow/nodes/element/index.d.ts +1 -1
  19. package/dist/xyflow/types.d.ts +2 -2
  20. package/dist/xyflow/utils.d.ts +3 -0
  21. package/package.json +15 -14
  22. package/src/LikeC4Diagram.css.ts +4 -4
  23. package/src/LikeC4Diagram.props.ts +23 -2
  24. package/src/LikeC4Diagram.tsx +79 -31
  25. package/src/globals.css.ts +0 -1
  26. package/src/mantine/theme.ts +1 -0
  27. package/src/state/DiagramContext.tsx +0 -1
  28. package/src/state/diagramStore.ts +177 -100
  29. package/src/theme.css.ts +0 -1
  30. package/src/ui/DiagramTitlePanel.tsx +3 -3
  31. package/src/ui/DynamicViewWalkthrough.css.ts +3 -2
  32. package/src/ui/DynamicViewWalkthrough.tsx +36 -17
  33. package/src/ui/OptionsPanel.tsx +12 -12
  34. package/src/ui/options/NodeOptions.tsx +16 -19
  35. package/src/xyflow/FitviewOnDiagramChange.tsx +15 -23
  36. package/src/xyflow/SelectEdgesOnNodeFocus.tsx +3 -6
  37. package/src/xyflow/SyncWithDiagram.tsx +21 -2
  38. package/src/xyflow/XYFlow.tsx +20 -23
  39. package/src/xyflow/XYFlowEvents.tsx +26 -8
  40. package/src/xyflow/XYFlowInner.tsx +5 -4
  41. package/src/xyflow/const.ts +1 -1
  42. package/src/xyflow/diagram-to-xyflow.ts +5 -31
  43. package/src/xyflow/edges/RelationshipEdge.tsx +158 -112
  44. package/src/xyflow/edges/edges.css.ts +32 -21
  45. package/src/xyflow/edges/utils.ts +5 -2
  46. package/src/xyflow/hooks/useLayoutConstraints.ts +17 -3
  47. package/src/xyflow/nodes/compound/CompoundNode.css.ts +40 -32
  48. package/src/xyflow/nodes/compound/CompoundNode.tsx +36 -26
  49. package/src/xyflow/nodes/element/ElementNode.tsx +20 -6
  50. package/src/xyflow/nodes/element/element.css.ts +15 -15
  51. package/src/xyflow/nodes/element/index.ts +1 -1
  52. package/src/xyflow/nodes/shared/NavigateToBtn.tsx +7 -3
  53. package/src/xyflow/types.ts +5 -2
  54. package/src/xyflow/utils.ts +48 -0
  55. package/dist/ui/index.d.ts +0 -2
  56. package/src/ui/index.ts +0 -3
@@ -3,12 +3,14 @@ import type {
3
3
  DiagramEdge,
4
4
  DiagramNode,
5
5
  DiagramView,
6
+ EdgeId,
6
7
  ElementShape,
7
8
  Fqn,
8
9
  NonEmptyArray,
9
10
  ThemeColor,
10
11
  ViewID
11
12
  } from '@likec4/core'
13
+ import type { XYPosition } from '@xyflow/system'
12
14
  import type { MouseEvent as ReactMouseEvent } from 'react'
13
15
  import type { SetRequired, Simplify } from 'type-fest'
14
16
  import type { XYFlowEdge, XYFlowNode } from './xyflow/types'
@@ -55,12 +57,25 @@ export namespace Changes {
55
57
  }
56
58
  targets: NonEmptyArray<Fqn>
57
59
  }
60
+
61
+ export interface SaveManualLayout {
62
+ op: 'save-manual-layout'
63
+ nodes: Record<Fqn, {
64
+ x: number
65
+ y: number
66
+ width: number
67
+ height: number
68
+ }>
69
+ edges: Record<EdgeId, {
70
+ controlPoints: XYPosition[]
71
+ }>
72
+ }
58
73
  }
59
74
 
60
- export type Change = Changes.ChangeElementStyle
75
+ export type Change = Changes.ChangeElementStyle | Changes.SaveManualLayout
61
76
 
62
77
  export type ChangeEvent = {
63
- changes: NonEmptyArray<Change>
78
+ change: Change
64
79
  }
65
80
  export type OnChange = {
66
81
  (event: ChangeEvent): void
@@ -149,6 +164,12 @@ export interface LikeC4DiagramProperties {
149
164
  * @default false
150
165
  */
151
166
  enableDynamicViewWalkthrough?: boolean | undefined
167
+
168
+ /**
169
+ * Experimental feature to enable edge editing
170
+ * @default false
171
+ */
172
+ experimentalEdgeEditing?: boolean | undefined
152
173
  }
153
174
 
154
175
  export interface LikeC4DiagramEventHandlers {
@@ -1,7 +1,11 @@
1
- import '@xyflow/react/dist/style.css'
2
1
  import { ReactFlowProvider as XYFlowProvider } from '@xyflow/react'
2
+ import '@xyflow/react/dist/style.css'
3
3
  import clsx from 'clsx'
4
- import { useRef } from 'react'
4
+ import { DEV } from 'esm-env'
5
+ import { shallowEqual } from 'fast-equals'
6
+ import { domAnimation, LazyMotion } from 'framer-motion'
7
+ import { memo, useRef } from 'react'
8
+ import useTilg from 'tilg'
5
9
  import { rootClassName } from './globals.css'
6
10
  import { KeepAspectRatioContainer } from './KeepAspectRatioContainer'
7
11
  import { cssDisablePan, cssNoControls, cssReactFlow, cssTransparentBg } from './LikeC4Diagram.css'
@@ -13,7 +17,7 @@ import { diagramViewToXYFlowData } from './xyflow/diagram-to-xyflow'
13
17
  import { FitViewOnDiagramChange } from './xyflow/FitviewOnDiagramChange'
14
18
  import { SelectEdgesOnNodeFocus } from './xyflow/SelectEdgesOnNodeFocus'
15
19
  import { SyncWithDiagram } from './xyflow/SyncWithDiagram'
16
- import type { XYFlowData } from './xyflow/types'
20
+ import type { XYFlowData, XYFlowEdge, XYFlowNode } from './xyflow/types'
17
21
  import { XYFlow } from './xyflow/XYFlow'
18
22
  import { XYFlowInner } from './xyflow/XYFlowInner'
19
23
 
@@ -29,13 +33,14 @@ export function LikeC4Diagram({
29
33
  nodesSelectable = !readonly,
30
34
  nodesDraggable = !readonly,
31
35
  background = 'dots',
32
- controls = !readonly,
36
+ controls = false,
33
37
  showElementLinks = true,
34
38
  showDiagramTitle = true,
35
39
  enableDynamicViewWalkthrough = true,
36
40
  initialWidth,
37
41
  initialHeight,
38
42
  keepAspectRatio = false,
43
+ experimentalEdgeEditing = false,
39
44
  onCanvasClick,
40
45
  onCanvasContextMenu,
41
46
  onCanvasDblClick,
@@ -46,7 +51,7 @@ export function LikeC4Diagram({
46
51
  onNodeClick,
47
52
  onNodeContextMenu
48
53
  }: LikeC4DiagramProps) {
49
- console.log('LikeC4Diagram')
54
+ DEV && useTilg()
50
55
  const initialRef = useRef<{
51
56
  defaultNodes: XYFlowData['nodes']
52
57
  defaultEdges: XYFlowData['edges']
@@ -81,6 +86,7 @@ export function LikeC4Diagram({
81
86
  showElementLinks={showElementLinks}
82
87
  nodesDraggable={nodesDraggable}
83
88
  nodesSelectable={nodesSelectable}
89
+ experimentalEdgeEditing={experimentalEdgeEditing}
84
90
  onCanvasClick={onCanvasClick}
85
91
  onCanvasContextMenu={onCanvasContextMenu}
86
92
  onEdgeClick={onEdgeClick}
@@ -91,38 +97,80 @@ export function LikeC4Diagram({
91
97
  onNavigateTo={onNavigateTo}
92
98
  onCanvasDblClick={onCanvasDblClick}
93
99
  >
94
- <KeepAspectRatioContainer
95
- className={clsx(rootClassName, className)}
96
- enabled={keepAspectRatio}
97
- width={view.width}
98
- height={view.height}
99
- >
100
- <XYFlow
101
- className={clsx(
102
- 'likec4-diagram',
103
- cssReactFlow,
104
- controls === false && cssNoControls,
105
- pannable !== true && cssDisablePan,
106
- background === 'transparent' && cssTransparentBg
107
- )}
108
- defaultNodes={initialRef.current.defaultNodes}
109
- defaultEdges={initialRef.current.defaultEdges}
100
+ <LazyMotion features={domAnimation} strict>
101
+ <KeepAspectRatioContainer
102
+ className={clsx(rootClassName, className)}
103
+ enabled={keepAspectRatio}
104
+ width={view.width}
105
+ height={view.height}
110
106
  >
111
- <XYFlowInner
112
- showDiagramTitle={showDiagramTitle}
113
- enableDynamicViewWalkthrough={enableDynamicViewWalkthrough}
107
+ <LikeC4DiagramInnerMemo
108
+ defaultNodes={initialRef.current.defaultNodes}
109
+ defaultEdges={initialRef.current.defaultEdges}
110
+ fitView={fitView}
111
+ zoomable={zoomable}
114
112
  background={background}
115
113
  controls={controls}
114
+ pannable={pannable}
115
+ showDiagramTitle={showDiagramTitle}
116
+ enableDynamicViewWalkthrough={enableDynamicViewWalkthrough}
116
117
  />
117
- </XYFlow>
118
- <WhenInitialized>
119
- <SyncWithDiagram />
120
- {fitView && <FitViewOnDiagramChange />}
121
- {fitView && zoomable && <SelectEdgesOnNodeFocus />}
122
- </WhenInitialized>
123
- </KeepAspectRatioContainer>
118
+ </KeepAspectRatioContainer>
119
+ </LazyMotion>
124
120
  </DiagramContextProvider>
125
121
  </XYFlowProvider>
126
122
  </EnsureMantine>
127
123
  )
128
124
  }
125
+
126
+ type LikeC4DiagramInnerProps = {
127
+ background: NonNullable<LikeC4DiagramProperties['background']>
128
+ fitView: boolean
129
+ zoomable: boolean
130
+ pannable: boolean
131
+ controls: boolean
132
+ defaultNodes: XYFlowNode[]
133
+ defaultEdges: XYFlowEdge[]
134
+ showDiagramTitle: boolean
135
+ enableDynamicViewWalkthrough: boolean
136
+ }
137
+ const LikeC4DiagramInnerMemo = memo<LikeC4DiagramInnerProps>(function LikeC4DiagramInner({
138
+ background,
139
+ fitView,
140
+ zoomable,
141
+ controls,
142
+ pannable,
143
+ defaultNodes,
144
+ defaultEdges,
145
+ showDiagramTitle,
146
+ enableDynamicViewWalkthrough
147
+ }) {
148
+ DEV && useTilg()
149
+ return (
150
+ <>
151
+ <XYFlow
152
+ defaultNodes={defaultNodes}
153
+ defaultEdges={defaultEdges}
154
+ className={clsx(
155
+ 'likec4-diagram',
156
+ cssReactFlow,
157
+ controls === false && cssNoControls,
158
+ pannable !== true && cssDisablePan,
159
+ background === 'transparent' && cssTransparentBg
160
+ )}
161
+ >
162
+ <XYFlowInner
163
+ showDiagramTitle={showDiagramTitle}
164
+ enableDynamicViewWalkthrough={enableDynamicViewWalkthrough}
165
+ background={background}
166
+ controls={controls}
167
+ />
168
+ </XYFlow>
169
+ <WhenInitialized>
170
+ <SyncWithDiagram />
171
+ {fitView && <FitViewOnDiagramChange />}
172
+ {fitView && zoomable && <SelectEdgesOnNodeFocus />}
173
+ </WhenInitialized>
174
+ </>
175
+ )
176
+ }, shallowEqual)
@@ -25,7 +25,6 @@ createGlobalTheme(`.${rootClassName}`, {
25
25
  }
26
26
  }
27
27
  },
28
- filterDimmed: 'brightness(.6) grayscale(.8) opacity(.5) blur(1px)',
29
28
  compound: {
30
29
  font: vars.likec4.font,
31
30
  titleColor: vars.element.loContrast
@@ -4,6 +4,7 @@ export const theme = createTheme({
4
4
  primaryColor: 'indigo',
5
5
  cursorType: 'pointer',
6
6
  defaultRadius: 'sm',
7
+ respectReducedMotion: true,
7
8
  headings: {
8
9
  fontWeight: '500',
9
10
  sizes: {
@@ -38,7 +38,6 @@ export function DiagramContextProvider({ children, view, ...props }: PropsWithCh
38
38
  if (!hasSubObject(state, props)) {
39
39
  store.current.setState(props, false, 'update incoming props')
40
40
  }
41
- isIncludedIn
42
41
  },
43
42
  [view, props]
44
43
  )
@@ -1,29 +1,20 @@
1
- import type {
2
- BorderStyle,
3
- DiagramEdge,
4
- DiagramNode,
5
- DiagramView,
6
- ElementShape,
7
- Fqn,
8
- NonEmptyArray,
9
- ThemeColor,
10
- ViewID
11
- } from '@likec4/core'
12
- import { invariant, nonexhaustive, StepEdgeId } from '@likec4/core'
1
+ import type { DiagramNode, DiagramView, Fqn, ViewID } from '@likec4/core'
2
+ import { invariant, nonexhaustive, nonNullable, StepEdgeId } from '@likec4/core'
13
3
  import { getViewportForBounds, type XYPosition } from '@xyflow/react'
14
- import { getNodeDimensions } from '@xyflow/system'
4
+ import { getInternalNodesBounds, getNodeDimensions } from '@xyflow/system'
15
5
  import { DEV } from 'esm-env'
16
6
  import { deepEqual, shallowEqual } from 'fast-equals'
17
7
  import type { MouseEvent as ReactMouseEvent } from 'react'
18
- import { entries, filter, map, prop } from 'remeda'
8
+ import { debounce, entries, mapToObj, reduce } from 'remeda'
19
9
  import type { Exact, Except, RequiredKeysOf, Simplify } from 'type-fest'
20
10
  import { devtools, subscribeWithSelector } from 'zustand/middleware'
21
11
  import { shallow } from 'zustand/shallow'
22
12
  import { createWithEqualityFn } from 'zustand/traditional'
23
- import type { Change, DiagramNodeWithNavigate, LikeC4DiagramEventHandlers } from '../LikeC4Diagram.props'
13
+ import type { Changes, DiagramNodeWithNavigate, LikeC4DiagramEventHandlers } from '../LikeC4Diagram.props'
24
14
  import { MinZoom } from '../xyflow/const'
25
15
  import type { XYStoreApi } from '../xyflow/hooks'
26
16
  import type { XYFlowEdge, XYFlowInstance, XYFlowNode } from '../xyflow/types'
17
+ import { bezierControlPoints, isSamePoint } from '../xyflow/utils'
27
18
 
28
19
  export type DiagramStore = {
29
20
  // Incoming props
@@ -36,8 +27,12 @@ export type DiagramStore = {
36
27
  pannable: boolean
37
28
  nodesDraggable: boolean
38
29
  nodesSelectable: boolean
30
+ experimentalEdgeEditing: boolean
39
31
 
40
32
  // Internal state
33
+ viewSyncState: 'synced' | 'changed'
34
+ viewSyncDebounceTimeout: number | null
35
+
41
36
  xyflow: XYFlowInstance
42
37
  initialized: boolean
43
38
  xyflowSynced: boolean
@@ -62,7 +57,8 @@ export type DiagramStore = {
62
57
  fromView: ViewID
63
58
  toView: ViewID
64
59
  element: DiagramNode
65
- elementCenterScreenPosition: XYPosition
60
+ // Position of the element in the source view
61
+ elementScreenPosition: XYPosition
66
62
  // If node from the target view is placed on the same position as the node from the source view
67
63
  positionCorrected: boolean
68
64
  }
@@ -85,6 +81,7 @@ export type DiagramInitialState = // Required properties
85
81
  | 'pannable'
86
82
  | 'nodesDraggable'
87
83
  | 'nodesSelectable'
84
+ | 'experimentalEdgeEditing'
88
85
  >
89
86
  // Optional properties
90
87
  // & Partial<
@@ -111,7 +108,8 @@ interface DiagramStoreActions {
111
108
  resetLastClicked: () => void
112
109
 
113
110
  getElement(id: Fqn): DiagramNode | null
114
- triggerOnChange: (changes: NonEmptyArray<Change>) => void
111
+ triggerChangeElementStyle: (change: Changes.ChangeElementStyle) => void
112
+ triggerSaveManualLayout: (xystore: XYStoreApi) => void
115
113
  triggerOnNavigateTo: (xynodeId: string, event: ReactMouseEvent) => void
116
114
  fitDiagram: (xyStore: XYStoreApi) => void
117
115
 
@@ -122,6 +120,8 @@ interface DiagramStoreActions {
122
120
  export type DiagramState = Simplify<DiagramStore & DiagramStoreActions>
123
121
 
124
122
  const DEFAULT_PROPS: Except<DiagramStore, RequiredKeysOf<DiagramInitialState> | 'xyflow'> = {
123
+ viewSyncState: 'synced',
124
+ viewSyncDebounceTimeout: null,
125
125
  initialized: false,
126
126
  xyflowSynced: false,
127
127
  previousViews: [],
@@ -173,6 +173,9 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
173
173
 
174
174
  updateView: (nextView) => {
175
175
  let {
176
+ viewSyncDebounceTimeout,
177
+ viewSyncState,
178
+ xyflow,
176
179
  dimmed,
177
180
  xyflowSynced,
178
181
  view: current,
@@ -186,9 +189,21 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
186
189
  hoveredNodeId
187
190
  } = get()
188
191
 
192
+ if (viewSyncDebounceTimeout) {
193
+ clearTimeout(viewSyncDebounceTimeout)
194
+ viewSyncDebounceTimeout = null
195
+ }
196
+
189
197
  if (shallowEqual(current, nextView)) {
190
- if (!xyflowSynced) {
191
- set({ xyflowSynced: true }, noReplace, 'updateView: xyflow synced')
198
+ if (!xyflowSynced || viewSyncState === 'changed') {
199
+ set(
200
+ {
201
+ viewSyncState: 'synced',
202
+ xyflowSynced: true
203
+ },
204
+ noReplace,
205
+ 'updateView: xyflow synced'
206
+ )
192
207
  }
193
208
  DEV && console.debug('store: skip updateView')
194
209
  return
@@ -218,8 +233,8 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
218
233
  if (activeDynamicViewStep && !edgeIds.has(StepEdgeId(activeDynamicViewStep))) {
219
234
  activeDynamicViewStep = null
220
235
  }
221
- xyflowSynced = deepEqual(current.nodes, nextView.nodes) && deepEqual(current.edges, nextView.edges)
222
- if (!xyflowSynced) {
236
+ xyflowSynced = deepEqual([current.nodes, current.edges], [nextView.nodes, nextView.edges])
237
+ if (dimmed.size > 0) {
223
238
  let nextDimmed = new StringSet([...dimmed].filter(id => nodeIds.has(id) || edgeIds.has(id)))
224
239
  if (nextDimmed.size !== dimmed.size) {
225
240
  dimmed = nextDimmed
@@ -233,6 +248,11 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
233
248
  }
234
249
  }
235
250
 
251
+ if (!lastOnNavigate) {
252
+ const zoom = xyflow.getZoom()
253
+ xyflow.setCenter(nextView.width / 2, nextView.height / 2, { zoom })
254
+ }
255
+
236
256
  // Reset hovered / clicked node/edge if the view is different
237
257
  lastClickedEdgeId = null
238
258
  lastClickedNodeId = null
@@ -252,6 +272,8 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
252
272
 
253
273
  set(
254
274
  {
275
+ viewSyncState: 'synced',
276
+ viewSyncDebounceTimeout,
255
277
  view: nextView,
256
278
  activeDynamicViewStep,
257
279
  xyflowSynced,
@@ -326,13 +348,15 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
326
348
 
327
349
  resetLastClicked: () => {
328
350
  let {
351
+ activeDynamicViewStep,
329
352
  focusedNodeId,
330
353
  lastClickedNodeId,
331
354
  lastClickedEdgeId
332
355
  } = get()
333
- if (focusedNodeId || lastClickedNodeId || lastClickedEdgeId) {
356
+ if (activeDynamicViewStep || focusedNodeId || lastClickedNodeId || lastClickedEdgeId) {
334
357
  set(
335
358
  {
359
+ activeDynamicViewStep: null,
336
360
  focusedNodeId: null,
337
361
  lastClickedNodeId: null,
338
362
  lastClickedEdgeId: null,
@@ -349,93 +373,144 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
349
373
  return view.nodes.find(({ id }) => id === fqn) ?? null
350
374
  },
351
375
 
352
- triggerOnChange: (changes: NonEmptyArray<Change>) => {
376
+ triggerChangeElementStyle: (change) => {
353
377
  if (DEV) {
354
- console.debug('triggerOnChange', changes)
378
+ console.debug('triggerChangeElementStyle', change)
355
379
  }
356
- const newShapes = new Map<Fqn, ElementShape>()
357
- const newColor = new Map<Fqn, ThemeColor>()
358
- const newOpacity = new Map<Fqn, number>()
359
- const newBorder = new Map<Fqn, BorderStyle>()
360
- for (const change of changes) {
361
- if (change.op !== 'change-element-style') {
362
- nonexhaustive(change.op)
380
+ const { view, updateView, onChange } = get()
381
+
382
+ // Style changes we already apply to the view
383
+ let hasChanges = false
384
+ const nodes = view.nodes.map(origin => {
385
+ if (!change.targets.includes(origin.id)) {
386
+ return origin
363
387
  }
364
- for (const target of change.targets) {
365
- for (const [key, value] of entries.strict(change.style)) {
366
- switch (key) {
367
- case 'shape':
368
- newShapes.set(target, value)
369
- break
370
- case 'color':
371
- newColor.set(target, value)
372
- break
373
- case 'opacity':
374
- newOpacity.set(target, value)
375
- break
376
- case 'border':
377
- newBorder.set(target, value)
378
- break
379
- default:
380
- nonexhaustive(key)
381
- }
388
+ let element = origin
389
+ for (const [key, value] of entries.strict(change.style)) {
390
+ switch (key) {
391
+ case 'shape':
392
+ if (value !== element.shape) {
393
+ element = {
394
+ ...element,
395
+ shape: value
396
+ }
397
+ }
398
+ break
399
+ case 'color':
400
+ if (value !== element.color) {
401
+ element = {
402
+ ...element,
403
+ color: value
404
+ }
405
+ }
406
+ break
407
+ case 'opacity':
408
+ if (value !== element.style?.opacity) {
409
+ element = {
410
+ ...element,
411
+ style: {
412
+ ...element.style,
413
+ opacity: value
414
+ }
415
+ }
416
+ }
417
+ break
418
+ case 'border':
419
+ if (value !== element.style?.border) {
420
+ element = {
421
+ ...element,
422
+ style: {
423
+ ...element.style,
424
+ border: value
425
+ }
426
+ }
427
+ }
428
+ break
429
+ default:
430
+ nonexhaustive(key)
382
431
  }
383
432
  }
433
+ if (element !== origin) {
434
+ hasChanges = true
435
+ return element
436
+ }
437
+ return origin
438
+ })
439
+ if (hasChanges) {
440
+ updateView({
441
+ ...view,
442
+ nodes
443
+ })
444
+ if (onChange) {
445
+ set({ viewSyncState: 'changed' }, noReplace, 'change sync state')
446
+ }
384
447
  }
448
+ // Trigger change event, even if there are no changes with local state
449
+ // but we maybe out of sync with the server
450
+ onChange?.({ change })
451
+ },
385
452
 
386
- get().xyflow.setNodes(nodes =>
387
- nodes.map(node => {
388
- let element = node.data.element
389
- const shape = newShapes.get(element.id)
390
- if (shape && shape !== element.shape) {
391
- element = {
392
- ...element,
393
- shape
394
- }
453
+ triggerSaveManualLayout: (xystore) => {
454
+ let { viewSyncDebounceTimeout: debounced, onChange } = get()
455
+ if (!onChange) {
456
+ DEV && console.debug('ignore triggerSaveManualLayout, as no onChange handler is set')
457
+ return
458
+ }
459
+ if (debounced) {
460
+ clearTimeout(debounced)
461
+ }
462
+ debounced = setTimeout(() => {
463
+ const { nodeLookup } = xystore.getState()
464
+ const { xyflow, onChange } = get()
465
+
466
+ const movedNodes = new StringSet()
467
+ const nodes = reduce([...nodeLookup.values()], (acc, node) => {
468
+ const dimensions = getNodeDimensions(node)
469
+ if (!isSamePoint(node.internals.positionAbsolute, node.data.element.position)) {
470
+ movedNodes.add(node.id)
395
471
  }
396
- const color = newColor.get(element.id)
397
- if (color && color !== element.color) {
398
- element = {
399
- ...element,
400
- color
401
- }
472
+ acc[node.data.fqn] = {
473
+ x: node.internals.positionAbsolute.x,
474
+ y: node.internals.positionAbsolute.y,
475
+ width: dimensions.width,
476
+ height: dimensions.height
402
477
  }
403
-
404
- const opacity = newOpacity.get(element.id)
405
- if (!!opacity && opacity !== element.style?.opacity) {
406
- element = {
407
- ...element,
408
- style: {
409
- ...element.style,
410
- opacity
411
- }
412
- }
478
+ return acc
479
+ }, {} as Changes.SaveManualLayout['nodes'])
480
+ const edges = reduce(xyflow.getEdges(), (acc, { source, target, data }) => {
481
+ let controlPoints = data.controlPoints
482
+ // If edge control points are not set, but the source or target node was moved
483
+ if (!controlPoints && (movedNodes.has(source) || movedNodes.has(target))) {
484
+ controlPoints = bezierControlPoints(data.edge)
413
485
  }
414
-
415
- const border = newBorder.get(element.id)
416
- if (border && border !== element.style?.border) {
417
- element = {
418
- ...element,
419
- style: {
420
- ...element.style,
421
- border
422
- }
486
+ if (controlPoints) {
487
+ acc[data.edge.id] = {
488
+ controlPoints
423
489
  }
424
490
  }
491
+ return acc
492
+ }, {} as Changes.SaveManualLayout['edges'])
425
493
 
426
- if (element === node.data.element) {
427
- return node
428
- }
429
- return {
430
- ...node,
431
- data: {
432
- ...node.data,
433
- element
434
- }
435
- }
436
- })
494
+ const change: Changes.SaveManualLayout = {
495
+ op: 'save-manual-layout',
496
+ nodes,
497
+ edges
498
+ }
499
+
500
+ if (DEV) {
501
+ console.debug('triggerSaveManualLayout', change)
502
+ }
503
+ set({ viewSyncDebounceTimeout: null }, noReplace, 'clear debounce timeout')
504
+ onChange?.({ change })
505
+ }, 5000)
506
+ set(
507
+ {
508
+ viewSyncDebounceTimeout: debounced,
509
+ viewSyncState: 'changed'
510
+ },
511
+ noReplace,
512
+ 'change sync state'
437
513
  )
438
- get().onChange?.({ changes })
439
514
  },
440
515
 
441
516
  triggerOnNavigateTo: (xynodeId, event) => {
@@ -443,7 +518,6 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
443
518
  const xynode = xyflow.getInternalNode(xynodeId)
444
519
  const element = xynode?.data.element as DiagramNodeWithNavigate
445
520
  invariant(!!xynode && element?.navigateTo, `node is not navigable: ${xynodeId}`)
446
- const dimensions = getNodeDimensions(xynode)
447
521
  set(
448
522
  {
449
523
  lastClickedNodeId: xynodeId,
@@ -451,9 +525,9 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
451
525
  fromView: view.id,
452
526
  toView: element.navigateTo,
453
527
  element: element,
454
- elementCenterScreenPosition: xyflow.flowToScreenPosition({
455
- x: xynode.internals.positionAbsolute.x + dimensions.width / 2,
456
- y: xynode.internals.positionAbsolute.y + dimensions.height / 2
528
+ elementScreenPosition: xyflow.flowToScreenPosition({
529
+ x: xynode.internals.positionAbsolute.x, // + dimensions.width / 2,
530
+ y: xynode.internals.positionAbsolute.y // + dimensions.height / 2
457
531
  }),
458
532
  positionCorrected: false
459
533
  }
@@ -479,7 +553,7 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
479
553
  height: view.height
480
554
  }
481
555
  const viewport = getViewportForBounds(bounds, width, height, MinZoom, 1, fitViewPadding)
482
- panZoom?.setViewport(viewport, { duration: 400 })
556
+ panZoom?.setViewport(viewport, { duration: 500 })
483
557
  if (!!focusedNodeId) {
484
558
  set(
485
559
  {
@@ -496,6 +570,9 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
496
570
  const { view, activeDynamicViewStep, xyflow, fitViewPadding } = get()
497
571
  invariant(view.__ === 'dynamic', 'view is not dynamic')
498
572
  const nextStep = (activeDynamicViewStep ?? 0) + increment
573
+ if (nextStep <= 0 || nextStep > view.edges.length) {
574
+ return
575
+ }
499
576
  const edgeId = StepEdgeId(nextStep)
500
577
  const dimmed = new StringSet()
501
578
  let edge: XYFlowEdge | null = null
package/src/theme.css.ts CHANGED
@@ -4,7 +4,6 @@ export const vars = createGlobalThemeContract({
4
4
  default: {
5
5
  font: 'default-font-family'
6
6
  },
7
- filterDimmed: 'filter-dimmed',
8
7
  likec4: {
9
8
  font: 'font-family',
10
9
  background: {