@likec4/diagram 1.3.0 → 1.5.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 (95) hide show
  1. package/dist/LikeC4Diagram.css.d.ts +0 -1
  2. package/dist/LikeC4Diagram.d.ts +1 -1
  3. package/dist/LikeC4Diagram.props.d.ts +7 -2
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.mjs +1580 -1194
  6. package/dist/state/DiagramContext.d.ts +97 -4169
  7. package/dist/state/diagramStore.d.ts +98 -4172
  8. package/dist/state/hooks.d.ts +4 -0
  9. package/dist/state/index.d.ts +1 -3
  10. package/dist/state/useDiagramStore.d.ts +3 -4622
  11. package/dist/state/useNavigationHistory.d.ts +5 -0
  12. package/dist/style.css +217 -159
  13. package/dist/theme.css.d.ts +5 -0
  14. package/dist/ui/BackwardForwardPanel.css.d.ts +1 -0
  15. package/dist/ui/BackwardForwardPanel.d.ts +1 -0
  16. package/dist/ui/DiagramTitlePanel.css.d.ts +1 -1
  17. package/dist/ui/OptionsPanel.d.ts +1 -3
  18. package/dist/ui/options/EdgeOptions.d.ts +19 -0
  19. package/dist/ui/options/NodeOptions.d.ts +2 -2
  20. package/dist/xyflow/EdgeMarkers.d.ts +11 -0
  21. package/dist/xyflow/XYFlowEvents.d.ts +12 -9
  22. package/dist/xyflow/XYFlowInner.d.ts +1 -0
  23. package/dist/xyflow/const.d.ts +3 -3
  24. package/dist/xyflow/diagram-to-xyflow.d.ts +1 -1
  25. package/dist/xyflow/edges/RelationshipEdge.d.ts +4 -3
  26. package/dist/xyflow/edges/edges.css.d.ts +1 -0
  27. package/dist/xyflow/edges/utils.d.ts +1 -0
  28. package/dist/xyflow/hooks/index.d.ts +3 -2
  29. package/dist/xyflow/hooks/useXYFlow.d.ts +22 -21
  30. package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +1 -0
  31. package/dist/xyflow/nodes/element/ElementIcon.d.ts +1 -4
  32. package/dist/xyflow/nodes/element/element.css.d.ts +3 -1
  33. package/dist/xyflow/types.d.ts +2 -32
  34. package/package.json +21 -21
  35. package/src/LikeC4Diagram.css.ts +10 -8
  36. package/src/LikeC4Diagram.props.ts +12 -5
  37. package/src/LikeC4Diagram.tsx +43 -26
  38. package/src/globals.css.ts +11 -4
  39. package/src/index.ts +1 -1
  40. package/src/mantine.css.ts +2 -2
  41. package/src/state/DiagramContext.tsx +47 -16
  42. package/src/state/diagramStore.ts +89 -80
  43. package/src/state/hooks.ts +5 -0
  44. package/src/state/index.ts +1 -3
  45. package/src/state/useDiagramStore.ts +6 -13
  46. package/src/state/useNavigationHistory.ts +39 -0
  47. package/src/theme.css.ts +5 -0
  48. package/src/ui/BackwardForwardPanel.css.ts +22 -0
  49. package/src/ui/BackwardForwardPanel.tsx +59 -0
  50. package/src/ui/DiagramTitlePanel.css.ts +17 -6
  51. package/src/ui/DiagramTitlePanel.tsx +108 -94
  52. package/src/ui/DynamicViewWalkthrough.tsx +6 -4
  53. package/src/ui/OptionsPanel.tsx +37 -18
  54. package/src/ui/options/EdgeOptions.tsx +157 -0
  55. package/src/ui/options/NodeOptions.tsx +37 -29
  56. package/src/xyflow/EdgeMarkers.tsx +148 -0
  57. package/src/xyflow/FitviewOnDiagramChange.tsx +8 -4
  58. package/src/xyflow/SelectEdgesOnNodeFocus.tsx +59 -55
  59. package/src/xyflow/SyncWithDiagram.tsx +13 -20
  60. package/src/xyflow/XYFlow.tsx +2 -4
  61. package/src/xyflow/XYFlowEvents.tsx +48 -33
  62. package/src/xyflow/XYFlowInner.tsx +6 -3
  63. package/src/xyflow/const.ts +3 -3
  64. package/src/xyflow/diagram-to-xyflow.ts +18 -53
  65. package/src/xyflow/edges/RelationshipEdge.tsx +218 -126
  66. package/src/xyflow/edges/edges.css.ts +44 -26
  67. package/src/xyflow/edges/utils.ts +10 -0
  68. package/src/xyflow/hooks/index.ts +4 -2
  69. package/src/xyflow/hooks/useLayoutConstraints.ts +5 -5
  70. package/src/xyflow/hooks/useXYFlow.ts +28 -1
  71. package/src/xyflow/nodes/compound/CompoundNode.css.ts +41 -31
  72. package/src/xyflow/nodes/compound/CompoundNode.tsx +52 -53
  73. package/src/xyflow/nodes/element/ElementIcon.tsx +11 -22
  74. package/src/xyflow/nodes/element/ElementLink.tsx +11 -16
  75. package/src/xyflow/nodes/element/ElementNode.tsx +69 -114
  76. package/src/xyflow/nodes/element/element.css.ts +48 -15
  77. package/src/xyflow/nodes/shared/NavigateToBtn.tsx +1 -1
  78. package/src/xyflow/types.ts +2 -4
  79. package/src/xyflow/utils.ts +3 -6
  80. package/dist/KeepAspectRatioContainer.d.ts +0 -9
  81. package/dist/icons/Clipboard.d.ts +0 -3
  82. package/dist/icons/Link.d.ts +0 -3
  83. package/dist/icons/ZoomIn.d.ts +0 -3
  84. package/dist/icons/index.d.ts +0 -3
  85. package/dist/mantine/theme.d.ts +0 -3
  86. package/dist/state/WhenInitialized.d.ts +0 -3
  87. package/dist/ui/Camera.d.ts +0 -3
  88. package/src/KeepAspectRatioContainer.tsx +0 -36
  89. package/src/icons/Clipboard.tsx +0 -24
  90. package/src/icons/Link.tsx +0 -23
  91. package/src/icons/ZoomIn.tsx +0 -25
  92. package/src/icons/index.ts +0 -3
  93. package/src/mantine/theme.ts +0 -20
  94. package/src/state/WhenInitialized.tsx +0 -24
  95. package/src/ui/Camera.tsx +0 -174
