@likec4/diagram 1.13.0 → 1.15.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 (43) hide show
  1. package/package.json +9 -9
  2. package/src/LikeC4CustomColors.tsx +6 -3
  3. package/src/LikeC4Diagram.css.ts +4 -0
  4. package/src/LikeC4Diagram.tsx +9 -7
  5. package/src/overlays/Overlays.css.ts +124 -0
  6. package/src/overlays/Overlays.tsx +210 -0
  7. package/src/overlays/edge-details/EdgeDetailsXYFlow.tsx +247 -0
  8. package/src/overlays/edge-details/SelectEdge.css.ts +99 -0
  9. package/src/overlays/edge-details/SelectEdge.tsx +153 -0
  10. package/src/overlays/edge-details/_types.ts +38 -0
  11. package/src/overlays/edge-details/use-layouted-edge-details.ts +356 -0
  12. package/src/overlays/edge-details/xyflow/CompoundNode.tsx +80 -0
  13. package/src/overlays/edge-details/xyflow/ElementNode.tsx +154 -0
  14. package/src/overlays/edge-details/xyflow/RelationshipEdge.tsx +81 -0
  15. package/src/overlays/edge-details/xyflow/styles.css.ts +196 -0
  16. package/src/overlays/index.tsx +1 -33
  17. package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +124 -63
  18. package/src/overlays/relationships-of/_types.ts +13 -2
  19. package/src/overlays/relationships-of/use-layouted-relationships.ts +37 -37
  20. package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +2 -2
  21. package/src/overlays/relationships-of/xyflow/ElementNode.tsx +47 -26
  22. package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +58 -22
  23. package/src/overlays/relationships-of/xyflow/styles.css.ts +53 -16
  24. package/src/state/DiagramContext.tsx +23 -5
  25. package/src/state/diagram-to-xyflow.ts +31 -20
  26. package/src/state/diagramStore.ts +30 -23
  27. package/src/xyflow/XYFlow.tsx +2 -1
  28. package/src/xyflow/XYFlowEvents.tsx +13 -1
  29. package/src/xyflow/edges/RelationshipEdge.tsx +10 -4
  30. package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +24 -21
  31. package/src/xyflow/nodes/compound/CompoundNode.css.ts +1 -0
  32. package/src/xyflow/nodes/compound/CompoundNode.tsx +11 -11
  33. package/src/xyflow/nodes/shared/Toolbar.tsx +1 -1
  34. package/src/xyflow/types.ts +1 -0
  35. package/src/xyflow/useLayoutConstraints.ts +38 -19
  36. package/src/components/Overlay.css.ts +0 -78
  37. package/src/components/Overlay.tsx +0 -102
  38. package/src/components/index.ts +0 -2
  39. package/src/overlays/edge-details/EdgeDetailsOverlay.css.ts +0 -13
  40. package/src/overlays/edge-details/EdgeDetailsOverlay.tsx +0 -37
  41. package/src/overlays/relationships-of/RelationshipsOverlay.tsx +0 -33
  42. package/src/overlays/relationships-of/styles.css.ts +0 -34
  43. /package/src/{components → overlays}/OverlayContext.tsx +0 -0
@@ -1,6 +1,7 @@
1
- import type { DiagramEdge, DiagramNode, DiagramView, Fqn } from '@likec4/core'
1
+ import { type DiagramEdge, DiagramNode, type DiagramView, ElementKind, type Fqn } from '@likec4/core'
2
2
  import { nonNullable, whereOperatorAsPredicate } from '@likec4/core'
3
3
  import { hasAtLeast } from 'remeda'
4
+ import type { UnionToIntersection } from 'type-fest'
4
5
  import type { WhereOperator } from '../LikeC4Diagram.props'
5
6
  import { ZIndexes } from '../xyflow/const'
6
7
  import type { XYFlowEdge, XYFlowNode } from '../xyflow/types'
