@likec4/diagram 1.12.2 → 1.13.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/package.json +8 -8
  2. package/src/LikeC4CustomColors.tsx +2 -2
  3. package/src/LikeC4Diagram.props.ts +15 -7
  4. package/src/LikeC4Diagram.tsx +23 -14
  5. package/src/StaticLikeC4Diagram.tsx +2 -0
  6. package/src/components/Overlay.css.ts +78 -0
  7. package/src/components/Overlay.tsx +102 -0
  8. package/src/components/OverlayContext.tsx +11 -0
  9. package/src/components/index.ts +2 -0
  10. package/src/likec4model/index.ts +1 -0
  11. package/src/likec4model/useLikeC4ElementsTree.ts +44 -0
  12. package/src/overlays/edge-details/EdgeDetailsOverlay.css.ts +13 -0
  13. package/src/overlays/edge-details/EdgeDetailsOverlay.tsx +37 -0
  14. package/src/overlays/index.tsx +33 -0
  15. package/src/overlays/relationships-of/RelationshipsOverlay.tsx +33 -0
  16. package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +359 -0
  17. package/src/overlays/relationships-of/SelectElement.css.ts +18 -0
  18. package/src/overlays/relationships-of/SelectElement.tsx +133 -0
  19. package/src/overlays/relationships-of/_types.ts +56 -0
  20. package/src/overlays/relationships-of/styles.css.ts +34 -0
  21. package/src/overlays/relationships-of/use-layouted-relationships.ts +607 -0
  22. package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +76 -0
  23. package/src/overlays/relationships-of/xyflow/ElementNode.tsx +130 -0
  24. package/src/overlays/relationships-of/xyflow/EmptyNode.tsx +22 -0
  25. package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +59 -0
  26. package/src/overlays/relationships-of/xyflow/styles.css.ts +170 -0
  27. package/src/state/diagram-to-xyflow.ts +1 -1
  28. package/src/state/diagramStore.ts +119 -27
  29. package/src/ui/top-left/ChangeAutoLayoutButton.tsx +1 -1
  30. package/src/ui/top-left/ManualLayoutToolsButton.tsx +110 -0
  31. package/src/ui/top-left/TopLeftPanel.tsx +15 -31
  32. package/src/ui/top-left/_shared.ts +2 -2
  33. package/src/xyflow/EdgeMarkers.tsx +19 -0
  34. package/src/xyflow/XYFlowEvents.tsx +21 -24
  35. package/src/xyflow/XYFlowInner.tsx +6 -10
  36. package/src/xyflow/edges/EdgeLabel.tsx +6 -5
  37. package/src/xyflow/edges/RelationshipEdge.tsx +4 -3
  38. package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +16 -0
  39. package/src/xyflow/edges/edges.css.ts +5 -11
  40. package/src/xyflow/nodes/compound/CompoundNode.css.ts +17 -40
  41. package/src/xyflow/nodes/compound/CompoundNode.tsx +219 -28
  42. package/src/xyflow/nodes/element/ElementLink.tsx +11 -8
  43. package/src/xyflow/nodes/element/ElementNode.tsx +211 -140
  44. package/src/xyflow/nodes/element/element.css.ts +22 -33
  45. package/src/xyflow/nodes/shared/Toolbar.tsx +81 -27
  46. package/src/xyflow/nodes/use-animate-variants.tsx +64 -0
  47. package/src/xyflow/useLayoutConstraints.ts +95 -125
  48. package/src/xyflow/utils.ts +4 -0
  49. package/src/xyflow/nodes/shared/NavigateToBtn.tsx +0 -32
