@likec4/diagram 1.2.2 → 1.4.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 (69) hide show
  1. package/dist/LikeC4Diagram.d.ts +1 -1
  2. package/dist/LikeC4Diagram.props.d.ts +27 -4
  3. package/dist/index.mjs +1543 -1346
  4. package/dist/state/DiagramContext.d.ts +2353 -264
  5. package/dist/state/diagramStore.d.ts +2360 -268
  6. package/dist/state/index.d.ts +0 -1
  7. package/dist/state/useDiagramStore.d.ts +3 -2534
  8. package/dist/state/useNavigationHistory.d.ts +5 -0
  9. package/dist/style.css +194 -131
  10. package/dist/theme.css.d.ts +5 -1
  11. package/dist/ui/BackwardForwardPanel.css.d.ts +1 -0
  12. package/dist/ui/BackwardForwardPanel.d.ts +1 -0
  13. package/dist/ui/DiagramTitlePanel.css.d.ts +1 -1
  14. package/dist/xyflow/XYFlow.d.ts +2 -1
  15. package/dist/xyflow/XYFlowEvents.d.ts +4 -4
  16. package/dist/xyflow/XYFlowInner.d.ts +2 -1
  17. package/dist/xyflow/const.d.ts +1 -1
  18. package/dist/xyflow/diagram-to-xyflow.d.ts +1 -1
  19. package/dist/xyflow/edges/RelationshipEdge.d.ts +4 -5
  20. package/dist/xyflow/edges/utils.d.ts +4 -2
  21. package/dist/xyflow/hooks/useXYFlow.d.ts +9 -9
  22. package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +3 -1
  23. package/dist/xyflow/nodes/element/ElementNode.d.ts +1 -1
  24. package/dist/xyflow/nodes/element/element.css.d.ts +1 -0
  25. package/dist/xyflow/nodes/element/index.d.ts +1 -1
  26. package/dist/xyflow/types.d.ts +3 -3
  27. package/dist/xyflow/utils.d.ts +3 -0
  28. package/package.json +18 -17
  29. package/src/LikeC4Diagram.css.ts +15 -5
  30. package/src/LikeC4Diagram.props.ts +33 -5
  31. package/src/LikeC4Diagram.tsx +103 -32
  32. package/src/globals.css.ts +1 -2
  33. package/src/mantine/theme.ts +1 -0
  34. package/src/state/DiagramContext.tsx +3 -4
  35. package/src/state/diagramStore.ts +177 -103
  36. package/src/state/index.ts +0 -1
  37. package/src/state/useDiagramStore.ts +6 -13
  38. package/src/state/useNavigationHistory.ts +39 -0
  39. package/src/theme.css.ts +5 -1
  40. package/src/ui/BackwardForwardPanel.css.ts +22 -0
  41. package/src/ui/BackwardForwardPanel.tsx +59 -0
  42. package/src/ui/DiagramTitlePanel.css.ts +11 -5
  43. package/src/ui/DiagramTitlePanel.tsx +108 -96
  44. package/src/ui/DynamicViewWalkthrough.css.ts +3 -2
  45. package/src/ui/DynamicViewWalkthrough.tsx +36 -17
  46. package/src/ui/OptionsPanel.tsx +28 -19
  47. package/src/ui/options/NodeOptions.tsx +16 -19
  48. package/src/xyflow/FitviewOnDiagramChange.tsx +15 -23
  49. package/src/xyflow/SelectEdgesOnNodeFocus.tsx +3 -6
  50. package/src/xyflow/SyncWithDiagram.tsx +21 -2
  51. package/src/xyflow/XYFlow.tsx +20 -23
  52. package/src/xyflow/XYFlowEvents.tsx +26 -8
  53. package/src/xyflow/XYFlowInner.tsx +10 -6
  54. package/src/xyflow/const.ts +1 -1
  55. package/src/xyflow/diagram-to-xyflow.ts +4 -41
  56. package/src/xyflow/edges/RelationshipEdge.tsx +160 -111
  57. package/src/xyflow/edges/edges.css.ts +41 -26
  58. package/src/xyflow/edges/utils.ts +5 -2
  59. package/src/xyflow/hooks/useLayoutConstraints.ts +19 -4
  60. package/src/xyflow/nodes/compound/CompoundNode.css.ts +45 -37
  61. package/src/xyflow/nodes/compound/CompoundNode.tsx +36 -26
  62. package/src/xyflow/nodes/element/ElementNode.tsx +27 -14
  63. package/src/xyflow/nodes/element/element.css.ts +31 -18
  64. package/src/xyflow/nodes/element/index.ts +1 -1
  65. package/src/xyflow/nodes/shared/NavigateToBtn.tsx +7 -3
  66. package/src/xyflow/types.ts +5 -2
  67. package/src/xyflow/utils.ts +48 -0
  68. package/dist/ui/index.d.ts +0 -2
  69. package/src/ui/index.ts +0 -3