@@ -55,7 +56,7 @@ export function diagramViewToXYFlowData(
55
56
  let next: typeof traverse[0] | undefined
56
57
  while ((next = traverse.shift())) {
57
58
  const { node, parent } = next
58
- const isCompound = hasAtLeast(node.children, 1)
59
+ const isCompound = hasAtLeast(node.children, 1) || node.kind == ElementKind.Group
59
60
  if (isCompound) {
60
61
  traverse.push(...node.children.map(child => ({ node: nodeById(child), parent: node })))
61
62
  }
@@ -69,35 +70,45 @@ export function diagramViewToXYFlowData(
69
70
  position.y -= parent.position[1]
70
71
  }
71
72
 
72
- // const outEdges = node.outEdges.map(e => view.edges.find(edge => edge.id === e)).filter(Boolean)
73
- // const inEdges = node.inEdges.map(e => view.edges.find(edge => edge.id === e)).filter(Boolean)
74
-
75
73
  const id = ns + node.id
76
- const draggable = opts.draggable
77
- xynodes.push({
74
+
75
+ const base: Omit<UnionToIntersection<XYFlowNode>, 'data'> = {
78
76
  id,
79
- type: isCompound ? 'compound' : 'element',
80
- data: {
81
- fqn: node.id,
82
- element: node
83
- },
84
- draggable,
85
- selectable: opts.selectable,
77
+ draggable: opts.draggable,
78
+ selectable: opts.selectable && node.kind !== ElementKind.Group,
86
79
  focusable: opts.selectable && !isCompound,
87
80
  deletable: false,
88
81
  position,
89
82
  zIndex: isCompound ? ZIndexes.Compound : ZIndexes.Element,
90
83
  width: node.width,
91
84
  height: node.height,
92
- hidden: !visiblePredicate(node),
93
- // parentId: parent ? ns + parent.id : null,
85
+ hidden: node.kind !== ElementKind.Group && !visiblePredicate(node),
94
86
  ...(parent && {
95
87
  parentId: ns + parent.id
96
- }),
97
- ...(isCompound && {
98
- dragHandle: '.likec4-compound-title'
99
88
  })
100
- })
89
+ }
90
+
91
+ xynodes.push(
92
+ isCompound
93
+ ? {
94
+ ...base,
95
+ type: 'compound',
96
+ data: {
97
+ fqn: node.id,
98
+ isViewGroup: node.kind === ElementKind.Group,
99
+ element: node
100
+ },
101
+ dragHandle: '.likec4-compound-title'
102
+ }
103
+ : {
104
+ ...base,
105
+ type: 'element',
106
+ data: {
107
+ fqn: node.id,
108
+ element: node
109
+ }
110
+ }
111
+ )
101
112
  }
102
113
 
103
114
  for (const edge of view.edges) {
@@ -395,7 +395,7 @@ export function createDiagramStore(props: DiagramInitialState) {
395
395
  selectable: nodesSelectable
396
396
  })
397
397
 
398
- update.xynodes = update.xynodes.map(update => {
398
+ update.xynodes = update.xynodes.map((update) => {
399
399
  const existing = xynodes.find(n => n.id === update.id)
400
400
  if (
401
401
  existing
@@ -403,18 +403,18 @@ export function createDiagramStore(props: DiagramInitialState) {
403
403
  && eq(existing.parentId ?? null, update.parentId ?? null)
404
404
  ) {
405
405
  if (
406
- existing.hidden === update.hidden
407
- && existing.width === update.width
406
+ existing.width === update.width
408
407
  && existing.height === update.height
408
+ && eq(existing.hidden ?? false, update.hidden ?? false)
409
409
  && eq(existing.position, update.position)
410
- && eq(existing.data.element, update.data.element)
410
+ && eq(existing.data, update.data)
411
411
  ) {
412
412
  return existing
413
413
  }
414
414
  return {
415
415
  ...existing,
416
416
  ...update
417
- }
417
+ } as XYFlowNode
418
418
  }
419
419
  return update
420
420
  })
@@ -425,7 +425,7 @@ export function createDiagramStore(props: DiagramInitialState) {
425
425
  const existing = xyedges.find(n => n.id === update.id)
426
426
  if (existing) {
427
427
  if (
428
- existing.hidden === update.hidden
428
+ eq(existing.hidden ?? false, update.hidden ?? false)
429
429
  && eq(existing.data.label, update.data.label)
430
430
  && eq(existing.data.controlPoints, update.data.controlPoints)
431
431
  && eq(existing.data.edge, update.data.edge)
@@ -850,22 +850,21 @@ export function createDiagramStore(props: DiagramInitialState) {
850
850
  },
851
851
 
852
852
  openOverlay: (overlay) => {
853
- set(
854
- {
855
- activeOverlay: overlay
856
- },
857
- noReplace,
858
- 'openOverlay'
859
- )
853
+ if (eq(overlay, get().activeOverlay)) {
854
+ return
855
+ }
856
+ set({ activeOverlay: overlay }, noReplace, 'openOverlay')
860
857
  },
861
858
  closeOverlay: () => {
862
- set(
863
- {
864
- activeOverlay: null
865
- },
866
- noReplace,
867
- 'openOverlay'
868
- )
859
+ if (get().activeOverlay !== null) {
860
+ set(
861
+ {
862
+ activeOverlay: null
863
+ },
864
+ noReplace,
865
+ 'openOverlay'
866
+ )
867
+ }
869
868
  },
870
869
 
871
870
  fitDiagram: (duration = 500) => {
@@ -875,7 +874,7 @@ export function createDiagramStore(props: DiagramInitialState) {
875
874
  const bounds = view.bounds
876
875
  const maxZoom = Math.max(1, transform[2])
877
876
  const viewport = getViewportForBounds(bounds, width, height, MinZoom, maxZoom, fitViewPadding)
878
- panZoom?.setViewport(viewport, { duration })
877
+ panZoom?.setViewport(viewport, duration > 0 ? { duration } : undefined)
879
878
  },
880
879
 
881
880
  nextDynamicStep: (increment = 1) => {
@@ -969,8 +968,11 @@ export function createDiagramStore(props: DiagramInitialState) {
969
968
  },
970
969
 
971
970
  onInit: (instance) => {
972
- const { xyflow, initialized } = get()
971
+ const { xyflow, initialized, fitViewEnabled, fitDiagram } = get()
973
972
  if (!initialized || xyflow !== instance) {
973
+ if (fitViewEnabled) {
974
+ fitDiagram(0)
975
+ }
974
976
  set(
975
977
  {
976
978
  xyflow: instance,
@@ -1063,7 +1065,12 @@ export function createDiagramStore(props: DiagramInitialState) {
1063
1065
  const { nodeLookup, parentLookup } = xystore.getState()
1064
1066
 
1065
1067
  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())))
1068
+ const nodesToAlign = [...selectedNodes.difference(new Set(parentLookup.keys()))]
1069
+
1070
+ if (!hasAtLeast(nodesToAlign, 2)) {
1071
+ console.warn('At least 2 nodes must be selected to align')
1072
+ return
1073
+ }
1067
1074
  const constraints = createLayoutConstraints(xystore, nodesToAlign)
1068
1075
 
1069
1076
  let getEdgePosition: (nodes: InternalNode<XYFlowNode>[]) => number
@@ -128,7 +128,8 @@ export function XYFlow({
128
128
  zoomOnDoubleClick={false}
129
129
  maxZoom={zoomable ? MaxZoom : 1}
130
130
  minZoom={zoomable ? MinZoom : 1}
131
- fitView={fitView}
131
+ // Fitview is handled by store in onInit
132
+ fitView={false}
132
133
  fitViewOptions={useMemo(() => ({
133
134
  minZoom: MinZoom,
134
135
  maxZoom: 1,
@@ -225,11 +225,13 @@ export function useXYFlowEvents() {
225
225
  }
226
226
  if (!onEdgeClick) {
227
227
  event.stopPropagation()
228
+ return
228
229
  }
229
230
  } else if (nodesSelectable) {
230
231
  xystore.getState().addSelectedEdges([xyedge.id])
231
232
  if (!onEdgeClick) {
232
233
  event.stopPropagation()
234
+ return
233
235
  }
234
236
  }
235
237
  onEdgeClick?.(
@@ -245,7 +247,9 @@ export function useXYFlowEvents() {
245
247
  enableDynamicViewWalkthrough,
246
248
  focusOnNode,
247
249
  activeWalkthrough,
248
- activateWalkthrough
250
+ activateWalkthrough,
251
+ openOverlay,
252
+ showRelationshipDetails
249
253
  } = diagramApi.getState()
250
254
  // if we are in dynamic view, and clicked on an edge, activate the step
251
255
  if (isDynamicView && enableDynamicViewWalkthrough) {
@@ -257,6 +261,14 @@ export function useXYFlowEvents() {
257
261
  return
258
262
  }
259
263
 
264
+ if (showRelationshipDetails) {
265
+ openOverlay({
266
+ edgeDetails: xyedge.data.edge.id
267
+ })
268
+ event.stopPropagation()
269
+ return
270
+ }
271
+
260
272
  if (enableFocusMode) {
261
273
  focusOnNode(xyedge.source)
262
274
  event.stopPropagation()
@@ -137,7 +137,6 @@ const curve = d3line<XYPosition>()
137
137
  export const RelationshipEdge = memo<EdgeProps<XYFlowEdge>>(function RelationshipEdgeR({
138
138
  id,
139
139
  data,
140
- selected,
141
140
  sourceX,
142
141
  sourceY,
143
142
  targetX,
@@ -438,10 +437,17 @@ export const RelationshipEdge = memo<EdgeProps<XYFlowEdge>>(function Relationshi
438
437
  const MarkerStart = markerStartName ? EdgeMarkers[markerStartName] : null
439
438
  const MarkerEnd = markerEndName ? EdgeMarkers[markerEndName] : null
440
439
 
441
- let labelZIndex = 1 + (isHovered ? ZIndexes.Element : (edgeLookup.get(id)!.zIndex ?? ZIndexes.Edge))
442
- if (isEdgePathEditable && selected) {
440
+ const edgeZIndex = edgeLookup.get(id)!.zIndex ?? ZIndexes.Edge
441
+ let labelZIndex
442
+ if (isEdgePathEditable) {
443
443
  // Move label below ControlPoints, otherwise they don't capture events
444
- labelZIndex = (edgeLookup.get(id)!.zIndex ?? ZIndexes.Edge) - 1
444
+ labelZIndex = edgeZIndex - 1
445
+ }
446
+ else if (isHovered) {
447
+ // Move above the elements
448
+ labelZIndex = ZIndexes.Element + 1
449
+ } else {
450
+ labelZIndex = edgeZIndex + 1
445
451
  }
446
452
 
447
453
  return (
@@ -1,4 +1,4 @@
1
- import { type DiagramNode, LikeC4Model, nameFromFqn } from '@likec4/core'
1
+ import { type DiagramNode, invariant, LikeC4Model, nameFromFqn } from '@likec4/core'
2
2
  import {
3
3
  ActionIcon,
4
4
  Box,
@@ -16,9 +16,9 @@ import {
16
16
  Tooltip as MantineTooltip,
17
17
  TooltipGroup
18
18
  } from '@mantine/core'
19
- import { IconArrowRight, IconFileSymlink, IconZoomScan } from '@tabler/icons-react'
19
+ import { IconArrowRight, IconArrowsMaximize, IconFileSymlink, IconInfoCircle, IconZoomScan } from '@tabler/icons-react'
20
20
  import clsx from 'clsx'
21
- import { forwardRef, Fragment, type MouseEventHandler, type PropsWithChildren } from 'react'
21
+ import { forwardRef, Fragment, type MouseEventHandler, type PropsWithChildren, useCallback } from 'react'
22
22
  import { filter, isTruthy, map, partition, pipe } from 'remeda'
23
23
  import { useDiagramState, useDiagramStoreApi, useMantinePortalProps, useXYNodesData } from '../../hooks'
24
24
  import type { RelationshipData } from '../types'
@@ -47,10 +47,12 @@ export function RelationshipsDropdownMenu({
47
47
  disabled?: boolean | undefined
48
48
  likec4model: LikeC4Model
49
49
  }>) {
50
+ const openOverlay = useDiagramState(s => s.openOverlay)
50
51
  const portalProps = useMantinePortalProps()
51
52
  const [sourceXYNode, targetXYNode] = useXYNodesData([edge.source, edge.target])
52
53
 
53
- if (!sourceXYNode || !targetXYNode) return children
54
+ invariant(sourceXYNode, `Source XYNode ${edge.source} not found for edge ${edge.id}`)
55
+ invariant(targetXYNode, `Target XYNode ${edge.target} not found for edge ${edge.id}`)
54
56
 
55
57
  const [direct, nested] = pipe(
56
58
  edge.relations,
@@ -70,14 +72,11 @@ export function RelationshipsDropdownMenu({
70
72
  partition(r => r.relationship.source === edge.source && r.relationship.target === edge.target)
71
73
  )
72
74
 
73
- if (direct.length + nested.length === 0) {
74
- return <>{children}</>
75
- }
76
-
77
75
  const renderRelationship = (relationship: LikeC4Model.ViewModel.Relationship, index: number) => (
78
76
  <Fragment key={relationship.id}>
79
77
  {index > 0 && <MenuDivider opacity={0.65} />}
80
78
  <MenuItem
79
+ onClick={onClickOpenOverlay}
81
80
  component={Relationship}
82
81
  relationship={relationship}
83
82
  sourceNode={sourceXYNode.data.element}
@@ -86,6 +85,17 @@ export function RelationshipsDropdownMenu({
86
85
  </Fragment>
87
86
  )
88
87
 
88
+ const onClickOpenOverlay = useCallback((e: React.MouseEvent): void => {
89
+ e.stopPropagation()
90
+ openOverlay({
91
+ edgeDetails: edge.id
92
+ })
93
+ }, [edge.id, openOverlay])
94
+
95
+ if (direct.length + nested.length === 0) {
96
+ return <>{children}</>
97
+ }
98
+
89
99
  return (
90
100
  <Menu
91
101
  trigger={'click-hover'}
@@ -107,7 +117,6 @@ export function RelationshipsDropdownMenu({
107
117
  onPointerDownCapture={stopPropagation}
108
118
  onPointerDown={stopPropagation}
109
119
  onClick={stopPropagation}
110
- onDoubleClick={stopPropagation}
111
120
  >
112
121
  {direct.length > 0 && (
113
122
  <>
@@ -122,22 +131,15 @@ export function RelationshipsDropdownMenu({
122
131
  {nested.map(renderRelationship)}
123
132
  </>
124
133
  )}
125
- {
126
- /* <Box pos={'absolute'} top={5} right={6}>
134
+ <Box pos={'absolute'} top={5} right={6}>
127
135
  <ActionIcon
128
136
  size={24}
129
- variant="light"
130
- onClick={e => {
131
- e.stopPropagation()
132
- diagramApi.getState().openOverlay({
133
- edgeDetails: edge.id
134
- })
135
- }}
137
+ variant="subtle"
138
+ onClick={onClickOpenOverlay}
136
139
  >
137
- <IconArrowsMaximize style={{ width: '70%' }} />
140
+ <IconInfoCircle style={{ width: '70%' }} />
138
141
  </ActionIcon>
139
- </Box> */
140
- }
142
+ </Box>
141
143
  </MenuDropdown>
142
144
  </Menu>
143
145
  )
@@ -195,6 +197,7 @@ const Relationship = forwardRef<
195
197
  variant="default"
196
198
  onPointerDownCapture={stopPropagation}
197
199
  onClick={event => {
200
+ event.stopPropagation()
198
201
  diagramApi.getState().onNavigateTo?.(navigateTo, event)
199
202
  }}
200
203
  role="button"
@@ -122,6 +122,7 @@ export const title = style({
122
122
  paddingLeft: 14,
123
123
  paddingTop: 13,
124
124
  paddingBottom: 6,
125
+ minHeight: 30,
125
126
  mixBlendMode: 'screen',
126
127
  selectors: {
127
128
  [`:where(.react-flow__node.draggable) &`]: {
@@ -1,4 +1,4 @@
1
- import type { ThemeColor } from '@likec4/core'
1
+ import { type ThemeColor } from '@likec4/core'
2
2
  import { ActionIcon, Box, Text, Tooltip } from '@mantine/core'
3
3
  import { useDebouncedValue } from '@mantine/hooks'
4
4
  import { IconTransform, IconZoomScan } from '@tabler/icons-react'
@@ -127,14 +127,12 @@ export const CompoundNodeMemo = /* @__PURE__ */ memo<CompoundNodeProps>(function
127
127
  selected = false,
128
128
  dragging = false,
129
129
  data: {
130
- element: {
131
- color,
132
- style,
133
- depth = 1,
134
- ...element
135
- }
130
+ isViewGroup,
131
+ element
136
132
  }
137
133
  }) {
134
+ const { depth, style, color } = element
135
+ const isNotViewGroup = !isViewGroup
138
136
  const opacity = clamp((style.opacity ?? 100) / 100, {
139
137
  min: 0,
140
138
  max: 1
@@ -164,9 +162,11 @@ export const CompoundNodeMemo = /* @__PURE__ */ memo<CompoundNodeProps>(function
164
162
  hasOnNavigateTo: !!s.onNavigateTo,
165
163
  enableRelationshipsMode: s.enableRelationshipsBrowser
166
164
  }))
167
- const isNavigable = !!element.navigateTo && hasOnNavigateTo
165
+ // If this is a view group, we don't want to show the navigate button
166
+ const isNavigable = isNotViewGroup && !!element.navigateTo && hasOnNavigateTo
167
+ const hasRelationshipsBrowser = isNotViewGroup && enableRelationshipsMode
168
168
 
169
- const _isToolbarVisible = isEditable && (isHovered || (import.meta.env.DEV && selected))
169
+ const _isToolbarVisible = isNotViewGroup && isEditable && (isHovered || (import.meta.env.DEV && selected))
170
170
  const [isToolbarVisible] = useDebouncedValue(_isToolbarVisible, _isToolbarVisible ? 500 : 300)
171
171
 
172
172
  const [animateVariants, animateHandlers] = useFramerAnimateVariants()
@@ -263,7 +263,7 @@ export const CompoundNodeMemo = /* @__PURE__ */ memo<CompoundNodeProps>(function
263
263
  component="div"
264
264
  className={clsx(
265
265
  css.title,
266
- (isNavigable || enableRelationshipsMode) && css.titleWithNavigation,
266
+ (isNavigable || hasRelationshipsBrowser) && css.titleWithNavigation,
267
267
  'likec4-compound-title'
268
268
  )}>
269
269
  {element.title}
@@ -286,7 +286,7 @@ export const CompoundNodeMemo = /* @__PURE__ */ memo<CompoundNodeProps>(function
286
286
  <IconZoomScan stroke={1.8} style={{ width: '75%' }} />
287
287
  </ActionIcon>
288
288
  )}
289
- {enableRelationshipsMode && (
289
+ {hasRelationshipsBrowser && (
290
290
  <Tooltip
291
291
  fz="xs"
292
292
  color="dark"
@@ -260,7 +260,7 @@ function GoToSourceButton({ elementId }: { elementId: Fqn }) {
260
260
  e.stopPropagation()
261
261
  diagramApi.getState().onOpenSourceElement?.(elementId)
262
262
  }}>
263
- <IconFileSymlink size={'70%'} stroke={1.8} />
263
+ <IconFileSymlink stroke={1.8} style={{ width: '70%' }} />
264
264
  </ActionIcon>
265
265
  </Tooltip>
266
266
  )
@@ -11,6 +11,7 @@ export type ElementXYFlowNode = Node<{
11
11
 
12
12
  export type CompoundXYFlowNode = Node<{
13
13
  fqn: Fqn
14
+ isViewGroup: boolean
14
15
  element: DiagramNode
15
16
  }, 'compound'>
16
17
 
@@ -1,8 +1,8 @@
1
- import { invariant, isAncestor, nonNullable } from '@likec4/core'
1
+ import { invariant, isAncestor, type NodeId, type NonEmptyArray, nonNullable } from '@likec4/core'
2
2
  import type { InternalNode, NodeChange, ReactFlowProps, XYPosition } from '@xyflow/react'
3
3
  import { getNodeDimensions } from '@xyflow/system'
4
4
  import { useMemo, useRef } from 'react'
5
- import { isNullish } from 'remeda'
5
+ import { filter, hasAtLeast, isNullish, map, pipe } from 'remeda'
6
6
  import { useDiagramStoreApi } from '../hooks'
7
7
  import { type XYStoreApi } from '../hooks/useXYFlow'
8
8
  import type { XYFlowNode } from './types'
@@ -99,11 +99,27 @@ type NodePositionUpdater = (
99
99
 
100
100
  export function createLayoutConstraints(
101
101
  xyflowApi: XYStoreApi,
102
- editingNodeIds: Set<string>
102
+ editingNodeIds: NonEmptyArray<string>
103
103
  ) {
104
104
  const { parentLookup, nodeLookup } = xyflowApi.getState()
105
105
  const rects = new Map<string, Leaf | Compound>()
106
106
 
107
+ const ancestorsOf = (nodeId: string) => {
108
+ const ancestors = [] as string[]
109
+ const xynode = nodeLookup.get(nodeId)
110
+ let parent = xynode?.parentId
111
+ let parentNode: InternalNode<XYFlowNode> | undefined
112
+ while (parent && (parentNode = nodeLookup.get(parent))) {
113
+ ancestors.push(parentNode.id as NodeId)
114
+ parent = parentNode.parentId
115
+ }
116
+ return ancestors
117
+ }
118
+
119
+ const ancestorsOfDraggingNodes = new Set(
120
+ editingNodeIds.flatMap(ancestorsOf)
121
+ )
122
+
107
123
  const traverse = new Array<{ xynode: InternalNode<XYFlowNode>; parent: Compound | null }>()
108
124
 
109
125
  for (const [, xynode] of nodeLookup) {
@@ -117,11 +133,11 @@ export function createLayoutConstraints(
117
133
 
118
134
  while (traverse.length > 0) {
119
135
  const { xynode, parent } = traverse.shift()!
120
- const isEditing = editingNodeIds.has(xynode.id)
136
+ const isEditing = editingNodeIds.includes(xynode.id)
121
137
 
122
138
  // Traverse children if the node is a compound, not dragging, and is an ancestor of the dragging node
123
139
  const shouldTraverse = !isEditing && xynode.type === 'compound'
124
- && editingNodeIds.values().some(x => isAncestor(xynode.id, x))
140
+ && ancestorsOfDraggingNodes.has(xynode.id)
125
141
 
126
142
  const rect = shouldTraverse ? new Compound(xynode, parent) : new Leaf(xynode, parent)
127
143
  rects.set(xynode.id, rect)
@@ -157,7 +173,6 @@ export function createLayoutConstraints(
157
173
  maxY: -Infinity
158
174
  })
159
175
 
160
-
161
176
  r.minX = childrenBB.minX - Rect.LeftPadding
162
177
  r.minY = childrenBB.minY - Rect.TopPadding
163
178
  r.maxX = childrenBB.maxX + Rect.RightPadding
@@ -200,11 +215,14 @@ export function createLayoutConstraints(
200
215
  animationFrameId ??= requestAnimationFrame(() => {
201
216
  animationFrameId = null
202
217
  updater(
203
- [
204
- ...editingNodeIds.values()
205
- .filter(id => rects.has(id) && nodeLookup.has(id))
206
- .map(id => ({ rect: nonNullable(rects.get(id)), node: nodeLookup.get(id)! }))
207
- ]
218
+ pipe(
219
+ editingNodeIds,
220
+ filter(id => rects.has(id) && nodeLookup.has(id)),
221
+ map(id => ({
222
+ rect: nonNullable(rects.get(id)),
223
+ node: nonNullable(nodeLookup.get(id))
224
+ }))
225
+ )
208
226
  )
209
227
  updateXYFlowNodes()
210
228
  })
@@ -229,17 +247,18 @@ export function useLayoutConstraints(): LayoutConstraints {
229
247
  const { nodeLookup } = xystore.getState()
230
248
  cancelSaveManualLayout()
231
249
 
232
- const draggingNodes = new Set(
233
- nodeLookup.values()
234
- .filter(x => x.dragging === true || x.id === xynode.id || x.selected)
235
- .filter(x => x.draggable === true)
236
- .map(x => x.id)
250
+ const draggingNodes = pipe(
251
+ Array.from(nodeLookup.values()),
252
+ filter(n => n.dragging === true || n.id === xynode.id || n.selected === true),
253
+ filter(n => n.draggable !== false),
254
+ map(x => x.id)
237
255
  )
238
- solverRef.current = createLayoutConstraints(xystore, draggingNodes)
256
+ if (hasAtLeast(draggingNodes, 1)) {
257
+ solverRef.current = createLayoutConstraints(xystore, draggingNodes)
258
+ }
239
259
  },
240
260
  onNodeDrag: () => {
241
- invariant(solverRef.current, 'solverRef.current should be defined')
242
- solverRef.current.onMove((nodes) => {
261
+ solverRef.current?.onMove((nodes) => {
243
262
  nodes.forEach(({ rect, node }) => {
244
263
  rect.positionAbsolute = node.internals.positionAbsolute
245
264
  })
@@ -1,78 +0,0 @@
1
- import { createVar, keyframes, style } from '@vanilla-extract/css'
2
- import { whereDark } from '../theme-vars'
3
-
4
- const dialogFadeIn = keyframes({
5
- '0%': {
6
- opacity: 0,
7
- display: 'none'
8
- },
9
- '100%': {
10
- opacity: 1,
11
- display: 'block'
12
- }
13
- })
14
-
15
- const dialogFadeOut = keyframes({
16
- '0%': {
17
- opacity: 1,
18
- transform: 'scale(1)',
19
- display: 'block'
20
- },
21
- '100%': {
22
- opacity: 0,
23
- transform: 'scale(0.98)',
24
- display: 'none'
25
- }
26
- })
27
-
28
- const backdropRgb = createVar('backdrop-rgb')
29
-
30
- const dialogBackdropFadeIn = keyframes({
31
- '0%': {
32
- WebkitBackdropFilter: 'blur(1px)',
33
- backdropFilter: 'blur(1px)',
34
- backgroundColor: `rgb(${backdropRgb} / 30%)`
35
- },
36
- '100%': {
37
- WebkitBackdropFilter: 'blur(8px)',
38
- backdropFilter: 'blur(8px)',
39
- backgroundColor: `rgb(${backdropRgb} / 85%)`
40
- }
41
- })
42
-
43
- export const dialog = style({
44
- top: 0,
45
- left: 0,
46
- padding: 0,
47
- margin: 0,
48
- boxSizing: 'border-box',
49
- border: '0 solid transparent',
50
- width: '100%',
51
- maxWidth: '100dvw',
52
- height: '100%',
53
- maxHeight: '100dvh',
54
- background: 'transparent',
55
- animation: `${dialogFadeOut} 130ms cubic-bezier(0.50, 0, 1, 1)`,
56
- transformOrigin: '50% 20%',
57
- vars: {
58
- [backdropRgb]: '255 255 255'
59
- },
60
- selectors: {
61
- [`${whereDark} &`]: {
62
- vars: {
63
- [backdropRgb]: '36 36 36'
64
- }
65
- },
66
- '&[open]': {
67
- animation: `${dialogFadeIn} 200ms cubic-bezier(0, 0, 0.40, 1) forwards`
68
- },
69
- '&::backdrop': {
70
- WebkitBackdropFilter: 'blur(1px)',
71
- backdropFilter: 'blur(1px)',
72
- backgroundColor: `rgb(${backdropRgb} / 30%)`
73
- },
74
- '&[open]::backdrop': {
75
- animation: `${dialogBackdropFadeIn} 350ms cubic-bezier(0, 0, 0.40, 1) forwards`
76
- }
77
- }
78
- })