@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
@@ -1,12 +1,18 @@
1
- import type { DiagramNode, DiagramView, Fqn, ViewID } from '@likec4/core'
2
- import { invariant, nonexhaustive, StepEdgeId } from '@likec4/core'
3
- import { getViewportForBounds, type XYPosition } from '@xyflow/react'
4
- import { getNodeDimensions } from '@xyflow/system'
1
+ import type { DiagramNode, DiagramView, Fqn, NodeId, ViewID } from '@likec4/core'
2
+ import { getBBoxCenter, invariant, nonexhaustive, nonNullable, StepEdgeId } from '@likec4/core'
3
+ import {
4
+ applyEdgeChanges,
5
+ applyNodeChanges,
6
+ getViewportForBounds,
7
+ type OnEdgesChange,
8
+ type OnNodesChange
9
+ } from '@xyflow/react'
10
+ import { boxToRect, getBoundsOfRects, getNodeDimensions } from '@xyflow/system'
5
11
  import { DEV } from 'esm-env'
6
- import { deepEqual, shallowEqual } from 'fast-equals'
12
+ import { deepEqual as eq, shallowEqual } from 'fast-equals'
7
13
  import type { MouseEvent as ReactMouseEvent } from 'react'
8
- import { entries, isEmpty, reduce } from 'remeda'
9
- import type { Exact, Except, RequiredKeysOf, Simplify } from 'type-fest'
14
+ import { entries, hasAtLeast, isNullish, reduce } from 'remeda'
15
+ import type { ConditionalKeys, Exact, Except, RequiredKeysOf, Simplify } from 'type-fest'
10
16
  import { devtools, subscribeWithSelector } from 'zustand/middleware'
11
17
  import { shallow } from 'zustand/shallow'
12
18
  import { createWithEqualityFn } from 'zustand/traditional'