@@ -1,11 +1,11 @@
1
1
  import type { DiagramNode, DiagramView, Fqn, ViewID } from '@likec4/core'
2
- import { invariant, nonexhaustive, nonNullable, StepEdgeId } from '@likec4/core'
2
+ import { invariant, nonexhaustive, StepEdgeId } from '@likec4/core'
3
3
  import { getViewportForBounds, type XYPosition } from '@xyflow/react'
4
- import { getInternalNodesBounds, getNodeDimensions } from '@xyflow/system'
4
+ import { getNodeDimensions } from '@xyflow/system'
5
5
  import { DEV } from 'esm-env'
6
6
  import { deepEqual, shallowEqual } from 'fast-equals'
7
7
  import type { MouseEvent as ReactMouseEvent } from 'react'
8
- import { debounce, entries, mapToObj, reduce } from 'remeda'
8
+ import { entries, isEmpty, reduce } from 'remeda'
9
9
  import type { Exact, Except, RequiredKeysOf, Simplify } from 'type-fest'
10
10
  import { devtools, subscribeWithSelector } from 'zustand/middleware'
11
11
  import { shallow } from 'zustand/shallow'
@@ -17,6 +17,7 @@ import type { XYFlowEdge, XYFlowInstance, XYFlowNode } from '../xyflow/types'
17
17
  import { bezierControlPoints, isSamePoint } from '../xyflow/utils'
18
18
 