@@ -0,0 +1,130 @@
1
+ import { ActionIcon, Box, Text as MantineText } from '@mantine/core'
2
+ import { IconZoomScan } from '@tabler/icons-react'
3
+ import { Handle, type NodeProps, Position } from '@xyflow/react'
4
+ import clsx from 'clsx'
5
+ import { m } from 'framer-motion'
6
+ import { useOverlayDialog } from '../../../components'
7
+ import { type DiagramState, useDiagramState } from '../../../hooks'
8
+ import { ElementShapeSvg } from '../../../xyflow/nodes/element/ElementShapeSvg'
9
+ import { stopPropagation } from '../../../xyflow/utils'
10
+ import type { XYFlowTypes } from '../_types'
11
+ import * as css from './styles.css'
12
+
13
+ const Text = MantineText.withProps({
14
+ component: 'div'
15
+ })
16
+
17
+ type ElementNodeProps = NodeProps<XYFlowTypes.ElementNode>
18
+
19
+ function selector(s: DiagramState) {
20
+ return {
21
+ viewId: s.view.id,
22
+ onNavigateTo: s.onNavigateTo
23
+ }
24
+ }
25
+
26
+ export function ElementNode({
27
+ data: {
28
+ element,
29
+ ports,
30
+ navigateTo,
31
+ ...data
32
+ },
33
+ selectable = true,
34
+ width: w = 100,
35
+ height: h = 100
36
+ }: ElementNodeProps) {
37
+ const overlay = useOverlayDialog()
38
+ const {
39
+ viewId,
40
+ onNavigateTo
41
+ } = useDiagramState(selector)
42
+ return (
43
+ <>
44
+ <m.div
45
+ className={clsx([
46
+ css.elementNode,
47
+ 'likec4-element-node'
48
+ ])}
49
+ data-likec4-color={element.color}
50
+ animate={{
51
+ opacity: data.dimmed ? 0.15 : 1,
52
+ transition: {
53
+ delay: data.dimmed ? .8 : 0
54
+ }
55
+ }}
56
+ {...(selectable && {
57
+ whileHover: {
58
+ scale: 1.045,
59
+ transition: {
60
+ delay: 0.15
61
+ }
62
+ },
63
+ whileTap: {
64
+ scale: 0.97
65
+ }
66
+ })}
67
+ >
68
+ <svg
69
+ className={clsx(
70
+ css.cssShapeSvg
71
+ )}
72
+ viewBox={`0 0 ${w} ${h}`}
73
+ width={w}
74
+ height={h}
75
+ >
76
+ <ElementShapeSvg
77
+ shape={element.shape}
78
+ w={w}
79
+ h={h} />
80
+ </svg>
81
+ <Box className={css.elementNodeContent}>
82
+ <Text className={css.elementNodeTitle} lineClamp={2}>{element.title}</Text>
83
+ {element.description && (
84
+ <Text className={css.elementNodeDescription} lineClamp={4}>{element.description}</Text>
85
+ )}
86
+ </Box>
87
+ {navigateTo && onNavigateTo && navigateTo !== viewId && (
88
+ <Box className={css.navigateBtnBox}>
89
+ <ActionIcon
90
+ className={clsx('nodrag nopan', css.navigateBtn)}
91
+ radius="md"
92
+ onClick={(event) => {
93
+ event.stopPropagation()
94
+ setTimeout(() => onNavigateTo(navigateTo), 200)
95
+ overlay.close()
96
+ }}
97
+ role="button"
98
+ onDoubleClick={stopPropagation}
99
+ onPointerDownCapture={stopPropagation}
100
+ >
101
+ <IconZoomScan stroke={1.8} style={{ width: '75%' }} />
102
+ </ActionIcon>
103
+ </Box>
104
+ )}
105
+ </m.div>
106
+ {ports.left.map(({ id, type }, i) => (
107
+ <Handle
108
+ key={id}
109
+ id={id}
110
+ type={type === 'in' ? 'target' : 'source'}
111
+ position={Position.Left}
112
+ style={{
113
+ visibility: 'hidden',
114
+ top: `${15 + (i + 1) * ((h - 30) / (ports.left.length + 1))}px`
115
+ }} />
116
+ ))}
117
+ {ports.right.map(({ id, type }, i) => (
118
+ <Handle
119
+ key={id}
120
+ id={id}
121
+ type={type === 'in' ? 'target' : 'source'}
122
+ position={Position.Right}
123
+ style={{
124
+ visibility: 'hidden',
125
+ top: `${15 + (i + 1) * ((h - 30) / (ports.right.length + 1))}px`
126
+ }} />
127
+ ))}
128
+ </>
129
+ )
130
+ }
@@ -0,0 +1,22 @@
1
+ import { Box, Text as MantineText } from '@mantine/core'
2
+ import { type NodeProps } from '@xyflow/react'
3
+ import type { XYFlowTypes } from '../_types'
4
+ import * as css from './styles.css'
5
+
6
+ const Text = MantineText.withProps({
7
+ component: 'div'
8
+ })
9
+
10
+ type EmptyNodeProps = NodeProps<XYFlowTypes.EmptyNode>
11
+
12
+ export function EmptyNode({
13
+ data: {
14
+ column
15
+ }
16
+ }: EmptyNodeProps) {
17
+ return (
18
+ <Box className={css.emptyNode}>
19
+ <Text c={'dimmed'} fw={500}>No {column === 'incomers' ? 'incoming' : 'outgoing'}</Text>
20
+ </Box>
21
+ )
22
+ }
@@ -0,0 +1,59 @@
1
+ import { Group, Text, ThemeIcon } from '@mantine/core'
2
+ import { IconBoxMultipleFilled } from '@tabler/icons-react'
3
+ import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getBezierPath } from '@xyflow/react'
4
+ import clsx from 'clsx'
5
+ import type { XYFlowTypes } from '../_types'
6
+ import { ZIndexes } from '../use-layouted-relationships'
7
+ import * as css from './styles.css'
8
+
9
+ export function RelationshipEdge({
10
+ data,
11
+ label,
12
+ ...props
13
+ }: EdgeProps<XYFlowTypes.Edge>) {
14
+ const [edgePath, labelX, labelY] = getBezierPath(props)
15
+ const isMultiRelation = data.relations.length > 1
16
+ return (
17
+ <>
18
+ <g
19
+ className={css.edgePath}
20
+ data-edge-dimmed={data.dimmed}
21
+ data-edge-hovered={data.hovered}
22
+ >
23
+ <BaseEdge
24
+ path={edgePath}
25
+ {...props}
26
+ />
27
+ </g>
28
+ {label && (
29
+ <EdgeLabelRenderer>
30
+ <div
31
+ style={{
32
+ position: 'absolute',
33
+ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
34
+ maxWidth: Math.abs(props.targetX - props.sourceX - 40),
35
+ zIndex: ZIndexes.max
36
+ }}
37
+ className={clsx([
38
+ css.edgeLabel,
39
+ 'nodrag nopan'
40
+ ])}
41
+ data-edge-dimmed={data.dimmed}
42
+ data-edge-hovered={data.hovered}
43
+ >
44
+ <Group gap={6} wrap="nowrap">
45
+ {isMultiRelation && (
46
+ <ThemeIcon size={'sm'} variant="transparent" color="orange">
47
+ <IconBoxMultipleFilled style={{ width: '80%' }} />
48
+ </ThemeIcon>
49
+ )}
50
+ <Text fw={isMultiRelation ? '500' : '400'} component={'div'} className={css.edgeLabelText} lineClamp={3}>
51
+ {label}
52
+ </Text>
53
+ </Group>
54
+ </div>
55
+ </EdgeLabelRenderer>
56
+ )}
57
+ </>
58
+ )
59
+ }
@@ -0,0 +1,170 @@
1
+ import { style } from '@vanilla-extract/css'
2
+ import { mantine, vars, whereLight, xyvars } from '../../../theme-vars'
3
+
4
+ export const elementNode = style({
5
+ width: '100%',
6
+ height: '100%'
7
+ })
8
+
9
+ export const elementNodeContent = style({
10
+ width: '100%',
11
+ height: '100%',
12
+ display: 'flex',
13
+ flexDirection: 'column',
14
+ justifyContent: 'center',
15
+ alignItems: 'center',
16
+ gap: 6,
17
+ padding: 16
18
+ })
19
+
20
+ export const elementNodeTitle = style({
21
+ flex: '0 0 auto',
22
+ width: 'fit-content',
23
+ textAlign: 'center',
24
+ fontFamily: vars.element.font,
25
+ fontOpticalSizing: 'auto',
26
+ fontStyle: 'normal',
27
+ fontWeight: 500,
28
+ fontSize: 17,
29
+ lineHeight: 1.25,
30
+ textWrap: 'balance',
31
+ color: vars.element.hiContrast
32
+ })
33
+
34
+ export const elementNodeDescription = style({
35
+ flex: '0 0 auto',
36
+ width: 'fit-content',
37
+ textAlign: 'center',
38
+ fontFamily: vars.element.font,
39
+ fontOpticalSizing: 'auto',
40
+ fontStyle: 'normal',
41
+ fontSize: mantine.fontSizes.xs,
42
+ lineHeight: 1.25,
43
+ textWrap: 'balance',
44
+ color: vars.element.loContrast
45
+ })
46
+
47
+ export const compoundNodeBody = style({
48
+ width: '100%',
49
+ height: '100%',
50
+ boxShadow: '0 4px 10px 0.5px rgba(0,0,0,0.1) , 0 2px 2px -1px rgba(0,0,0,0.4)',
51
+ background: `color-mix(in srgb , ${vars.element.fill}, transparent 45%)`,
52
+ borderRadius: 4
53
+ })
54
+
55
+ export const compoundNodeTitle = style({
56
+ fontFamily: vars.compound.font,
57
+ fontOpticalSizing: 'auto',
58
+ fontStyle: 'normal',
59
+ fontWeight: 600,
60
+ fontSize: 14,
61
+ lineHeight: 1,
62
+ textTransform: 'uppercase',
63
+ paddingTop: 12,
64
+ paddingLeft: 10,
65
+ mixBlendMode: 'screen',
66
+ color: vars.compound.titleColor,
67
+ selectors: {
68
+ [`${whereLight} &`]: {
69
+ mixBlendMode: 'darken',
70
+ color: vars.element.stroke
71
+ }
72
+ }
73
+ })
74
+
75
+ export const cssShapeSvg = style({
76
+ top: 0,
77
+ left: 0,
78
+ position: 'absolute',
79
+ pointerEvents: 'none',
80
+ fill: vars.element.fill,
81
+ stroke: vars.element.stroke,
82
+ overflow: 'visible',
83
+ filter: `
84
+ drop-shadow(0 2px 1px rgba(0, 0, 0, 0.21))
85
+ drop-shadow(0 1px 1px color-mix(in srgb, ${vars.element.stroke} 40%, transparent))
86
+ drop-shadow(0 5px 3px rgba(0, 0, 0, 0.1))
87
+ `,
88
+ zIndex: -1
89
+ })
90
+
91
+ export const edgePath = style({
92
+ opacity: 1,
93
+ transition: 'all 0.2s ease-in-out',
94
+
95
+ selectors: {
96
+ [`&[data-edge-dimmed='true']`]: {
97
+ opacity: 0.1,
98
+ transitionDelay: '800ms'
99
+ }
100
+ }
101
+ })
102
+
103
+ export const edgeLabel = style({
104
+ padding: '2px 5px',
105
+ fontFamily: vars.likec4.font,
106
+ position: 'absolute',
107
+ cursor: 'pointer',
108
+ width: 'fit-content',
109
+ color: xyvars.edge.labelColor,
110
+ backgroundColor: xyvars.edge.labelBgColor,
111
+ borderRadius: 4,
112
+ // everything inside EdgeLabelRenderer has no pointer events by default
113
+ // if you have an interactive element, set pointer-events: all
114
+ pointerEvents: 'all',
115
+ textWrap: 'pretty',
116
+ whiteSpace: 'preserve-breaks',
117
+ opacity: 1,
118
+ transition: 'all 0.2s ease-in-out',
119
+ selectors: {
120
+ [`&[data-edge-dimmed='true']`]: {
121
+ opacity: 0.1,
122
+ // transitionDuration: '400ms',
123
+ transitionDelay: '800ms'
124
+ }
125
+ }
126
+ })
127
+
128
+ export const edgeLabelText = style({
129
+ textAlign: 'center',
130
+ whiteSpaceCollapse: 'preserve-breaks',
131
+ fontSize: mantine.fontSizes.sm,
132
+ lineHeight: mantine.lineHeights.xs
133
+ })
134
+
135
+ export const emptyNode = style({
136
+ width: '100%',
137
+ height: '100%',
138
+ border: `3px dashed ${mantine.colors.defaultBorder}`,
139
+ borderRadius: mantine.radius.md,
140
+ display: 'flex',
141
+ justifyContent: 'center',
142
+ alignItems: 'center'
143
+ })
144
+
145
+ export const navigateBtnBox = style({
146
+ zIndex: 100,
147
+ position: 'absolute',
148
+ left: '50%',
149
+ bottom: 2,
150
+ transform: 'translate(-50%, 0%)',
151
+ selectors: {
152
+ [`:where([data-likec4-shape='browser']) &`]: {
153
+ bottom: 4
154
+ }
155
+ }
156
+ })
157
+ export const navigateBtn = style({
158
+ pointerEvents: 'all',
159
+ color: vars.element.loContrast,
160
+ cursor: 'pointer',
161
+ backgroundColor: 'var(--ai-bg)',
162
+ 'vars': {
163
+ '--ai-bg': `color-mix(in srgb , ${vars.element.fill}, transparent 99%)`,
164
+ '--ai-bg-hover': `color-mix(in srgb , ${vars.element.fill} 65%, ${vars.element.stroke})`,
165
+ '--ai-hover': `color-mix(in srgb , ${vars.element.fill} 50%, ${vars.element.stroke})`
166
+ },
167
+ ':hover': {
168
+ boxShadow: mantine.shadows.md
169
+ }
170
+ })
@@ -73,7 +73,7 @@ export function diagramViewToXYFlowData(
73
73
  // const inEdges = node.inEdges.map(e => view.edges.find(edge => edge.id === e)).filter(Boolean)
74
74
 
75
75
  const id = ns + node.id
76
- const draggable = opts.draggable && (!parent || parent.children.length > 1)
76
+ const draggable = opts.draggable
77
77
  xynodes.push({
78
78
  id,
79
79
  type: isCompound ? 'compound' : 'element',
@@ -23,15 +23,16 @@ import {
23
23
  applyEdgeChanges,
24
24
  applyNodeChanges,
25
25
  getViewportForBounds,
26
+ type InternalNode,
26
27
  type OnEdgesChange,
27
28
  type OnNodesChange
28
29
  } from '@xyflow/react'
29
- import { boxToRect, getBoundsOfRects, getNodeDimensions } from '@xyflow/system'
30
+ import { boxToRect, getBoundsOfRects, getNodeDimensions, type XYPosition } from '@xyflow/system'
30
31
  import { DEV } from 'esm-env'
31
32
  import { deepEqual as eq, shallowEqual } from 'fast-equals'
32
33
  import type { MouseEvent as ReactMouseEvent } from 'react'
33
34
  import { entries, first, hasAtLeast, isNullish, map, prop, reduce } from 'remeda'
34
- import type { ConditionalKeys, Except, RequiredKeysOf, Simplify } from 'type-fest'
35
+ import type { ConditionalKeys, Except, RequiredKeysOf, RequireExactlyOne, Simplify } from 'type-fest'
35
36
  import { devtools, subscribeWithSelector } from 'zustand/middleware'
36
37
  import { shallow } from 'zustand/shallow'
37
38
  import { createWithEqualityFn } from 'zustand/traditional'
@@ -45,6 +46,7 @@ import type {
45
46
  import { type Vector, vector } from '../utils/vector'
46
47
  import { MinZoom } from '../xyflow/const'
47
48
  import type { XYFlowEdge, XYFlowInstance, XYFlowNode } from '../xyflow/types'
49
+ import { createLayoutConstraints } from '../xyflow/useLayoutConstraints'
48
50
  import { bezierControlPoints, isInside, isSamePoint, toDomPrecision } from '../xyflow/utils'
49
51
  import { diagramViewToXYFlowData } from './diagram-to-xyflow'
50
52
 
@@ -55,6 +57,9 @@ type RequiredOrNull<T> = {
55
57
  export type DiagramInitialState = {
56
58
  view: DiagramView
57
59
  readonly: boolean
60
+ // If LikeC4Model provided
61
+ hasLikeC4Model: boolean
62
+ controls: boolean
58
63
  showElementLinks: boolean
59
64
  showNavigationButtons: boolean
60
65
  showNotations: boolean
@@ -67,6 +72,7 @@ export type DiagramInitialState = {
67
72
  nodesSelectable: boolean
68
73
  experimentalEdgeEditing: boolean
69
74
  enableFocusMode: boolean
75
+ enableRelationshipsBrowser: boolean
70
76
  renderIcon: ElementIconRenderer | null
71
77
  whereFilter: WhereOperator<string, string> | null
72
78
  // If Dynamic View
@@ -79,6 +85,8 @@ export type DiagramInitialState = {
79
85
  getContainer: () => HTMLDivElement | null
80
86
  } & RequiredOrNull<LikeC4DiagramEventHandlers>
81
87
 
88
+ type AlignmentMode = 'Left' | 'Center' | 'Right' | 'Top' | 'Middle' | 'Bottom'
89
+
82
90
  const StringSet = Set<string>
83
91
 
84
92
  export type DiagramState = Simplify<
@@ -93,6 +101,13 @@ export type DiagramState = Simplify<
93
101
 
94
102
  initialized: boolean
95
103
 
104
+ activeOverlay:
105
+ | null
106
+ | RequireExactlyOne<{
107
+ relationshipsOf: Fqn
108
+ edgeDetails: EdgeId
109
+ }>
110
+
96
111
  // readonly=false and onChange is not null
97
112
  isEditable: () => boolean
98
113
 
@@ -163,6 +178,9 @@ export type DiagramState = Simplify<
163
178
  activateWalkthrough: (step: EdgeId | XYFlowEdge) => void
164
179
  stopWalkthrough: () => void
165
180
 
181
+ openOverlay: (overlay: NonNullable<DiagramState['activeOverlay']>) => void
182
+ closeOverlay: () => void
183
+
166
184
  onInit: (xyflow: XYFlowInstance) => void
167
185
  onNodesChange: OnNodesChange<XYFlowNode>
168
186
  onEdgesChange: OnEdgesChange<XYFlowEdge>
@@ -170,6 +188,7 @@ export type DiagramState = Simplify<
170
188
  highlightByElementNotation: (notation: ElementNotation, onlyOfKind?: ElementKind) => void
171
189
 
172
190
  resetEdgeControlPoints: () => void
191
+ align: (mode: AlignmentMode) => void
173
192
  }
174
193
  >
175
194
 
@@ -184,6 +203,7 @@ const DEFAULT_PROPS: Except<
184
203
  initialized: false,
185
204
  navigationHistoryIndex: 0,
186
205
  viewportChanged: false,
206
+ activeOverlay: null,
187
207
  activeWalkthrough: null,
188
208
  focusedNodeId: null,
189
209
  hoveredNodeId: null,
@@ -258,6 +278,7 @@ export function createDiagramStore(props: DiagramInitialState) {
258
278
  lastClickedNodeId,
259
279
  lastClickedEdgeId,
260
280
  activeWalkthrough,
281
+ activeOverlay,
261
282
  nodesDraggable,
262
283
  nodesSelectable,
263
284
  hoveredEdgeId,
@@ -364,6 +385,7 @@ export function createDiagramStore(props: DiagramInitialState) {
364
385
  hoveredNodeId = null
365
386
  focusedNodeId = null
366
387
  activeWalkthrough = null
388
+ activeOverlay = null
367
389
  dimmed = EmptyStringSet
368
390
  }
369
391
 
@@ -429,6 +451,7 @@ export function createDiagramStore(props: DiagramInitialState) {
429
451
  viewSyncDebounceTimeout,
430
452
  view: nextView,
431
453
  activeWalkthrough,
454
+ activeOverlay,
432
455
  lastOnNavigate,
433
456
  lastClickedNodeId,
434
457
  lastClickedEdgeId,
@@ -453,6 +476,7 @@ export function createDiagramStore(props: DiagramInitialState) {
453
476
  set(
454
477
  {
455
478
  activeWalkthrough: null,
479
+ activeOverlay: null,
456
480
  focusedNodeId: null,
457
481
  dimmed: EmptyStringSet
458
482
  },
@@ -481,6 +505,7 @@ export function createDiagramStore(props: DiagramInitialState) {
481
505
  set(
482
506
  {
483
507
  activeWalkthrough: null,
508
+ activeOverlay: null,
484
509
  focusedNodeId: nodeId,
485
510
  dimmed
486
511
  },
@@ -515,27 +540,19 @@ export function createDiagramStore(props: DiagramInitialState) {
515
540
  },
516
541
 
517
542
  resetFocusAndLastClicked: () => {
518
- let {
519
- activeWalkthrough,
520
- focusedNodeId,
521
- lastClickedNodeId,
522
- lastClickedEdgeId,
523
- xystore
524
- } = get()
525
- if (activeWalkthrough || focusedNodeId || lastClickedNodeId || lastClickedEdgeId) {
526
- set(
527
- {
528
- activeWalkthrough: null,
529
- focusedNodeId: null,
530
- lastClickedNodeId: null,
531
- lastClickedEdgeId: null,
532
- dimmed: EmptyStringSet
533
- },
534
- noReplace,
535
- 'resetLastClicked'
536
- )
537
- }
538
- xystore.getState().resetSelectedElements()
543
+ set(
544
+ {
545
+ activeWalkthrough: null,
546
+ activeOverlay: null,
547
+ focusedNodeId: null,
548
+ lastClickedNodeId: null,
549
+ lastClickedEdgeId: null,
550
+ dimmed: EmptyStringSet
551
+ },
552
+ noReplace,
553
+ 'resetLastClicked'
554
+ )
555
+ get().xystore.getState().resetSelectedElements()
539
556
  },
540
557
 
541
558
  getElement: (fqn) => {
@@ -740,10 +757,7 @@ export function createDiagramStore(props: DiagramInitialState) {
740
757
  onChange?.({ change })
741
758
  },
742
759
  scheduleSaveManualLayout: () => {
743
- let { viewSyncDebounceTimeout } = get()
744
- if (viewSyncDebounceTimeout) {
745
- clearTimeout(viewSyncDebounceTimeout)
746
- }
760
+ clearTimeout(get().viewSyncDebounceTimeout ?? undefined)
747
761
  set(
748
762
  {
749
763
  viewSyncDebounceTimeout: setTimeout(() => {
@@ -835,6 +849,25 @@ export function createDiagramStore(props: DiagramInitialState) {
835
849
  }
836
850
  },
837
851
 
852
+ openOverlay: (overlay) => {
853
+ set(
854
+ {
855
+ activeOverlay: overlay
856
+ },
857
+ noReplace,
858
+ 'openOverlay'
859
+ )
860
+ },
861
+ closeOverlay: () => {
862
+ set(
863
+ {
864
+ activeOverlay: null
865
+ },
866
+ noReplace,
867
+ 'openOverlay'
868
+ )
869
+ },
870
+
838
871
  fitDiagram: (duration = 500) => {
839
872
  const { fitViewPadding, view, xystore } = get()
840
873
  const { width, height, panZoom, transform } = xystore.getState()
@@ -1024,6 +1057,65 @@ export function createDiagramStore(props: DiagramInitialState) {
1024
1057
 
1025
1058
  return vector(v).mul(scale).add(nodeCenter)
1026
1059
  }
1060
+ },
1061
+ align: (mode: AlignmentMode) => {
1062
+ const { scheduleSaveManualLayout, xystore } = get()
1063
+ const { nodeLookup, parentLookup } = xystore.getState()
1064
+
1065
+ const selectedNodes = new Set(nodeLookup.values().filter(n => n.selected).map(n => n.id))
1066
+ const nodesToAlign = new Set(selectedNodes.difference(new Set(parentLookup.keys())))
1067
+ const constraints = createLayoutConstraints(xystore, nodesToAlign)
1068
+
1069
+ let getEdgePosition: (nodes: InternalNode<XYFlowNode>[]) => number
1070
+ let getPosition: (alignTo: number, node: InternalNode<XYFlowNode>) => number
1071
+ let propertyToEdit: keyof XYPosition
1072
+
1073
+ switch (mode) {
1074
+ case 'Left':
1075
+ getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.x))
1076
+ propertyToEdit = 'x'
1077
+ getPosition = (alignTo, _) => Math.floor(alignTo)
1078
+ break
1079
+ case 'Top':
1080
+ getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.y))
1081
+ propertyToEdit = 'y'
1082
+ getPosition = (alignTo, _) => Math.floor(alignTo)
1083
+ break
1084
+ case 'Right':
1085
+ getEdgePosition = nodes =>
1086
+ Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width))
1087
+ propertyToEdit = 'x'
1088
+ getPosition = (alignTo, node) => Math.floor(alignTo - node.width!)
1089
+ break
1090
+ case 'Bottom':
1091
+ getEdgePosition = nodes =>
1092
+ Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height))
1093
+ propertyToEdit = 'y'
1094
+ getPosition = (alignTo, node) => Math.floor(alignTo - node.height!)
1095
+ break
1096
+ case 'Center':
1097
+ getEdgePosition = nodes =>
1098
+ Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width / 2))
1099
+ propertyToEdit = 'x'
1100
+ getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).width / 2)
1101
+ break
1102
+ case 'Middle':
1103
+ getEdgePosition = nodes =>
1104
+ Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height / 2))
1105
+ propertyToEdit = 'y'
1106
+ getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).height / 2)
1107
+ break
1108
+ }
1109
+
1110
+ constraints.onMove((nodes) => {
1111
+ const alignTo = getEdgePosition(nodes.map(({ node }) => node))
1112
+
1113
+ nodes.forEach(({ rect, node }) => {
1114
+ rect.positionAbsolute = { ...rect.positionAbsolute, [propertyToEdit]: getPosition(alignTo, node) }
1115
+ })
1116
+ })
1117
+
1118
+ scheduleSaveManualLayout()
1027
1119
  }
1028
1120
  } satisfies DiagramState),
1029
1121
  {
@@ -59,7 +59,7 @@ export const ChangeAutoLayoutButton = (props: PopoverProps) => {
59
59
  })
60
60
  }
61
61
 
62
- const setSpacing = (nodeSep: number, rankSep: number) => {
62
+ const setSpacing = (nodeSep: number | null, rankSep: number | null) => {
63
63
  // Force fitDiagram
64
64
  store.setState({
65
65
  viewportChanged: false