@@ -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: {
@@ -4,12 +4,12 @@ import { useUpdateEffect } from '../hooks'
4
4
  import { useXYFlow } from '../xyflow/hooks'
5
5
  import { createDiagramStore, type DiagramInitialState } from './diagramStore'
6
6
 
7
- type DiagramContextValue = ReturnType<typeof createDiagramStore>
8
- export const DiagramContext = createContext<DiagramContextValue | null>(null)
7
+ export type DiagramZustandStore = ReturnType<typeof createDiagramStore>
8
+ export const DiagramContext = createContext<DiagramZustandStore | null>(null)
9
9
 
10
10
  export function DiagramContextProvider({ children, view, ...props }: PropsWithChildren<DiagramInitialState>) {
11
11
  const xyflow = useXYFlow()
12
- const store = useRef<DiagramContextValue>()
12
+ const store = useRef<DiagramZustandStore>()
13
13
 
14
14
  if (!store.current) {
15
15
  store.current = createDiagramStore({
@@ -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'
1
+ import type { DiagramNode, DiagramView, Fqn, ViewID } from '@likec4/core'
12
2
  import { invariant, nonexhaustive, StepEdgeId } from '@likec4/core'
13
3
  import { getViewportForBounds, type XYPosition } from '@xyflow/react'
14
4
  import { 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 { entries, isEmpty, 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,11 @@ 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
+ viewSyncDebounceTimeout: number | null
34
+
41
35
  xyflow: XYFlowInstance
42
36
  initialized: boolean
43
37
  xyflowSynced: boolean
@@ -62,7 +56,8 @@ export type DiagramStore = {
62
56
  fromView: ViewID
63
57
  toView: ViewID
64
58
  element: DiagramNode
65
- elementCenterScreenPosition: XYPosition
59
+ // Position of the element in the source view
60
+ elementScreenPosition: XYPosition
66
61
  // If node from the target view is placed on the same position as the node from the source view
67
62
  positionCorrected: boolean
68
63
  }
@@ -85,6 +80,7 @@ export type DiagramInitialState = // Required properties
85
80
  | 'pannable'
86
81
  | 'nodesDraggable'
87
82
  | 'nodesSelectable'
83
+ | 'experimentalEdgeEditing'
88
84
  >
89
85
  // Optional properties
90
86
  // & Partial<
@@ -111,7 +107,10 @@ interface DiagramStoreActions {
111
107
  resetLastClicked: () => void
112
108
 
113
109
  getElement(id: Fqn): DiagramNode | null
114
- triggerOnChange: (changes: NonEmptyArray<Change>) => void
110
+ triggerChangeElementStyle: (change: Changes.ChangeElementStyle) => void
111
+
112
+ cancelSaveManualLayout: () => void
113
+ triggerSaveManualLayout: (xystore: XYStoreApi) => void
115
114
  triggerOnNavigateTo: (xynodeId: string, event: ReactMouseEvent) => void
116
115
  fitDiagram: (xyStore: XYStoreApi) => void
117
116
 
@@ -122,6 +121,7 @@ interface DiagramStoreActions {
122
121
  export type DiagramState = Simplify<DiagramStore & DiagramStoreActions>
123
122
 
124
123
  const DEFAULT_PROPS: Except<DiagramStore, RequiredKeysOf<DiagramInitialState> | 'xyflow'> = {
124
+ viewSyncDebounceTimeout: null,
125
125
  initialized: false,
126
126
  xyflowSynced: false,
127
127
  previousViews: [],
@@ -173,6 +173,8 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
173
173
 
174
174
  updateView: (nextView) => {
175
175
  let {
176
+ viewSyncDebounceTimeout,
177
+ xyflow,
176
178
  dimmed,
177
179
  xyflowSynced,
178
180
  view: current,
@@ -186,10 +188,12 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
186
188
  hoveredNodeId
187
189
  } = get()
188
190
 
191
+ if (viewSyncDebounceTimeout !== null) {
192
+ clearTimeout(viewSyncDebounceTimeout)
193
+ viewSyncDebounceTimeout = null
194
+ }
195
+
189
196
  if (shallowEqual(current, nextView)) {
190
- if (!xyflowSynced) {
191
- set({ xyflowSynced: true }, noReplace, 'updateView: xyflow synced')
192
- }
193
197
  DEV && console.debug('store: skip updateView')
194
198
  return
195
199
  }
@@ -218,8 +222,8 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
218
222
  if (activeDynamicViewStep && !edgeIds.has(StepEdgeId(activeDynamicViewStep))) {
219
223
  activeDynamicViewStep = null
220
224
  }
221
- xyflowSynced = deepEqual(current.nodes, nextView.nodes) && deepEqual(current.edges, nextView.edges)
222
- if (!xyflowSynced) {
225
+ xyflowSynced = deepEqual([current.nodes, current.edges], [nextView.nodes, nextView.edges])
226
+ if (dimmed.size > 0) {
223
227
  let nextDimmed = new StringSet([...dimmed].filter(id => nodeIds.has(id) || edgeIds.has(id)))
224
228
  if (nextDimmed.size !== dimmed.size) {
225
229
  dimmed = nextDimmed
@@ -233,6 +237,11 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
233
237
  }
234
238
  }
235
239
 
240
+ if (!lastOnNavigate) {
241
+ const zoom = xyflow.getZoom()
242
+ xyflow.setCenter(nextView.width / 2, nextView.height / 2, { zoom })
243
+ }
244
+
236
245
  // Reset hovered / clicked node/edge if the view is different
237
246
  lastClickedEdgeId = null
238
247
  lastClickedNodeId = null
@@ -252,6 +261,7 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
252
261
 
253
262
  set(
254
263
  {
264
+ viewSyncDebounceTimeout,
255
265
  view: nextView,
256
266
  activeDynamicViewStep,
257
267
  xyflowSynced,
@@ -302,37 +312,39 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
302
312
 
303
313
  setHoveredNode: (nodeId) => {
304
314
  if (nodeId !== get().hoveredNodeId) {
305
- set({ hoveredNodeId: nodeId }, noReplace, nodeId ? 'setHoveredNode' : 'unsetHoveredNode')
315
+ set({ hoveredNodeId: nodeId })
306
316
  }
307
317
  },
308
318
 
309
319
  setHoveredEdge: (edgeId) => {
310
320
  if (edgeId !== get().hoveredEdgeId) {
311
- set({ hoveredEdgeId: edgeId }, noReplace, edgeId ? 'setHoveredEdge' : 'unsetHoveredEdge')
321
+ set({ hoveredEdgeId: edgeId })
312
322
  }
313
323
  },
314
324
 
315
325
  setLastClickedNode: (nodeId) => {
316
326
  if (nodeId !== get().lastClickedNodeId) {
317
- set({ lastClickedNodeId: nodeId }, noReplace, nodeId ? 'setLastClickedNode' : 'unsetLastClickedNode')
327
+ set({ lastClickedNodeId: nodeId })
318
328
  }
319
329
  },
320
330
 
321
331
  setLastClickedEdge: (edgeId) => {
322
332
  if (edgeId !== get().lastClickedEdgeId) {
323
- set({ lastClickedEdgeId: edgeId }, noReplace, edgeId ? 'setLastClickedEdge' : 'unsetLastClickedEdge')
333
+ set({ lastClickedEdgeId: edgeId })
324
334
  }
325
335
  },
326
336
 
327
337
  resetLastClicked: () => {
328
338
  let {
339
+ activeDynamicViewStep,
329
340
  focusedNodeId,
330
341
  lastClickedNodeId,
331
342
  lastClickedEdgeId
332
343
  } = get()
333
- if (focusedNodeId || lastClickedNodeId || lastClickedEdgeId) {
344
+ if (activeDynamicViewStep || focusedNodeId || lastClickedNodeId || lastClickedEdgeId) {
334
345
  set(
335
346
  {
347
+ activeDynamicViewStep: null,
336
348
  focusedNodeId: null,
337
349
  lastClickedNodeId: null,
338
350
  lastClickedEdgeId: null,
@@ -349,93 +361,153 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
349
361
  return view.nodes.find(({ id }) => id === fqn) ?? null
350
362
  },
351
363
 
352
- triggerOnChange: (changes: NonEmptyArray<Change>) => {
364
+ triggerChangeElementStyle: (change) => {
353
365
  if (DEV) {
354
- console.debug('triggerOnChange', changes)
366
+ console.debug('triggerChangeElementStyle', change)
355
367
  }
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)
368
+ const { view, updateView, onChange } = get()
369
+
370
+ // Style changes we already apply to the view
371
+ let hasChanges = false
372
+ const nodes = view.nodes.map(origin => {
373
+ if (!change.targets.includes(origin.id)) {
374
+ return origin
363
375
  }
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
- }
376
+ let element = origin
377
+ for (const [key, value] of entries.strict(change.style)) {
378
+ switch (key) {
379
+ case 'shape':
380
+ if (value !== element.shape) {
381
+ element = {
382
+ ...element,
383
+ shape: value
384
+ }
385
+ }
386
+ break
387
+ case 'color':
388
+ if (value !== element.color) {
389
+ element = {
390
+ ...element,
391
+ color: value
392
+ }
393
+ }
394
+ break
395
+ case 'opacity':
396
+ if (value !== element.style?.opacity) {
397
+ element = {
398
+ ...element,
399
+ style: {
400
+ ...element.style,
401
+ opacity: value
402
+ }
403
+ }
404
+ }
405
+ break
406
+ case 'border':
407
+ if (value !== element.style?.border) {
408
+ element = {
409
+ ...element,
410
+ style: {
411
+ ...element.style,
412
+ border: value
413
+ }
414
+ }
415
+ }
416
+ break
417
+ default:
418
+ nonexhaustive(key)
382
419
  }
383
420
  }
421
+ if (element !== origin) {
422
+ hasChanges = true
423
+ return element
424
+ }
425
+ return origin
426
+ })
427
+ if (hasChanges) {
428
+ updateView({
429
+ ...view,
430
+ nodes
431
+ })
384
432
  }
433
+ // Trigger change event, even if there are no changes with local state
434
+ // but we maybe out of sync with the server
435
+ onChange?.({ change })
436
+ },
385
437
 
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
- }
395
- }
396
- const color = newColor.get(element.id)
397
- if (color && color !== element.color) {
398
- element = {
399
- ...element,
400
- color
401
- }
402
- }
438
+ cancelSaveManualLayout: () => {
439
+ let { viewSyncDebounceTimeout } = get()
440
+ if (viewSyncDebounceTimeout !== null) {
441
+ clearTimeout(viewSyncDebounceTimeout)
442
+ set({ viewSyncDebounceTimeout: null })
443
+ }
444
+ },
403
445
 
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
- }
446
+ triggerSaveManualLayout: (xystore) => {
447
+ let { viewSyncDebounceTimeout: debounced, onChange } = get()
448
+ if (!onChange) {
449
+ DEV && console.debug('ignore triggerSaveManualLayout, as no onChange handler is set')
450
+ return
451
+ }
452
+ if (debounced) {
453
+ clearTimeout(debounced)
454
+ }
455
+ debounced = setTimeout(() => {
456
+ const { nodeLookup } = xystore.getState()
457
+ const { xyflow, onChange } = get()
458
+ set({ viewSyncDebounceTimeout: null })
459
+
460
+ const movedNodes = new StringSet()
461
+ const nodes = reduce([...nodeLookup.values()], (acc, node) => {
462
+ const dimensions = getNodeDimensions(node)
463
+ if (!isSamePoint(node.internals.positionAbsolute, node.data.element.position)) {
464
+ movedNodes.add(node.id)
413
465
  }
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
- }
423
- }
466
+ acc[node.data.fqn] = {
467
+ x: node.internals.positionAbsolute.x,
468
+ y: node.internals.positionAbsolute.y,
469
+ width: dimensions.width,
470
+ height: dimensions.height
424
471
  }
425
-
426
- if (element === node.data.element) {
427
- return node
472
+ return acc
473
+ }, {} as Changes.SaveManualLayout['nodes'])
474
+ const edges = reduce(xyflow.getEdges(), (acc, { source, target, data }) => {
475
+ let controlPoints = data.controlPoints
476
+ // If edge control points are not set, but the source or target node was moved
477
+ if (!controlPoints && (movedNodes.has(source) || movedNodes.has(target))) {
478
+ controlPoints = bezierControlPoints(data.edge)
428
479
  }
429
- return {
430
- ...node,
431
- data: {
432
- ...node.data,
433
- element
480
+ if (controlPoints) {
481
+ acc[data.edge.id] = {
482
+ controlPoints
434
483
  }
435
484
  }
436
- })
485
+ return acc
486
+ }, {} as Changes.SaveManualLayout['edges'])
487
+ if (movedNodes.size === 0 && isEmpty(edges)) {
488
+ DEV && console.debug('ignore triggerSaveManualLayout, as no changes detected')
489
+ return
490
+ }
491
+
492
+ const change: Changes.SaveManualLayout = {
493
+ op: 'save-manual-layout',
494
+ nodes,
495
+ edges
496
+ }
497
+
498
+ if (DEV) {
499
+ console.debug('triggerSaveManualLayout', change)
500
+ }
501
+
502
+ onChange?.({ change })
503
+ }, 2000)
504
+ set(
505
+ {
506
+ viewSyncDebounceTimeout: debounced
507
+ },
508
+ noReplace,
509
+ 'debounce sync state'
437
510
  )
438
- get().onChange?.({ changes })
439
511
  },
440
512
 
441
513
  triggerOnNavigateTo: (xynodeId, event) => {
@@ -443,7 +515,6 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
443
515
  const xynode = xyflow.getInternalNode(xynodeId)
444
516
  const element = xynode?.data.element as DiagramNodeWithNavigate
445
517
  invariant(!!xynode && element?.navigateTo, `node is not navigable: ${xynodeId}`)
446
- const dimensions = getNodeDimensions(xynode)
447
518
  set(
448
519
  {
449
520
  lastClickedNodeId: xynodeId,
@@ -451,9 +522,9 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
451
522
  fromView: view.id,
452
523
  toView: element.navigateTo,
453
524
  element: element,
454
- elementCenterScreenPosition: xyflow.flowToScreenPosition({
455
- x: xynode.internals.positionAbsolute.x + dimensions.width / 2,
456
- y: xynode.internals.positionAbsolute.y + dimensions.height / 2
525
+ elementScreenPosition: xyflow.flowToScreenPosition({
526
+ x: xynode.internals.positionAbsolute.x, // + dimensions.width / 2,
527
+ y: xynode.internals.positionAbsolute.y // + dimensions.height / 2
457
528
  }),
458
529
  positionCorrected: false
459
530
  }
@@ -479,7 +550,7 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
479
550
  height: view.height
480
551
  }
481
552
  const viewport = getViewportForBounds(bounds, width, height, MinZoom, 1, fitViewPadding)
482
- panZoom?.setViewport(viewport, { duration: 400 })
553
+ panZoom?.setViewport(viewport, { duration: 500 })
483
554
  if (!!focusedNodeId) {
484
555
  set(
485
556
  {
@@ -496,6 +567,9 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
496
567
  const { view, activeDynamicViewStep, xyflow, fitViewPadding } = get()
497
568
  invariant(view.__ === 'dynamic', 'view is not dynamic')
498
569
  const nextStep = (activeDynamicViewStep ?? 0) + increment
570
+ if (nextStep <= 0 || nextStep > view.edges.length) {
571
+ return
572
+ }
499
573
  const edgeId = StepEdgeId(nextStep)
500
574
  const dimmed = new StringSet()
501
575
  let edge: XYFlowEdge | null = null
@@ -1,4 +1,3 @@
1
1
  export { DiagramContextProvider } from './DiagramContext'
2
2
  export type * from './diagramStore'
3
3
  export * from './useDiagramStore'
4
- export { WhenInitialized } from './WhenInitialized'
@@ -1,14 +1,14 @@
1
1
  import { shallowEqual } from 'fast-equals'
2
- import { useContext, useMemo } from 'react'
2
+ import { useContext } from 'react'
3
3
  import { useStoreWithEqualityFn as useZustandStore } from 'zustand/traditional'
4
4
 
5
- import { DiagramContext } from './DiagramContext'
5
+ import { DiagramContext, type DiagramZustandStore } from './DiagramContext'
6
6
  import type { DiagramState } from './diagramStore'
7
7
 
8
8
  function useDiagramState<StateSlice = unknown>(
9
9
  selector: (state: DiagramState) => StateSlice,
10
10
  equalityFn?: (a: StateSlice, b: StateSlice) => boolean
11
- ) {
11
+ ): StateSlice {
12
12
  const store = useContext(DiagramContext)
13
13
 
14
14
  if (store === null) {
@@ -18,22 +18,15 @@ function useDiagramState<StateSlice = unknown>(
18
18
  return useZustandStore(store, selector, equalityFn ?? shallowEqual)
19
19
  }
20
20
 
21
- function useDiagramStoreApi() {
21
+ export type DiagramStoreApi = Readonly<Pick<DiagramZustandStore, 'getState' | 'setState' | 'subscribe'>>
22
+ function useDiagramStoreApi(): DiagramStoreApi {
22
23
  const store = useContext(DiagramContext)
23
24
 
24
25
  if (store === null) {
25
26
  throw new Error('useDiagramStoreApi could be used only inside DiagramContext')
26
27
  }
27
28
 
28
- return useMemo(
29
- () => ({
30
- getState: store.getState,
31
- setState: store.setState,
32
- subscribe: store.subscribe
33
- }),
34
- [store]
35
- )
29
+ return store
36
30
  }
37
- export type DiagramStoreApi = ReturnType<typeof useDiagramStoreApi>
38
31
 
39
32
  export { useDiagramState, useDiagramStoreApi }
@@ -0,0 +1,39 @@
1
+ import { useStateHistory } from '@mantine/hooks'
2
+ import { useMemo } from 'react'
3
+ import { useUpdateEffect } from '../hooks/useUpdateEffect'
4
+ import { useDiagramState, useDiagramStoreApi } from './useDiagramStore'
5
+
6
+ export function useNavigationHistory() {
7
+ const store = useDiagramStoreApi()
8
+ const viewId = useDiagramState(s => s.view.id)
9
+
10
+ const [historyViewId, historyOps, {
11
+ history,
12
+ current: historyIndex
13
+ }] = useStateHistory(viewId)
14
+
15
+ const hasBack = historyIndex > 0
16
+ const hasForward = historyIndex < history.length - 1
17
+
18
+ useUpdateEffect(() => {
19
+ if (viewId !== historyViewId) {
20
+ historyOps.set(viewId)
21
+ }
22
+ }, [viewId])
23
+
24
+ useUpdateEffect(() => {
25
+ if (viewId !== historyViewId) {
26
+ store.getState().onNavigateTo?.(historyViewId)
27
+ }
28
+ }, [historyViewId])
29
+
30
+ return useMemo(() => ({
31
+ hasBack,
32
+ hasForward,
33
+ ops: historyOps
34
+ }), [
35
+ hasBack,
36
+ hasForward,
37
+ historyOps
38
+ ])
39
+ }
package/src/theme.css.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { createGlobalThemeContract } from '@vanilla-extract/css'
2
2
 
3
3
  export const vars = createGlobalThemeContract({
4
+ safariAnimationHook: 'safari-animation-hook',
4
5
  default: {
5
6
  font: 'default-font-family'
6
7
  },
7
- filterDimmed: 'filter-dimmed',
8
8
  likec4: {
9
9
  font: 'font-family',
10
10
  background: {
@@ -33,6 +33,10 @@ export const vars = createGlobalThemeContract({
33
33
  optionsPanel: {
34
34
  top: 'options-panel-top',
35
35
  right: 'options-panel-right'
36
+ },
37
+ navigationPanel: {
38
+ top: 'navigation-panel-top',
39
+ left: 'navigation-panel-left'
36
40
  }
37
41
  }, (value) => `likec4-${value}`)
38
42
 
@@ -0,0 +1,22 @@
1
+ import { fallbackVar, globalStyle, style } from '@vanilla-extract/css'
2
+ import { mantine } from '../mantine.css'
3
+ import { vars } from '../theme.css'
4
+
5
+ export const panel = style({
6
+ top: fallbackVar(vars.navigationPanel.top, '1rem'),
7
+ left: fallbackVar(vars.navigationPanel.left, '1rem'),
8
+ margin: 0
9
+ })
10
+
11
+ globalStyle(`${panel} .mantine-ActionIcon-root`, {
12
+ vars: {
13
+ ['--ai-size']: 'var(--ai-size-md)'
14
+ },
15
+ '@media': {
16
+ [mantine.largerThan('md')]: {
17
+ vars: {
18
+ ['--ai-size']: 'var(--ai-size-lg)'
19
+ }
20
+ }
21
+ }
22
+ })
@@ -0,0 +1,59 @@
1
+ import { ActionIcon, Group } from '@mantine/core'
2
+ import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'
3
+ import clsx from 'clsx'
4
+ import { AnimatePresence, m } from 'framer-motion'
5
+ import { useNavigationHistory } from '../state/useNavigationHistory'
6
+ import * as css from './BackwardForwardPanel.css'
7
+
8
+ export function BackwardForwardPanel() {
9
+ const { hasBack, hasForward, ops } = useNavigationHistory()
10
+
11
+ return (
12
+ <Group
13
+ className={clsx(
14
+ 'react-flow__panel',
15
+ css.panel,
16
+ 'likec4-navigation-panel'
17
+ )}
18
+ gap={'xs'}>
19
+ <AnimatePresence>
20
+ {hasBack && (
21
+ <m.div
22
+ layout
23
+ initial={{ opacity: 0.05, transform: 'translateX(-10px)' }}
24
+ animate={{ opacity: 1, transform: 'translateX(0)' }}
25
+ exit={{
26
+ opacity: 0.05,
27
+ transform: 'translateX(-10px)'
28
+ }}
29
+ key={'back'}>
30
+ <ActionIcon
31
+ variant="light"
32
+ color="gray"
33
+ onClick={() => ops.back()}>
34
+ <IconChevronLeft />
35
+ </ActionIcon>
36
+ </m.div>
37
+ )}
38
+ {hasForward && (
39
+ <m.div
40
+ layout
41
+ initial={{ opacity: 0.05, transform: 'translateX(10px)' }}
42
+ animate={{ opacity: 1, transform: 'translateX(0)' }}
43
+ exit={{
44
+ opacity: 0,
45
+ transform: 'translateX(10px)'
46
+ }}
47
+ key={'forward'}>
48
+ <ActionIcon
49
+ variant="light"
50
+ color="gray"
51
+ onClick={() => ops.forward()}>
52
+ <IconChevronRight />
53
+ </ActionIcon>
54
+ </m.div>
55
+ )}
56
+ </AnimatePresence>
57
+ </Group>
58
+ )
59
+ }