@@ -19,11 +25,14 @@ import type {
19
25
  import { MinZoom } from '../xyflow/const'
20
26
  import type { XYStoreApi } from '../xyflow/hooks'
21
27
  import type { XYFlowEdge, XYFlowInstance, XYFlowNode } from '../xyflow/types'
22
- import { bezierControlPoints, isSamePoint } from '../xyflow/utils'
28
+ import { bezierControlPoints, isSamePoint, toDomPrecision } from '../xyflow/utils'
29
+ import { diagramViewToXYFlowData } from './diagram-to-xyflow'
23
30
 
24
- export type DiagramStore = {
25
- readonly storeDevId: string
26
- // Incoming props
31
+ type RequiredOrNull<T> = {
32
+ [P in keyof T]-?: NonNullable<T[P]> | null
33
+ }
34
+
35
+ export type DiagramInitialState = {
27
36
  view: DiagramView
28
37
  readonly: boolean
29
38
  showElementLinks: boolean
@@ -34,104 +43,106 @@ export type DiagramStore = {
34
43
  nodesDraggable: boolean
35
44
  nodesSelectable: boolean
36
45
  experimentalEdgeEditing: boolean
46
+ enableFocusMode: boolean
37
47
  renderIcon: ElementIconRenderer | null
38
- // Diagram Container, see DiagramContainer.tsx
39
- getContainer: () => HTMLDivElement | null
40
-
41
- // Internal state
42
- viewSyncDebounceTimeout: number | null
48
+ // If Dynamic View
49
+ enableDynamicViewWalkthrough: boolean
43
50
 
44
51
  xyflow: XYFlowInstance
45
52
  xystore: XYStoreApi
46
- initialized: boolean
47
- xyflowSynced: boolean
48
-
49
- // If Dynamic View
50
- activeDynamicViewStep: number | null
51
-
52
- // This is XYFlow id's
53
- lastClickedNodeId: string | null
54
- lastClickedEdgeId: string | null
55
- focusedNodeId: string | null
56
- hoveredNodeId: string | null
57
- hoveredEdgeId: string | null
58
-
59
- // id's of nodes / edges that
60
- dimmed: ReadonlySet<string>
61
-
62
- // Stack of previous distinct views (unique id's)
63
- previousViews: DiagramView[]
64
-
65
- lastOnNavigate: null | {
66
- fromView: ViewID
67
- toView: ViewID
68
- element: DiagramNode
69
- // Position of the element in the source view
70
- elementScreenPosition: XYPosition
71
- // If node from the target view is placed on the same position as the node from the source view
72
- positionCorrected: boolean
73
- }
74
53
 
75
- // User changed the viewport by dragging or zooming
76
- viewportChanged: boolean
77
- } & Required<LikeC4DiagramEventHandlers>
54
+ // Diagram Container, for Mantine Portal
55
+ getContainer: () => HTMLDivElement | null
56
+ } & RequiredOrNull<LikeC4DiagramEventHandlers>
78
57
 
79
58
  const StringSet = Set<string>
80
59
 
81
- export type DiagramInitialState = // Required properties
82
- & Pick<
83
- DiagramStore,
84
- | 'view'
85
- | 'renderIcon'
86
- | 'readonly'
87
- | 'showElementLinks'
88
- | 'fitViewEnabled'
89
- | 'fitViewPadding'
90
- | 'zoomable'
91
- | 'pannable'
92
- | 'nodesDraggable'
93
- | 'nodesSelectable'
94
- | 'experimentalEdgeEditing'
95
- >
96
- & LikeC4DiagramEventHandlers
60
+ export type DiagramState = Simplify<
61
+ DiagramInitialState & {
62
+ readonly storeDevId: string
97
63
 
98
- interface DiagramStoreActions {
99
- isDynamicView: () => boolean
64
+ // Internal state
65
+ xynodes: XYFlowNode[]
66
+ xyedges: XYFlowEdge[]
67
+ viewSyncDebounceTimeout: number | null
68
+ viewportChanged: boolean
100
69
 
101
- updateView: (view: DiagramView) => void
70
+ initialized: boolean
102
71
 
103
- focusOnNode: (nodeId: string) => void
72
+ // If Dynamic View
73
+ isDynamicView: boolean
74
+ activeDynamicViewStep: number | null
104
75
 
105
- setHoveredNode: (nodeId: string | null) => void
106
- setHoveredEdge: (edgeId: string | null) => void
76
+ // This is XYFlow id's
77
+ lastClickedNodeId: string | null
78
+ lastClickedEdgeId: string | null
79
+ focusedNodeId: string | null
80
+ hoveredNodeId: string | null
81
+ hoveredEdgeId: string | null
107
82
 
108
- setLastClickedNode: (nodeId: string | null) => void
109
- setLastClickedEdge: (edgeId: string | null) => void
83
+ // id's of nodes / edges that
84
+ dimmed: ReadonlySet<string>
110
85
 
111
- resetLastClicked: () => void
86
+ lastOnNavigate: null | {
87
+ fromView: ViewID
88
+ toView: ViewID
89
+ fromNode: NodeId
90
+ }
91
+ navigationHistory: Array<{
92
+ viewId: ViewID
93
+ nodeId: NodeId | null
94
+ }>
95
+ navigationHistoryIndex: number
112
96
 
113
- getElement(id: Fqn): DiagramNode | null
114
- triggerChangeElementStyle: (change: Changes.ChangeElementStyle) => void
97
+ // Actions
98
+ updateView: (view: DiagramView) => void
115
99
 
116
- cancelSaveManualLayout: () => void
117
- triggerSaveManualLayout: () => void
118
- triggerOnNavigateTo: (xynodeId: string, event: ReactMouseEvent) => void
119
- fitDiagram: (duration?: number) => void
100
+ focusOnNode: (nodeId: string) => void
120
101
 
121
- nextDynamicStep: (increment?: number) => void
122
- stopDynamicView: () => void
123
- }
102
+ setHoveredNode: (nodeId: string | null) => void
103
+ setHoveredEdge: (edgeId: string | null) => void
104
+
105
+ setLastClickedNode: (nodeId: string | null) => void
106
+ setLastClickedEdge: (edgeId: string | null) => void
107
+
108
+ resetLastClicked: () => void
109
+
110
+ getElement(id: Fqn): DiagramNode | null
111
+ triggerChangeElementStyle: (change: Changes.ChangeElementStyle) => void
112
+
113
+ /**
114
+ * @returns true if there was pending save layout
115
+ */
116
+ cancelSaveManualLayout: () => boolean
117
+ scheduleSaveManualLayout: () => void
118
+ triggerSaveManualLayout: () => void
119
+
120
+ triggerOnNavigateTo: (xynodeId: string, event: ReactMouseEvent) => void
121
+ fitDiagram: (duration?: number) => void
124
122
 
125
- export type DiagramState = Simplify<DiagramStore & DiagramStoreActions>
123
+ goBack: () => void
124
+ goForward: () => void
125
+
126
+ nextDynamicStep: (increment?: number) => void
127
+ activateDynamicStep: (step: number) => void
128
+ stopDynamicView: () => void
129
+
130
+ onInit: (xyflow: XYFlowInstance) => void
131
+ onNodesChange: OnNodesChange<XYFlowNode>
132
+ onEdgesChange: OnEdgesChange<XYFlowEdge>
133
+ }
134
+ >
126
135
 
127
136
  const DEFAULT_PROPS: Except<
128
- DiagramStore,
129
- RequiredKeysOf<DiagramInitialState> | 'xystore' | 'xyflow' | 'getContainer' | 'storeDevId'
137
+ DiagramState,
138
+ 'storeDevId' | 'navigationHistory' | RequiredKeysOf<DiagramInitialState> | ConditionalKeys<DiagramState, Function>
130
139
  > = {
140
+ xyedges: [],
141
+ xynodes: [],
142
+ isDynamicView: false,
131
143
  viewSyncDebounceTimeout: null,
132
144
  initialized: false,
133
- xyflowSynced: false,
134
- previousViews: [],
145
+ navigationHistoryIndex: 0,
135
146
  viewportChanged: false,
136
147
  activeDynamicViewStep: null,
137
148
  focusedNodeId: null,
@@ -140,28 +151,24 @@ const DEFAULT_PROPS: Except<
140
151
  lastClickedNodeId: null,
141
152
  lastClickedEdgeId: null,
142
153
  dimmed: new StringSet(),
143
- lastOnNavigate: null,
144
- onChange: null,
145
- onNavigateTo: null,
146
- onNodeClick: null,
147
- onNodeContextMenu: null,
148
- onCanvasContextMenu: null,
149
- onEdgeClick: null,
150
- onEdgeContextMenu: null,
151
- onCanvasClick: null,
152
- onCanvasDblClick: null
154
+ lastOnNavigate: null
153
155
  }
154
156
 
155
- export type CreateDiagramStore = DiagramInitialState & Pick<DiagramStore, 'xystore' | 'xyflow' | 'getContainer'>
156
-
157
157
  const noReplace = false
158
158
 
159
159
  let StoreDevId = 1
160
160
 
161
161
  const EmptyStringSet: ReadonlySet<string> = new StringSet()
162
162
 
163
- export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props: T) {
163
+ export function createDiagramStore<T extends Exact<DiagramInitialState, T>>(props: T) {
164
164
  const storeDevId = 'DiagramStore' + String(StoreDevId++).padStart(2, '0')
165
+ const {
166
+ xynodes,
167
+ xyedges
168
+ } = diagramViewToXYFlowData(props.view, {
169
+ draggable: props.nodesDraggable,
170
+ selectable: props.nodesSelectable
171
+ })
165
172
  return createWithEqualityFn<
166
173
  DiagramState,
167
174
  [
@@ -172,28 +179,49 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
172
179
  subscribeWithSelector(
173
180
  devtools(
174
181
  (set, get) => ({
175
- storeDevId,
176
182
  ...DEFAULT_PROPS,
177
- ...(props as CreateDiagramStore),
178
- isDynamicView: () => {
179
- return get().view?.__ === 'dynamic'
183
+ ...(props as DiagramInitialState),
184
+ storeDevId,
185
+ isDynamicView: props.view.__ === 'dynamic',
186
+ xynodes,
187
+ xyedges,
188
+ navigationHistory: [{
189
+ viewId: props.view.id,
190
+ nodeId: null as Fqn | null
191
+ }],
192
+ navigationHistoryIndex: 0,
193
+
194
+ onNodesChange: (changes) => {
195
+ set({
196
+ xynodes: applyNodeChanges(changes, get().xynodes)
197
+ })
198
+ },
199
+ onEdgesChange: (changes) => {
200
+ set({
201
+ xyedges: applyEdgeChanges(changes, get().xyedges)
202
+ })
180
203
  },
181
204
 
182
205
  updateView: (nextView) => {
183
206
  let {
184
207
  viewSyncDebounceTimeout,
185
208
  xyflow,
209
+ xystore,
186
210
  dimmed,
187
- xyflowSynced,
188
211
  view: current,
189
212
  lastOnNavigate,
190
- previousViews,
213
+ navigationHistory,
214
+ navigationHistoryIndex,
191
215
  focusedNodeId,
192
216
  lastClickedNodeId,
193
217
  lastClickedEdgeId,
194
218
  activeDynamicViewStep,
219
+ nodesDraggable,
220
+ nodesSelectable,
195
221
  hoveredEdgeId,
196
- hoveredNodeId
222
+ hoveredNodeId,
223
+ xyedges,
224
+ xynodes
197
225
  } = get()
198
226
 
199
227
  if (shallowEqual(current, nextView)) {
@@ -230,7 +258,6 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
230
258
  if (activeDynamicViewStep && !edgeIds.has(StepEdgeId(activeDynamicViewStep))) {
231
259
  activeDynamicViewStep = null
232
260
  }
233
- xyflowSynced = deepEqual([current.nodes, current.edges], [nextView.nodes, nextView.edges])
234
261
  if (dimmed.size > 0) {
235
262
  let nextDimmed = new StringSet([...dimmed].filter(id => nodeIds.has(id) || edgeIds.has(id)))
236
263
  if (nextDimmed.size !== dimmed.size) {
@@ -239,15 +266,56 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
239
266
  }
240
267
  } else {
241
268
  // Reset lastOnNavigate if the view is not the source or target view
242
- if (lastOnNavigate) {
243
- if (lastOnNavigate.toView !== nextView.id && lastOnNavigate.fromView !== nextView.id) {
244
- lastOnNavigate = null
269
+ const stepCurrent = nonNullable(navigationHistory[navigationHistoryIndex])
270
+ if (stepCurrent.viewId !== nextView.id) {
271
+ navigationHistory = [
272
+ ...navigationHistory.slice(0, navigationHistoryIndex + 1),
273
+ {
274
+ viewId: nextView.id,
275
+ nodeId: lastOnNavigate?.fromNode || null
276
+ }
277
+ ]
278
+ navigationHistoryIndex = navigationHistory.length - 1
279
+ } else {
280
+ // We are navigating to the same view as in the history
281
+ if (stepCurrent.nodeId) {
282
+ lastOnNavigate ??= {
283
+ fromView: current.id,
284
+ toView: nextView.id,
285
+ fromNode: stepCurrent.nodeId
286
+ }
245
287
  }
246
288
  }
247
289
 
248
- if (!lastOnNavigate) {
290
+ if (lastOnNavigate && lastOnNavigate.toView !== nextView.id) {
291
+ lastOnNavigate = null
292
+ }
293
+
294
+ const elTo = lastOnNavigate && nextView.nodes.find(n => n.id === lastOnNavigate?.fromNode)
295
+ const xynodeFrom = elTo && xyflow.getInternalNode(elTo.id)
296
+
297
+ if (!lastOnNavigate || isNullish(elTo) || isNullish(xynodeFrom)) {
249
298
  const zoom = xyflow.getZoom()
250
- xyflow.setCenter(nextView.width / 2, nextView.height / 2, { zoom })
299
+ const { x, y } = getBBoxCenter(nextView.bounds)
300
+ xyflow.setCenter(x, y, { zoom })
301
+ lastOnNavigate = null
302
+ }
303
+
304
+ if (lastOnNavigate && !!elTo && !!xynodeFrom) {
305
+ const fromPos = xyflow.flowToScreenPosition({
306
+ x: xynodeFrom.internals.positionAbsolute.x, // + dimensions.width / 2,
307
+ y: xynodeFrom.internals.positionAbsolute.y // + dimensions.height / 2
308
+ }),
309
+ toPos = xyflow.flowToScreenPosition({
310
+ x: elTo.position[0], // + elFrom.width / 2,
311
+ y: elTo.position[1] // + elFrom.height / 2
312
+ }),
313
+ diff = {
314
+ x: toDomPrecision(fromPos.x - toPos.x),
315
+ y: toDomPrecision(fromPos.y - toPos.y)
316
+ }
317
+ xystore.getState().panBy(diff)
318
+ lastOnNavigate = null
251
319
  }
252
320
 
253
321
  // Reset hovered / clicked node/edge if the view is different
@@ -258,29 +326,64 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
258
326
  focusedNodeId = null
259
327
  activeDynamicViewStep = null
260
328
  dimmed = EmptyStringSet
329
+ }
330
+
331
+ const update = diagramViewToXYFlowData(nextView, {
332
+ draggable: nodesDraggable,
333
+ selectable: nodesSelectable
334
+ })
261
335
 
262
- // Update history stack (back button not implemented yet)
263
- previousViews = [
264
- current,
265
- ...previousViews.filter((v) => v.id !== nextView.id && v.id !== current.id)
266
- ]
267
- xyflowSynced = false
336
+ update.xynodes = update.xynodes.map(update => {
337
+ const existing = xynodes.find(n => n.id === update.id)
338
+ if (existing && existing.type === update.type && eq(existing.parentId ?? null, update.parentId ?? null)) {
339
+ if (eq(existing.data.element, update.data.element)) {
340
+ return existing
341
+ }
342
+ return {
343
+ ...existing,
344
+ ...update
345
+ }
346
+ }
347
+ return update
348
+ })
349
+ // Merge with existing edges, but only if the view is the same
350
+ // and the edges have no layout drift
351
+ if (isSameView && !nextView.hasLayoutDrift) {
352
+ update.xyedges = update.xyedges.map((update): XYFlowEdge => {
353
+ const existing = xyedges.find(n => n.id === update.id)
354
+ if (existing) {
355
+ if (eq(existing.data, update.data)) {
356
+ return existing
357
+ }
358
+ return {
359
+ ...existing,
360
+ ...update
361
+ }
362
+ }
363
+ return update
364
+ })
268
365
  }
269
366
 
367
+ const hasChanges = !isSameView || !shallowEqual(update.xynodes, xynodes)
368
+ || !shallowEqual(update.xyedges, xyedges)
369
+
270
370
  set(
271
371
  {
372
+ isDynamicView: nextView.__ === 'dynamic',
272
373
  viewSyncDebounceTimeout,
273
374
  view: nextView,
274
375
  activeDynamicViewStep,
275
- xyflowSynced,
276
376
  lastOnNavigate,
277
- previousViews,
278
377
  lastClickedNodeId,
279
378
  lastClickedEdgeId,
280
379
  focusedNodeId,
281
380
  hoveredEdgeId,
282
381
  hoveredNodeId,
283
- dimmed
382
+ navigationHistory,
383
+ navigationHistoryIndex,
384
+ dimmed,
385
+ xynodes: hasChanges ? update.xynodes : xynodes,
386
+ xyedges: hasChanges ? update.xyedges : xyedges
284
387
  },
285
388
  noReplace,
286
389
  isSameView ? 'update-view [same]' : 'update-view [another]'
@@ -288,7 +391,8 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
288
391
  },
289
392
 
290
393
  focusOnNode: (nodeId) => {
291
- const { focusedNodeId, view } = get()
394
+ const { focusedNodeId, view, enableFocusMode } = get()
395
+ invariant(enableFocusMode, 'focus mode is not enabled')
292
396
  if (nodeId !== focusedNodeId) {
293
397
  const notDimmed = new StringSet([nodeId])
294
398
  const dimmed = new StringSet()
@@ -448,70 +552,113 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
448
552
  if (viewSyncDebounceTimeout !== null) {
449
553
  clearTimeout(viewSyncDebounceTimeout)
450
554
  set({ viewSyncDebounceTimeout: null })
555
+ return true
451
556
  }
557
+ return false
452
558
  },
453
559
 
454
560
  triggerSaveManualLayout: () => {
455
- let { viewSyncDebounceTimeout: debounced, onChange } = get()
561
+ const { xyflow, view, onChange, xystore, viewSyncDebounceTimeout } = get()
562
+ if (viewSyncDebounceTimeout !== null) {
563
+ clearTimeout(viewSyncDebounceTimeout)
564
+ set({ viewSyncDebounceTimeout: null })
565
+ }
456
566
  if (!onChange) {
457
567
  DEV && console.debug('ignore triggerSaveManualLayout, as no onChange handler is set')
458
568
  return
459
569
  }
460
- if (debounced) {
461
- clearTimeout(debounced)
570
+ const { nodeLookup } = xystore.getState()
571
+ const movedNodes = new StringSet()
572
+ let bounds = {
573
+ x: 0,
574
+ y: 0,
575
+ width: 1,
576
+ height: 1
462
577
  }
463
- debounced = setTimeout(() => {
464
- const { xyflow, onChange, xystore } = get()
465
- const { nodeLookup } = xystore.getState()
466
- set({ viewSyncDebounceTimeout: null })
467
-
468
- const movedNodes = new StringSet()
469
- const nodes = reduce([...nodeLookup.values()], (acc, node) => {
470
- const dimensions = getNodeDimensions(node)
471
- if (!isSamePoint(node.internals.positionAbsolute, node.data.element.position)) {
472
- movedNodes.add(node.id)
473
- }
474
- acc[node.data.fqn] = {
475
- x: node.internals.positionAbsolute.x,
476
- y: node.internals.positionAbsolute.y,
477
- width: dimensions.width,
478
- height: dimensions.height
479
- }
480
- return acc
481
- }, {} as Changes.SaveManualLayout['nodes'])
482
- const edges = reduce(xyflow.getEdges(), (acc, { source, target, data }) => {
483
- let controlPoints = data.controlPoints
484
- // If edge control points are not set, but the source or target node was moved
485
- if (!controlPoints && (movedNodes.has(source) || movedNodes.has(target))) {
486
- controlPoints = bezierControlPoints(data.edge)
487
- }
488
- if (controlPoints) {
489
- acc[data.edge.id] = {
490
- controlPoints
491
- }
492
- }
578
+ const nodes = reduce([...nodeLookup.values()], (acc, node) => {
579
+ const dimensions = getNodeDimensions(node)
580
+ if (!isSamePoint(node.internals.positionAbsolute, node.data.element.position)) {
581
+ movedNodes.add(node.id)
582
+ }
583
+ const rect = acc[node.data.fqn] = {
584
+ isCompound: node.data.element.children.length > 0,
585
+ x: Math.floor(node.internals.positionAbsolute.x),
586
+ y: Math.floor(node.internals.positionAbsolute.y),
587
+ width: Math.ceil(dimensions.width),
588
+ height: Math.ceil(dimensions.height)
589
+ }
590
+ bounds = getBoundsOfRects(bounds, rect)
591
+ return acc
592
+ }, {} as Changes.SaveManualLayout['layout']['nodes'])
593
+ const edges = reduce(xyflow.getEdges(), (acc, { source, target, data }) => {
594
+ let controlPoints = data.controlPoints
595
+ const sourceOrTargetMoved = movedNodes.has(source) || movedNodes.has(target)
596
+ // If edge control points are not set, but the source or target node was moved
597
+ if ((!controlPoints || controlPoints.length === 0) && sourceOrTargetMoved) {
598
+ controlPoints = bezierControlPoints(data.edge)
599
+ }
600
+ if (data.edge.points.length === 0 && (!controlPoints || controlPoints.length === 0)) {
493
601
  return acc
494
- }, {} as Changes.SaveManualLayout['edges'])
495
- if (movedNodes.size === 0 && isEmpty(edges)) {
496
- DEV && console.debug('ignore triggerSaveManualLayout, as no changes detected')
497
- return
498
602
  }
603
+ const _updated: Changes.SaveManualLayout['layout']['edges'][string] = acc[data.edge.id] = {
604
+ points: data.edge.points
605
+ }
606
+ if (data.label?.bbox) {
607
+ _updated.labelBBox = data.label?.bbox
608
+ }
609
+ if (data.edge.labelBBox) {
610
+ _updated.labelBBox ??= data.edge.labelBBox
611
+ }
612
+ if (controlPoints && hasAtLeast(controlPoints, 1)) {
613
+ _updated.controlPoints = controlPoints
614
+ }
615
+ if (!sourceOrTargetMoved && data.edge.dotpos) {
616
+ _updated.dotpos = data.edge.dotpos
617
+ }
618
+ const allX = [...data.edge.points.map(p => p[0]), ...(controlPoints ?? []).map(p => p.x)]
619
+ const allY = [...data.edge.points.map(p => p[1]), ...(controlPoints ?? []).map(p => p.y)]
620
+ const rect = boxToRect({
621
+ x: Math.floor(Math.min(...allX)),
622
+ y: Math.floor(Math.min(...allY)),
623
+ x2: Math.ceil(Math.max(...allX)),
624
+ y2: Math.ceil(Math.max(...allY))
625
+ })
626
+ bounds = getBoundsOfRects(bounds, rect)
627
+ return acc
628
+ }, {} as Changes.SaveManualLayout['layout']['edges'])
629
+
630
+ const change: Changes.SaveManualLayout = {
631
+ op: 'save-manual-layout',
632
+ layout: {
633
+ hash: view.hash,
634
+ autoLayout: view.autoLayout,
499
635
 
500
- const change: Changes.SaveManualLayout = {
501
- op: 'save-manual-layout',
502
636
  nodes,
503
- edges
637
+ edges,
638
+ ...bounds
504
639
  }
640
+ }
505
641
 
506
- if (DEV) {
507
- console.debug('triggerSaveManualLayout', change)
508
- }
642
+ if (DEV) {
643
+ console.debug('triggerSaveManualLayout', change)
644
+ }
509
645
 
510
- onChange?.({ change })
511
- }, 2000) as any as number // explicit typecast to number to suppress TS error in astro build
646
+ onChange({ change })
647
+ },
648
+ scheduleSaveManualLayout: () => {
649
+ let { viewSyncDebounceTimeout, onChange } = get()
650
+ if (!onChange) {
651
+ DEV && console.debug('ignore triggerSaveManualLayout, as no onChange handler is set')
652
+ return
653
+ }
654
+ if (viewSyncDebounceTimeout) {
655
+ clearTimeout(viewSyncDebounceTimeout)
656
+ }
512
657
  set(
513
658
  {
514
- viewSyncDebounceTimeout: debounced
659
+ viewSyncDebounceTimeout: setTimeout(() => {
660
+ get().triggerSaveManualLayout()
661
+ }, 1000) as any as number // explicit typecast to number to suppress TS error in astro build
515
662
  },
516
663
  noReplace,
517
664
  'debounce sync state'
@@ -519,46 +666,90 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
519
666
  },
520
667
 
521
668
  triggerOnNavigateTo: (xynodeId, event) => {
522
- const { xyflow, view } = get()
523
- const xynode = xyflow.getInternalNode(xynodeId)
524
- invariant(!!xynode, `node not found: ${xynodeId}`)
525
- const element = xynode.data.element as DiagramNodeWithNavigate
526
- invariant(element.navigateTo, `node is not navigable: ${xynodeId}`)
669
+ const { view, xynodes, onNavigateTo } = get()
670
+ if (!onNavigateTo) {
671
+ return
672
+ }
673
+ const xynode = xynodes.find(({ id }) => id === xynodeId)
674
+ invariant(xynode, `xynode not found: ${xynodeId}`)
675
+ const element = view.nodes.find(({ id }) => id === xynodeId)
676
+ invariant(element?.navigateTo, `node is not navigable: ${xynodeId}`)
527
677
  set(
528
678
  {
529
679
  lastClickedNodeId: xynodeId,
530
680
  lastOnNavigate: {
531
681
  fromView: view.id,
532
682
  toView: element.navigateTo,
533
- element: element,
534
- elementScreenPosition: xyflow.flowToScreenPosition({
535
- x: xynode.internals.positionAbsolute.x, // + dimensions.width / 2,
536
- y: xynode.internals.positionAbsolute.y // + dimensions.height / 2
537
- }),
538
- positionCorrected: false
683
+ fromNode: element.id
539
684
  }
540
685
  },
541
686
  noReplace,
542
687
  'triggerOnNavigateTo'
543
688
  )
544
- get().onNavigateTo?.(
689
+ onNavigateTo(
545
690
  element.navigateTo,
546
691
  event,
547
- element,
548
- xynode.internals.userNode
692
+ element as DiagramNodeWithNavigate,
693
+ xynode
549
694
  )
550
695
  },
551
696
 
697
+ goBack: () => {
698
+ const { navigationHistory, navigationHistoryIndex, onNavigateTo } = get()
699
+ const { viewId, nodeId } = nonNullable(navigationHistory[navigationHistoryIndex])
700
+ const stepBack = (navigationHistoryIndex > 0 && navigationHistory[navigationHistoryIndex - 1]) || null
701
+ if (stepBack && onNavigateTo) {
702
+ set(
703
+ {
704
+ lastClickedEdgeId: null,
705
+ lastClickedNodeId: null,
706
+ navigationHistoryIndex: navigationHistoryIndex - 1,
707
+ lastOnNavigate: nodeId
708
+ ? {
709
+ fromView: viewId,
710
+ toView: stepBack.viewId,
711
+ fromNode: nodeId as Fqn
712
+ }
713
+ : null
714
+ },
715
+ noReplace,
716
+ 'goBack'
717
+ )
718
+ onNavigateTo(stepBack.viewId)
719
+ }
720
+ },
721
+ goForward: () => {
722
+ const { navigationHistory, navigationHistoryIndex, onNavigateTo } = get()
723
+ const { viewId } = nonNullable(navigationHistory[navigationHistoryIndex])
724
+ const stepForward = navigationHistoryIndex < navigationHistory.length - 1
725
+ ? navigationHistory[navigationHistoryIndex + 1]
726
+ : null
727
+ if (stepForward && onNavigateTo) {
728
+ set(
729
+ {
730
+ lastClickedEdgeId: null,
731
+ lastClickedNodeId: null,
732
+ navigationHistoryIndex: navigationHistoryIndex + 1,
733
+ lastOnNavigate: stepForward.nodeId
734
+ ? {
735
+ fromView: viewId,
736
+ toView: stepForward.viewId,
737
+ fromNode: stepForward.nodeId as Fqn
738
+ }
739
+ : null
740
+ },
741
+ noReplace,
742
+ 'goForward'
743
+ )
744
+ onNavigateTo(stepForward.viewId)
745
+ }
746
+ },
747
+
552
748
  fitDiagram: (duration = 500) => {
553
749
  const { fitViewPadding, view, focusedNodeId, activeDynamicViewStep, xystore } = get()
554
750
  const { width, height, panZoom, transform } = xystore.getState()
555
751
 
556
- const bounds = {
557
- x: 0,
558
- y: 0,
559
- width: view.width,
560
- height: view.height
561
- }
752
+ const bounds = view.bounds
562
753
  const maxZoom = Math.max(1, transform[2])
563
754
  const viewport = getViewportForBounds(bounds, width, height, MinZoom, maxZoom, fitViewPadding)
564
755
  panZoom?.setViewport(viewport, { duration })
@@ -576,12 +767,19 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
576
767
  },
577
768
 
578
769
  nextDynamicStep: (increment = 1) => {
579
- const { view, activeDynamicViewStep, xyflow, xystore, fitViewPadding } = get()
580
- invariant(view.__ === 'dynamic', 'view is not dynamic')
770
+ const { activeDynamicViewStep, activateDynamicStep } = get()
581
771
  const nextStep = (activeDynamicViewStep ?? 0) + increment
582
- if (nextStep <= 0 || nextStep > view.edges.length) {
772
+ if (nextStep <= 0) {
583
773
  return
584
774
  }
775
+ if (nextStep !== activeDynamicViewStep) {
776
+ activateDynamicStep(nextStep)
777
+ }
778
+ },
779
+
780
+ activateDynamicStep: (nextStep: number) => {
781
+ const { isDynamicView, xyflow, xystore, fitViewPadding } = get()
782
+ invariant(isDynamicView, 'view is not dynamic')
585
783
  const edgeId = StepEdgeId(nextStep)
586
784
  const dimmed = new StringSet()
587
785
  let edge: XYFlowEdge | null = null
@@ -617,7 +815,7 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
617
815
  dimmed
618
816
  },
619
817
  noReplace,
620
- 'nextDynamicStep'
818
+ 'activateDynamicStep'
621
819
  )
622
820
  },
623
821
 
@@ -634,6 +832,20 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
634
832
  )
635
833
  get().fitDiagram()
636
834
  }
835
+ },
836
+
837
+ onInit: (instance) => {
838
+ const { xyflow, initialized } = get()
839
+ if (!initialized || xyflow !== instance) {
840
+ set(
841
+ {
842
+ xyflow: instance,
843
+ initialized: true
844
+ },
845
+ noReplace,
846
+ 'onInit'
847
+ )
848
+ }
637
849
  }
638
850
  }),
639
851
  {