19
19
  export type DiagramStore = {
20
+ readonly storeDevId: string
20
21
  // Incoming props
21
22
  view: DiagramView
22
23
  readonly: boolean
@@ -28,12 +29,14 @@ export type DiagramStore = {
28
29
  nodesDraggable: boolean
29
30
  nodesSelectable: boolean
30
31
  experimentalEdgeEditing: boolean
32
+ // Diagram Container, see DiagramContainer.tsx
33
+ getContainer: () => HTMLDivElement | null
31
34
 
32
35
  // Internal state
33
- viewSyncState: 'synced' | 'changed'
34
36
  viewSyncDebounceTimeout: number | null
35
37
 
36
38
  xyflow: XYFlowInstance
39
+ xystore: XYStoreApi
37
40
  initialized: boolean
38
41
  xyflowSynced: boolean
39
42
 
@@ -83,13 +86,6 @@ export type DiagramInitialState = // Required properties
83
86
  | 'nodesSelectable'
84
87
  | 'experimentalEdgeEditing'
85
88
  >
86
- // Optional properties
87
- // & Partial<
88
- // Pick<
89
- // DiagramStore,
90
- // 'colorScheme'
91
- // >
92
- // >
93
89
  & LikeC4DiagramEventHandlers
94
90
 
95
91
  interface DiagramStoreActions {
@@ -109,9 +105,11 @@ interface DiagramStoreActions {
109
105
 
110
106
  getElement(id: Fqn): DiagramNode | null
111
107
  triggerChangeElementStyle: (change: Changes.ChangeElementStyle) => void
112
- triggerSaveManualLayout: (xystore: XYStoreApi) => void
108
+
109
+ cancelSaveManualLayout: () => void
110
+ triggerSaveManualLayout: () => void
113
111
  triggerOnNavigateTo: (xynodeId: string, event: ReactMouseEvent) => void
114
- fitDiagram: (xyStore: XYStoreApi) => void
112
+ fitDiagram: (duration?: number) => void
115
113
 
116
114
  nextDynamicStep: (increment?: number) => void
117
115
  stopDynamicView: () => void
@@ -119,8 +117,10 @@ interface DiagramStoreActions {
119
117
 
120
118
  export type DiagramState = Simplify<DiagramStore & DiagramStoreActions>
121
119
 
122
- const DEFAULT_PROPS: Except<DiagramStore, RequiredKeysOf<DiagramInitialState> | 'xyflow'> = {
123
- viewSyncState: 'synced',
120
+ const DEFAULT_PROPS: Except<
121
+ DiagramStore,
122
+ RequiredKeysOf<DiagramInitialState> | 'xystore' | 'xyflow' | 'getContainer' | 'storeDevId'
123
+ > = {
124
124
  viewSyncDebounceTimeout: null,
125
125
  initialized: false,
126
126
  xyflowSynced: false,
@@ -145,15 +145,16 @@ const DEFAULT_PROPS: Except<DiagramStore, RequiredKeysOf<DiagramInitialState> |
145
145
  onCanvasDblClick: null
146
146
  }
147
147
 
148
- type CreateDiagramStore = DiagramInitialState & Pick<DiagramStore, 'xyflow'>
148
+ export type CreateDiagramStore = DiagramInitialState & Pick<DiagramStore, 'xystore' | 'xyflow' | 'getContainer'>
149
149
 
150
150
  const noReplace = false
151
151
 
152
- let storeDevId = 1
152
+ let StoreDevId = 1
153
153
 
154
154
  const EmptyStringSet: ReadonlySet<string> = new StringSet()
155
155
 
156
156
  export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props: T) {
157
+ const storeDevId = 'DiagramStore' + String(StoreDevId++).padStart(2, '0')
157
158
  return createWithEqualityFn<
158
159
  DiagramState,
159
160
  [
@@ -164,9 +165,9 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
164
165
  subscribeWithSelector(
165
166
  devtools(
166
167
  (set, get) => ({
168
+ storeDevId,
167
169
  ...DEFAULT_PROPS,
168
170
  ...(props as CreateDiagramStore),
169
-
170
171
  isDynamicView: () => {
171
172
  return get().view?.__ === 'dynamic'
172
173
  },
@@ -174,7 +175,6 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
174
175
  updateView: (nextView) => {
175
176
  let {
176
177
  viewSyncDebounceTimeout,
177
- viewSyncState,
178
178
  xyflow,
179
179
  dimmed,
180
180
  xyflowSynced,
@@ -189,26 +189,16 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
189
189
  hoveredNodeId
190
190
  } = get()
191
191
 
192
- if (viewSyncDebounceTimeout) {
193
- clearTimeout(viewSyncDebounceTimeout)
194
- viewSyncDebounceTimeout = null
195
- }
196
-
197
192
  if (shallowEqual(current, nextView)) {
198
- if (!xyflowSynced || viewSyncState === 'changed') {
199
- set(
200
- {
201
- viewSyncState: 'synced',
202
- xyflowSynced: true
203
- },
204
- noReplace,
205
- 'updateView: xyflow synced'
206
- )
207
- }
208
193
  DEV && console.debug('store: skip updateView')
209
194
  return
210
195
  }
211
196
 
197
+ if (viewSyncDebounceTimeout !== null) {
198
+ clearTimeout(viewSyncDebounceTimeout)
199
+ viewSyncDebounceTimeout = null
200
+ }
201
+
212
202
  const isSameView = current.id === nextView.id
213
203
 
214
204
  if (isSameView) {
@@ -272,7 +262,6 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
272
262
 
273
263
  set(
274
264
  {
275
- viewSyncState: 'synced',
276
265
  viewSyncDebounceTimeout,
277
266
  view: nextView,
278
267
  activeDynamicViewStep,
@@ -287,7 +276,7 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
287
276
  dimmed
288
277
  },
289
278
  noReplace,
290
- 'update-view'
279
+ isSameView ? 'update-view [same]' : 'update-view [another]'
291
280
  )
292
281
  },
293
282
 
@@ -324,25 +313,25 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
324
313
 
325
314
  setHoveredNode: (nodeId) => {
326
315
  if (nodeId !== get().hoveredNodeId) {
327
- set({ hoveredNodeId: nodeId }, noReplace, nodeId ? 'setHoveredNode' : 'unsetHoveredNode')
316
+ set({ hoveredNodeId: nodeId })
328
317
  }
329
318
  },
330
319
 
331
320
  setHoveredEdge: (edgeId) => {
332
321
  if (edgeId !== get().hoveredEdgeId) {
333
- set({ hoveredEdgeId: edgeId }, noReplace, edgeId ? 'setHoveredEdge' : 'unsetHoveredEdge')
322
+ set({ hoveredEdgeId: edgeId })
334
323
  }
335
324
  },
336
325
 
337
326
  setLastClickedNode: (nodeId) => {
338
327
  if (nodeId !== get().lastClickedNodeId) {
339
- set({ lastClickedNodeId: nodeId }, noReplace, nodeId ? 'setLastClickedNode' : 'unsetLastClickedNode')
328
+ set({ lastClickedNodeId: nodeId })
340
329
  }
341
330
  },
342
331
 
343
332
  setLastClickedEdge: (edgeId) => {
344
333
  if (edgeId !== get().lastClickedEdgeId) {
345
- set({ lastClickedEdgeId: edgeId }, noReplace, edgeId ? 'setLastClickedEdge' : 'unsetLastClickedEdge')
334
+ set({ lastClickedEdgeId: edgeId })
346
335
  }
347
336
  },
348
337
 
@@ -386,7 +375,7 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
386
375
  return origin
387
376
  }
388
377
  let element = origin
389
- for (const [key, value] of entries.strict(change.style)) {
378
+ for (const [key, value] of entries(change.style)) {
390
379
  switch (key) {
391
380
  case 'shape':
392
381
  if (value !== element.shape) {
@@ -441,16 +430,21 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
441
430
  ...view,
442
431
  nodes
443
432
  })
444
- if (onChange) {
445
- set({ viewSyncState: 'changed' }, noReplace, 'change sync state')
446
- }
447
433
  }
448
434
  // Trigger change event, even if there are no changes with local state
449
435
  // but we maybe out of sync with the server
450
436
  onChange?.({ change })
451
437
  },
452
438
 
453
- triggerSaveManualLayout: (xystore) => {
439
+ cancelSaveManualLayout: () => {
440
+ let { viewSyncDebounceTimeout } = get()
441
+ if (viewSyncDebounceTimeout !== null) {
442
+ clearTimeout(viewSyncDebounceTimeout)
443
+ set({ viewSyncDebounceTimeout: null })
444
+ }
445
+ },
446
+
447
+ triggerSaveManualLayout: () => {
454
448
  let { viewSyncDebounceTimeout: debounced, onChange } = get()
455
449
  if (!onChange) {
456
450
  DEV && console.debug('ignore triggerSaveManualLayout, as no onChange handler is set')
@@ -460,8 +454,9 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
460
454
  clearTimeout(debounced)
461
455
  }
462
456
  debounced = setTimeout(() => {
457
+ const { xyflow, onChange, xystore } = get()
463
458
  const { nodeLookup } = xystore.getState()
464
- const { xyflow, onChange } = get()
459
+ set({ viewSyncDebounceTimeout: null })
465
460
 
466
461
  const movedNodes = new StringSet()
467
462
  const nodes = reduce([...nodeLookup.values()], (acc, node) => {
@@ -490,6 +485,10 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
490
485
  }
491
486
  return acc
492
487
  }, {} as Changes.SaveManualLayout['edges'])
488
+ if (movedNodes.size === 0 && isEmpty(edges)) {
489
+ DEV && console.debug('ignore triggerSaveManualLayout, as no changes detected')
490
+ return
491
+ }
493
492
 
494
493
  const change: Changes.SaveManualLayout = {
495
494
  op: 'save-manual-layout',
@@ -500,24 +499,24 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
500
499
  if (DEV) {
501
500
  console.debug('triggerSaveManualLayout', change)
502
501
  }
503
- set({ viewSyncDebounceTimeout: null }, noReplace, 'clear debounce timeout')
502
+
504
503
  onChange?.({ change })
505
- }, 5000)
504
+ }, 2000) as any as number // explicit typecast to number to suppress TS error in astro build
506
505
  set(
507
506
  {
508
- viewSyncDebounceTimeout: debounced,
509
- viewSyncState: 'changed'
507
+ viewSyncDebounceTimeout: debounced
510
508
  },
511
509
  noReplace,
512
- 'change sync state'
510
+ 'debounce sync state'
513
511
  )
514
512
  },
515
513
 
516
514
  triggerOnNavigateTo: (xynodeId, event) => {
517
515
  const { xyflow, view } = get()
518
516
  const xynode = xyflow.getInternalNode(xynodeId)
519
- const element = xynode?.data.element as DiagramNodeWithNavigate
520
- invariant(!!xynode && element?.navigateTo, `node is not navigable: ${xynodeId}`)
517
+ invariant(!!xynode, `node not found: ${xynodeId}`)
518
+ const element = xynode.data.element as DiagramNodeWithNavigate
519
+ invariant(element.navigateTo, `node is not navigable: ${xynodeId}`)
521
520
  set(
522
521
  {
523
522
  lastClickedNodeId: xynodeId,
@@ -543,20 +542,23 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
543
542
  )
544
543
  },
545
544
 
546
- fitDiagram: (xyStore) => {
547
- const { width, height, panZoom } = xyStore.getState()
548
- const { fitViewPadding, view, focusedNodeId } = get()
545
+ fitDiagram: (duration = 500) => {
546
+ const { fitViewPadding, view, focusedNodeId, activeDynamicViewStep, xystore } = get()
547
+ const { width, height, panZoom, transform } = xystore.getState()
548
+
549
549
  const bounds = {
550
550
  x: 0,
551
551
  y: 0,
552
552
  width: view.width,
553
553
  height: view.height
554
554
  }
555
- const viewport = getViewportForBounds(bounds, width, height, MinZoom, 1, fitViewPadding)
556
- panZoom?.setViewport(viewport, { duration: 500 })
557
- if (!!focusedNodeId) {
555
+ const maxZoom = Math.max(1, transform[2])
556
+ const viewport = getViewportForBounds(bounds, width, height, MinZoom, maxZoom, fitViewPadding)
557
+ panZoom?.setViewport(viewport, { duration })
558
+ if ((focusedNodeId ?? activeDynamicViewStep) !== null) {
558
559
  set(
559
560
  {
561
+ activeDynamicViewStep: null,
560
562
  focusedNodeId: null,
561
563
  dimmed: EmptyStringSet
562
564
  },
@@ -567,7 +569,7 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
567
569
  },
568
570
 
569
571
  nextDynamicStep: (increment = 1) => {
570
- const { view, activeDynamicViewStep, xyflow, fitViewPadding } = get()
572
+ const { view, activeDynamicViewStep, xyflow, xystore, fitViewPadding } = get()
571
573
  invariant(view.__ === 'dynamic', 'view is not dynamic')
572
574
  const nextStep = (activeDynamicViewStep ?? 0) + increment
573
575
  if (nextStep <= 0 || nextStep > view.edges.length) {
@@ -592,36 +594,43 @@ export function createDiagramStore<T extends Exact<CreateDiagramStore, T>>(props
592
594
  }
593
595
  dimmed.add(n.id)
594
596
  }
595
- xyflow.fitView({
597
+ const { fitView, transform } = xystore.getState()
598
+ fitView({
596
599
  duration: 400,
597
- maxZoom: 1,
600
+ includeHiddenNodes: true,
601
+ maxZoom: Math.max(1, transform[2]),
598
602
  minZoom: MinZoom,
599
- padding: fitViewPadding,
603
+ padding: Math.max(fitViewPadding, 0.2),
600
604
  nodes: selected
601
605
  })
602
- set({
603
- focusedNodeId: null,
604
- activeDynamicViewStep: nextStep,
605
- dimmed
606
- })
606
+ set(
607
+ {
608
+ focusedNodeId: null,
609
+ activeDynamicViewStep: nextStep,
610
+ dimmed
611
+ },
612
+ noReplace,
613
+ 'nextDynamicStep'
614
+ )
607
615
  },
608
616
 
609
617
  stopDynamicView: () => {
610
- const { xyflow, fitViewPadding } = get()
611
- set({
612
- activeDynamicViewStep: null,
613
- dimmed: EmptyStringSet
614
- })
615
- xyflow.fitView({
616
- duration: 400,
617
- maxZoom: 1,
618
- minZoom: MinZoom,
619
- padding: fitViewPadding
620
- })
618
+ if (get().activeDynamicViewStep !== null) {
619
+ set(
620
+ {
621
+ activeDynamicViewStep: null,
622
+ focusedNodeId: null,
623
+ dimmed: EmptyStringSet
624
+ },
625
+ noReplace,
626
+ 'stopDynamicView'
627
+ )
628
+ get().fitDiagram()
629
+ }
621
630
  }
622
631
  }),
623
632
  {
624
- name: `DiagramStore ${storeDevId++} - ${props.view.id}`,
633
+ name: `${storeDevId} - ${props.view.id}`,
625
634
  enabled: DEV
626
635
  }
627
636
  )
@@ -0,0 +1,5 @@
1
+ export { useDiagramState, useDiagramStoreApi } from './useDiagramStore'
2
+ export { useNavigationHistory } from './useNavigationHistory'
3
+
4
+ export type * from './diagramStore'
5
+ export type * from './useDiagramStore'
@@ -1,4 +1,2 @@
1
- export { DiagramContextProvider } from './DiagramContext'
2
1
  export type * from './diagramStore'
3
- export * from './useDiagramStore'
4
- export { WhenInitialized } from './WhenInitialized'
2
+ export type * from './useDiagramStore'
@@ -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,6 +1,7 @@
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
  },
@@ -32,6 +33,10 @@ export const vars = createGlobalThemeContract({
32
33
  optionsPanel: {
33
34
  top: 'options-panel-top',
34
35
  right: 'options-panel-right'
36
+ },
37
+ navigationPanel: {
38
+ top: 'navigation-panel-top',
39
+ left: 'navigation-panel-left'
35
40
  }
36
41
  }, (value) => `likec4-${value}`)
37
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, '0.8rem'),
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-lg)'
14
+ }
15
+ // '@media': {
16
+ // [mantine.largerThan('lg')]: {
17
+ // vars: {
18
+ // ['--ai-size']: 'var(--ai-size-xl)'
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/hooks'
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
+ }
@@ -1,5 +1,6 @@
1
- import { globalStyle, style } from '@vanilla-extract/css'
1
+ import { fallbackVar, globalStyle, style } from '@vanilla-extract/css'
2
2
  import { mantine } from '../mantine.css'
3
+ import { vars } from '../theme.css'
3
4
 
4
5
  export const container = style({
5
6
  bottom: 0,
@@ -8,16 +9,26 @@ export const container = style({
8
9
  margin: 0
9
10
  })
10
11
 
11
- export const paper = style({
12
+ export const card = style({
12
13
  cursor: 'default',
13
- minWidth: 100,
14
+ minWidth: 200,
14
15
  maxWidth: 'calc(100vw - 16px)',
15
16
  backgroundColor: `color-mix(in srgb, ${mantine.colors.body}, transparent 20%)`,
16
- WebkitBackdropFilter: 'blur(8px)',
17
- backdropFilter: 'blur(8px)',
17
+ WebkitBackdropFilter: fallbackVar(vars.safariAnimationHook, 'blur(8px)'),
18
+ backdropFilter: fallbackVar(vars.safariAnimationHook, 'blur(8px)'),
18
19
  '@media': {
20
+ [mantine.largerThan('sm')]: {
21
+ minWidth: 300,
22
+ maxWidth: '90vw'
23
+ },
19
24
  [mantine.largerThan('md')]: {
20
- maxWidth: '45vw'
25
+ minWidth: 400,
26
+ maxWidth: '70vw'
27
+ }
28
+ },
29
+ selectors: {
30
+ [`:where([data-mantine-color-scheme='dark']) &`]: {
31
+ backgroundColor: `color-mix(in srgb, ${mantine.colors.dark[6]}, transparent 20%)`
21
32
  }
22
33
  }
23
34
  })