@likec4/diagram 1.17.0 → 1.18.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 (53) hide show
  1. package/package.json +24 -18
  2. package/src/LikeC4Diagram.props.ts +27 -12
  3. package/src/LikeC4Diagram.tsx +14 -9
  4. package/src/LikeC4Search.tsx +149 -0
  5. package/src/StaticLikeC4Diagram.tsx +1 -0
  6. package/src/hooks/useDiagramState.ts +8 -3
  7. package/src/hooks/useXYFlow.ts +2 -1
  8. package/src/likec4model/useLikeC4ElementsTree.ts +6 -13
  9. package/src/likec4model/useLikeC4Model.ts +21 -11
  10. package/src/overlays/Overlays.tsx +96 -63
  11. package/src/overlays/edge-details/EdgeDetailsXYFlow.tsx +3 -1
  12. package/src/overlays/edge-details/_types.ts +7 -3
  13. package/src/overlays/edge-details/use-layouted-edge-details.ts +21 -14
  14. package/src/overlays/edge-details/xyflow/ElementNode.tsx +4 -2
  15. package/src/overlays/edge-details/xyflow/RelationshipEdge.tsx +4 -4
  16. package/src/overlays/element-details/ElementDetailsCard.tsx +94 -81
  17. package/src/overlays/element-details/TabPanelDeployments.css.ts +69 -0
  18. package/src/overlays/element-details/TabPanelDeployments.tsx +217 -0
  19. package/src/overlays/element-details/TabPanelRelationships.tsx +8 -9
  20. package/src/overlays/element-details/TabPanelStructure.tsx +10 -10
  21. package/src/overlays/relationships-of/RelationshipsOverlay.tsx +1 -1
  22. package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +3 -11
  23. package/src/overlays/relationships-of/SelectElement.tsx +2 -2
  24. package/src/overlays/relationships-of/_types.ts +2 -2
  25. package/src/overlays/relationships-of/use-layouted-relationships.ts +18 -19
  26. package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +1 -3
  27. package/src/overlays/relationships-of/xyflow/ElementNode.tsx +12 -6
  28. package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +0 -1
  29. package/src/state/aligners.ts +473 -0
  30. package/src/state/diagramStore.layout.ts +67 -0
  31. package/src/state/diagramStore.ts +31 -77
  32. package/src/ui/DiagramTitlePanel.tsx +2 -29
  33. package/src/ui/Link.tsx +40 -0
  34. package/src/ui/notation/NotationPanel.tsx +4 -2
  35. package/src/ui/top-left/ChangeAutoLayoutButton.tsx +2 -2
  36. package/src/ui/top-left/ManualLayoutToolsButton.tsx +15 -0
  37. package/src/ui/top-left/TopLeftPanel.tsx +16 -3
  38. package/src/xyflow/edges/EdgeLabel.tsx +4 -4
  39. package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +20 -7
  40. package/src/xyflow/nodes/compound/CompoundNode.css.ts +28 -13
  41. package/src/xyflow/nodes/compound/CompoundNode.tsx +51 -19
  42. package/src/xyflow/nodes/element/ElementNode.tsx +153 -92
  43. package/src/xyflow/nodes/element/element.css.ts +53 -11
  44. package/src/xyflow/nodes/shared/ElementIcon.tsx +1 -1
  45. package/src/xyflow/nodes/shared/Toolbar.tsx +24 -8
  46. package/src/xyflow/useLayoutConstraints.ts +1 -1
  47. package/src/xyflow/useXYFlowEvents.ts +18 -5
  48. package/src/ui/OptionsPanel.css.ts +0 -9
  49. package/src/ui/OptionsPanel.tsx +0 -70
  50. package/src/ui/options/EdgeOptions.tsx +0 -142
  51. package/src/ui/options/NodeOptions.tsx +0 -362
  52. package/src/xyflow/nodes/element/ElementDetails.css.ts +0 -101
  53. package/src/xyflow/nodes/element/ElementDetails.tsx +0 -393
@@ -1,13 +1,13 @@
1
1
  import type {
2
+ ComputedNode,
2
3
  DiagramNode,
3
4
  DiagramView,
4
5
  EdgeId,
5
- ElementKind,
6
6
  ElementNotation,
7
7
  Fqn,
8
8
  NodeId,
9
9
  ViewChange,
10
- ViewID,
10
+ ViewId,
11
11
  XYPoint
12
12
  } from '@likec4/core'
13
13
  import {
@@ -17,17 +17,16 @@ import {
17
17
  isStepEdgeId,
18
18
  nonexhaustive,
19
19
  nonNullable,
20
- StepEdgeId
20
+ type StepEdgeId
21
21
  } from '@likec4/core'
22
22
  import {
23
23
  applyEdgeChanges,
24
24
  applyNodeChanges,
25
25
  getViewportForBounds,
26
- type InternalNode,
27
26
  type OnEdgesChange,
28
27
  type OnNodesChange
29
28
  } from '@xyflow/react'
30
- import { boxToRect, getBoundsOfRects, getNodeDimensions, type XYPosition } from '@xyflow/system'
29
+ import { boxToRect, getBoundsOfRects, getNodeDimensions } from '@xyflow/system'
31
30
  import { DEV } from 'esm-env'
32
31
  import { deepEqual as eq, shallowEqual } from 'fast-equals'
33
32
  import type { MouseEvent as ReactMouseEvent } from 'react'
@@ -46,9 +45,9 @@ import type {
46
45
  import { type Vector, vector } from '../utils/vector'
47
46
  import { MinZoom } from '../xyflow/const'
48
47
  import type { XYFlowEdge, XYFlowInstance, XYFlowNode } from '../xyflow/types'
49
- import { createLayoutConstraints } from '../xyflow/useLayoutConstraints'
50
48
  import { bezierControlPoints, isInside, isSamePoint, toDomPrecision } from '../xyflow/utils'
51
49
  import { diagramViewToXYFlowData } from './diagram-to-xyflow'
50
+ import { align, type AlignmentMode } from './diagramStore.layout'
52
51
 
53
52
  type RequiredOrNull<T> = {
54
53
  [P in keyof T]-?: NonNullable<T[P]> | null
@@ -73,6 +72,7 @@ export type DiagramInitialState = {
73
72
  enableElementDetails: boolean
74
73
  enableRelationshipBrowser: boolean
75
74
  enableRelationshipDetails: boolean
75
+ enableSearch: boolean
76
76
  renderIcon: ElementIconRenderer | null
77
77
  whereFilter: WhereOperator<string, string> | null
78
78
  // If Dynamic View
@@ -85,7 +85,7 @@ export type DiagramInitialState = {
85
85
  getContainer: () => HTMLDivElement | null
86
86
  } & RequiredOrNull<LikeC4DiagramEventHandlers>
87
87
 
88
- type AlignmentMode = 'Left' | 'Center' | 'Right' | 'Top' | 'Middle' | 'Bottom'
88
+ type NodeKind = ComputedNode['kind']
89
89
 
90
90
  const StringSet = Set<string>
91
91
 
@@ -132,12 +132,12 @@ export type DiagramState = Simplify<
132
132
  dimmed: ReadonlySet<string>
133
133
 
134
134
  lastOnNavigate: null | {
135
- fromView: ViewID
136
- toView: ViewID
137
- fromNode: NodeId
135
+ fromView: ViewId
136
+ toView: ViewId
137
+ fromNode: NodeId | null
138
138
  }
139
139
  navigationHistory: Array<{
140
- viewId: ViewID
140
+ viewId: ViewId
141
141
  nodeId: NodeId | null
142
142
  }>
143
143
  navigationHistoryIndex: number
@@ -186,10 +186,12 @@ export type DiagramState = Simplify<
186
186
  onNodesChange: OnNodesChange<XYFlowNode>
187
187
  onEdgesChange: OnEdgesChange<XYFlowEdge>
188
188
 
189
- highlightByElementNotation: (notation: ElementNotation, onlyOfKind?: ElementKind) => void
189
+ highlightByElementNotation: (notation: ElementNotation, onlyOfKind?: NodeKind) => void
190
190
 
191
191
  resetEdgeControlPoints: () => void
192
192
  align: (mode: AlignmentMode) => void
193
+
194
+ onOpenSourceView: () => void
193
195
  }
194
196
  >
195
197
 
@@ -850,10 +852,25 @@ export function createDiagramStore(props: DiagramInitialState) {
850
852
  }
851
853
  },
852
854
 
855
+ onOpenSourceView: () => {
856
+ const { view, onOpenSource } = get()
857
+ onOpenSource?.({
858
+ view: view.id
859
+ })
860
+ },
861
+
853
862
  openOverlay: (overlay) => {
854
863
  if (eq(overlay, get().activeOverlay)) {
855
864
  return
856
865
  }
866
+ if ('elementDetails' in overlay) {
867
+ const diagramNode = get().view.nodes.find(({ id }) => id === overlay.elementDetails)
868
+ if (!diagramNode || !diagramNode.modelRef) {
869
+ get().closeOverlay()
870
+ return
871
+ }
872
+ }
873
+
857
874
  set(
858
875
  {
859
876
  activeWalkthrough: null,
@@ -992,7 +1009,7 @@ export function createDiagramStore(props: DiagramInitialState) {
992
1009
  }
993
1010
  },
994
1011
 
995
- highlightByElementNotation: (notation: ElementNotation, onlyOfKind?: ElementKind) => {
1012
+ highlightByElementNotation: (notation: ElementNotation, onlyOfKind?: NodeKind) => {
996
1013
  const { xynodes, xyedges } = get()
997
1014
  const dimmed = new StringSet(map(xyedges, prop('id')))
998
1015
  xynodes.forEach(({ id, data }) => {
@@ -1068,70 +1085,7 @@ export function createDiagramStore(props: DiagramInitialState) {
1068
1085
  return vector(v).mul(scale).add(nodeCenter)
1069
1086
  }
1070
1087
  },
1071
- align: (mode: AlignmentMode) => {
1072
- const { scheduleSaveManualLayout, xystore } = get()
1073
- const { nodeLookup, parentLookup } = xystore.getState()
1074
-
1075
- const selectedNodes = new Set(nodeLookup.values().filter(n => n.selected).map(n => n.id))
1076
- const nodesToAlign = [...selectedNodes.difference(new Set(parentLookup.keys()))]
1077
-
1078
- if (!hasAtLeast(nodesToAlign, 2)) {
1079
- console.warn('At least 2 nodes must be selected to align')
1080
- return
1081
- }
1082
- const constraints = createLayoutConstraints(xystore, nodesToAlign)
1083
-
1084
- let getEdgePosition: (nodes: InternalNode<XYFlowNode>[]) => number
1085
- let getPosition: (alignTo: number, node: InternalNode<XYFlowNode>) => number
1086
- let propertyToEdit: keyof XYPosition
1087
-
1088
- switch (mode) {
1089
- case 'Left':
1090
- getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.x))
1091
- propertyToEdit = 'x'
1092
- getPosition = (alignTo, _) => Math.floor(alignTo)
1093
- break
1094
- case 'Top':
1095
- getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.y))
1096
- propertyToEdit = 'y'
1097
- getPosition = (alignTo, _) => Math.floor(alignTo)
1098
- break
1099
- case 'Right':
1100
- getEdgePosition = nodes =>
1101
- Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width))
1102
- propertyToEdit = 'x'
1103
- getPosition = (alignTo, node) => Math.floor(alignTo - node.width!)
1104
- break
1105
- case 'Bottom':
1106
- getEdgePosition = nodes =>
1107
- Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height))
1108
- propertyToEdit = 'y'
1109
- getPosition = (alignTo, node) => Math.floor(alignTo - node.height!)
1110
- break
1111
- case 'Center':
1112
- getEdgePosition = nodes =>
1113
- Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width / 2))
1114
- propertyToEdit = 'x'
1115
- getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).width / 2)
1116
- break
1117
- case 'Middle':
1118
- getEdgePosition = nodes =>
1119
- Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height / 2))
1120
- propertyToEdit = 'y'
1121
- getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).height / 2)
1122
- break
1123
- }
1124
-
1125
- constraints.onMove((nodes) => {
1126
- const alignTo = getEdgePosition(nodes.map(({ node }) => node))
1127
-
1128
- nodes.forEach(({ rect, node }) => {
1129
- rect.positionAbsolute = { ...rect.positionAbsolute, [propertyToEdit]: getPosition(alignTo, node) }
1130
- })
1131
- })
1132
-
1133
- scheduleSaveManualLayout()
1134
- }
1088
+ align: align(get)
1135
1089
  } satisfies DiagramState),
1136
1090
  {
1137
1091
  name: `${storeDevId} - ${props.view.id}`,
@@ -5,6 +5,7 @@ import clsx from 'clsx'
5
5
  import { AnimatePresence, m } from 'framer-motion'
6
6
  import { type DiagramState, useDiagramState } from '../hooks'
7
7
  import * as css from './DiagramTitlePanel.css'
8
+ import { Link } from './Link'
8
9
 
9
10
  const selector = (s: DiagramState) => ({
10
11
  id: s.view.id,
@@ -151,35 +152,7 @@ export default function DiagramTitlePanel() {
151
152
  gap={3}
152
153
  justify="stretch"
153
154
  align="stretch">
154
- {links.map((link) => (
155
- <Group key={link.url} wrap="nowrap" align="center" gap={'sm'}>
156
- <Box flex={'1'} style={{ overflow: 'hidden' }}>
157
- <Anchor
158
- href={link.url}
159
- target="_blank"
160
- fz="xs"
161
- truncate="end"
162
- display={'inline-block'}
163
- w={'100%'}>
164
- {link.title || link.url}
165
- </Anchor>
166
- </Box>
167
- <CopyButton value={link.url}>
168
- {({ copied, copy }) => (
169
- <Button
170
- size="compact-xs"
171
- fz={'10'}
172
- variant="light"
173
- onClick={copy}
174
- color={copied
175
- ? 'teal'
176
- : 'gray'}>
177
- {copied ? 'copied' : 'copy'}
178
- </Button>
179
- )}
180
- </CopyButton>
181
- </Group>
182
- ))}
155
+ {links.map((link) => <Link link={link} />)}
183
156
  </Stack>
184
157
  )}
185
158
  </>
@@ -0,0 +1,40 @@
1
+ import type { Link } from '@likec4/core'
2
+ import { Anchor, Box, Button, CopyButton, Group } from '@mantine/core'
3
+
4
+ export function Link({ link }: { link: Link }) {
5
+ return (
6
+ <>
7
+ <Group key={link.url} wrap="nowrap" align="center" gap={'sm'}>
8
+ <Box flex={'1'} style={{ overflow: 'hidden' }}>
9
+ <Anchor
10
+ href={link.url}
11
+ target="_blank"
12
+ fz="xs"
13
+ truncate="end"
14
+ display={'inline-block'}
15
+ w={'100%'}
16
+ onClick={(e) => e.stopPropagation()}>
17
+ {link.title || link.url}
18
+ </Anchor>
19
+ </Box>
20
+ <CopyButton value={link.url}>
21
+ {({ copied, copy }) => (
22
+ <Button
23
+ size="compact-xs"
24
+ fz={'10'}
25
+ variant="light"
26
+ onClick={(e) => {
27
+ e.stopPropagation()
28
+ copy()
29
+ }}
30
+ color={copied
31
+ ? 'teal'
32
+ : 'gray'}>
33
+ {copied ? 'copied' : 'copy'}
34
+ </Button>
35
+ )}
36
+ </CopyButton>
37
+ </Group>
38
+ </>
39
+ )
40
+ }
@@ -1,4 +1,4 @@
1
- import { type ElementKind, type ElementNotation as ElementNotationData } from '@likec4/core'
1
+ import { type ComputedNode, type ElementNotation as ElementNotationData } from '@likec4/core'
2
2
  import {
3
3
  ActionIcon,
4
4
  Badge,
@@ -29,13 +29,15 @@ import { vars } from '../../theme-vars'
29
29
  import { ElementShapeSvg } from '../../xyflow/nodes/element/ElementShapeSvg'
30
30
  import * as css from './NotationPanel.css'
31
31
 
32
+ type NodeKind = ComputedNode['kind']
33
+
32
34
  const ElementNotation = ({ value }: { value: ElementNotationData }) => {
33
35
  const {
34
36
  title,
35
37
  color = 'primary',
36
38
  shape = 'rectangle'
37
39
  } = value
38
- const [onlyKind, setOnlyKind] = useState<ElementKind | null>(null)
40
+ const [onlyKind, setOnlyKind] = useState<NodeKind | null>(null)
39
41
  const diagramStore = useDiagramStoreApi()
40
42
  const w = 300
41
43
  const h = 200
@@ -137,7 +137,7 @@ export const ChangeAutoLayoutButton = (props: PopoverProps) => {
137
137
  }
138
138
 
139
139
  const MAX_SPACING = 400
140
- const SpacingSliders = forwardRef<HTMLDivElement, {
140
+ const SpacingSliders = forwardRef<HTMLDivElement | null, {
141
141
  isVertical: boolean
142
142
  nodeSep: number | undefined
143
143
  rankSep: number | undefined
@@ -160,7 +160,7 @@ const SpacingSliders = forwardRef<HTMLDivElement, {
160
160
  onChange(Math.round(x * MAX_SPACING), Math.round(y * MAX_SPACING))
161
161
  },
162
162
  [onChange, isVertical],
163
- 150,
163
+ 250,
164
164
  2000
165
165
  )
166
166
 
@@ -6,6 +6,7 @@ import {
6
6
  IconLayoutAlignMiddle,
7
7
  IconLayoutAlignRight,
8
8
  IconLayoutAlignTop,
9
+ IconLayoutBoardSplit,
9
10
  IconLayoutCollage,
10
11
  IconRouteOff
11
12
  } from '@tabler/icons-react'
@@ -53,6 +54,13 @@ export const ManualLayoutToolsButton = (props: PopoverProps) => {
53
54
  <PopoverDropdown p={0}>
54
55
  <Group gap={'xs'}>
55
56
  <ActionIconGroup pos={'relative'}>
57
+ <Action
58
+ label="Align in columns"
59
+ icon={<IconLayoutBoardSplit />}
60
+ onClick={e => {
61
+ e.stopPropagation()
62
+ store.getState().align('Column')
63
+ }} />
56
64
  <Action
57
65
  label="Align left"
58
66
  icon={<IconLayoutAlignLeft />}
@@ -74,6 +82,13 @@ export const ManualLayoutToolsButton = (props: PopoverProps) => {
74
82
  e.stopPropagation()
75
83
  store.getState().align('Right')
76
84
  }} />
85
+ <Action
86
+ label="Align in rows"
87
+ icon={<IconLayoutBoardSplit style={{ transform: 'rotate(90deg)' }} />}
88
+ onClick={e => {
89
+ e.stopPropagation()
90
+ store.getState().align('Row')
91
+ }} />
77
92
  <Action
78
93
  label="Align top"
79
94
  icon={<IconLayoutAlignTop />}
@@ -12,13 +12,15 @@ import {
12
12
  Text,
13
13
  TooltipGroup
14
14
  } from '@mantine/core'
15
+ import { openSpotlight } from '@mantine/spotlight'
15
16
  import {
16
17
  IconAlertTriangle,
17
18
  IconChevronLeft,
18
19
  IconChevronRight,
19
20
  IconFileSymlink,
20
21
  IconFocusCentered,
21
- IconMenu2
22
+ IconMenu2,
23
+ IconSearch
22
24
  } from '@tabler/icons-react'
23
25
  import clsx from 'clsx'
24
26
  import { AnimatePresence, LayoutGroup, m } from 'framer-motion'
@@ -138,6 +140,7 @@ const LayoutDriftNotification = (props: PopoverProps) => (
138
140
  export const TopLeftPanel = () => {
139
141
  const store = useDiagramStoreApi()
140
142
  const {
143
+ enableSearch,
141
144
  showNavigationButtons,
142
145
  showFitDiagram,
143
146
  showLayoutDriftWarning,
@@ -150,11 +153,12 @@ export const TopLeftPanel = () => {
150
153
  const isNotFocused = isNullish(s.focusedNodeId)
151
154
  const isNotActive = isNotWalkthrough && isNotFocused
152
155
  return ({
156
+ enableSearch: s.enableSearch,
153
157
  showNavigationButtons: !!s.onBurgerMenuClick || (s.showNavigationButtons && !!s.onNavigateTo),
154
158
  showFitDiagram: s.controls && s.fitViewEnabled && s.zoomable && isNotWalkthrough,
155
159
  showLayoutDriftWarning: s.controls && !s.readonly && s.view.hasLayoutDrift === true && isNotActive,
156
160
  showChangeAutoLayout: s.controls && s.isEditable() && isNotActive,
157
- showGoToSource: s.controls && !!s.onOpenSourceView && isNotWalkthrough,
161
+ showGoToSource: s.controls && !!s.onOpenSource && isNotWalkthrough,
158
162
  viewportChanged: s.viewportChanged,
159
163
  showManualLayoutTools: s.controls && s.readonly !== true && s.experimentalEdgeEditing === true
160
164
  })
@@ -174,12 +178,21 @@ export const TopLeftPanel = () => {
174
178
  gap={'xs'}>
175
179
  {showNavigationButtons && <BackwardForwardButtons />}
176
180
  <ActionIconGroup className={css.actionIconGroup} orientation="vertical">
181
+ {enableSearch && (
182
+ <Tooltip label="Search (Ctrl+F or ⌘+F)">
183
+ <ActionIcon
184
+ onClick={() => openSpotlight()}
185
+ >
186
+ <IconSearch />
187
+ </ActionIcon>
188
+ </Tooltip>
189
+ )}
177
190
  {showGoToSource && (
178
191
  <Tooltip label="Open source" {...portalProps}>
179
192
  <ActionIcon
180
193
  onClick={e => {
181
194
  e.stopPropagation()
182
- store.getState().onOpenSourceView?.()
195
+ store.getState().onOpenSourceView()
183
196
  }}>
184
197
  <IconFileSymlink stroke={1.5} />
185
198
  </ActionIcon>
@@ -1,4 +1,4 @@
1
- import { extractStep, isStepEdgeId, type ViewID } from '@likec4/core'
1
+ import { extractStep, isStepEdgeId, type ViewId } from '@likec4/core'
2
2
  import {
3
3
  ActionIcon,
4
4
  Box,
@@ -80,9 +80,9 @@ export const EdgeLabel = ({
80
80
  </Box>
81
81
  )}
82
82
  {isTruthy(label?.text) && (
83
- <Box className={edgesCss.edgeLabelText}>
83
+ <Text component="div" className={edgesCss.edgeLabelText} lineClamp={5}>
84
84
  {label.text}
85
- </Box>
85
+ </Text>
86
86
  )}
87
87
  {navigateTo && <NavigateToBtn viewId={navigateTo} />}
88
88
  </Box>
@@ -162,7 +162,7 @@ const NotePopover = ({ notes, children }: PropsWithChildren<{ notes: string }>)
162
162
  }
163
163
 
164
164
  type NavigateToBtnProps = {
165
- viewId: ViewID
165
+ viewId: ViewId
166
166
  }
167
167
 
168
168
  function NavigateToBtn({ viewId }: NavigateToBtnProps) {
@@ -16,11 +16,12 @@ import {
16
16
  Tooltip as MantineTooltip,
17
17
  TooltipGroup
18
18
  } from '@mantine/core'
19
- import { IconArrowRight, IconArrowsMaximize, IconFileSymlink, IconInfoCircle, IconZoomScan } from '@tabler/icons-react'
19
+ import { IconArrowRight, IconFileSymlink, IconInfoCircle, IconZoomScan } from '@tabler/icons-react'
20
20
  import clsx from 'clsx'
21
21
  import { forwardRef, Fragment, type MouseEventHandler, type PropsWithChildren, useCallback } from 'react'
22
22
  import { filter, isTruthy, map, partition, pick, pipe } from 'remeda'
23
23
  import { useDiagramState, useDiagramStoreApi, useMantinePortalProps, useXYNodesData } from '../../hooks'
24
+ import { Link } from '../../ui/Link'
24
25
  import type { RelationshipData } from '../types'
25
26
  import * as css from './RelationshipsDropdownMenu.css'
26
27
 
@@ -72,7 +73,7 @@ export function RelationshipsDropdownMenu({
72
73
  }
73
74
  }),
74
75
  filter(isTruthy),
75
- partition(r => r.relationship.source === edge.source && r.relationship.target === edge.target)
76
+ partition(r => r.source.id === edge.source && r.target.id === edge.target)
76
77
  )
77
78
 
78
79
  const onClickOpenOverlay = useCallback((e: React.MouseEvent): void => {
@@ -84,7 +85,7 @@ export function RelationshipsDropdownMenu({
84
85
  }
85
86
  }, [edge.id, openOverlay, enableRelationshipBrowser])
86
87
 
87
- const renderRelationship = (relationship: LikeC4Model.ViewModel.Relationship, index: number) => (
88
+ const renderRelationship = (relationship: LikeC4Model.AnyRelation, index: number) => (
88
89
  <Fragment key={relationship.id}>
89
90
  {index > 0 && <MenuDivider opacity={0.65} />}
90
91
  <MenuItem
@@ -155,7 +156,7 @@ export function RelationshipsDropdownMenu({
155
156
  const Relationship = forwardRef<
156
157
  HTMLDivElement,
157
158
  StackProps & {
158
- relationship: LikeC4Model.ViewModel.Relationship
159
+ relationship: LikeC4Model.AnyRelation
159
160
  edge: RelationshipData['edge']
160
161
  sourceNode: DiagramNode
161
162
  targetNode: DiagramNode
@@ -175,12 +176,13 @@ const Relationship = forwardRef<
175
176
  hasOnNavigateTo
176
177
  } = useDiagramState(s => ({
177
178
  viewId: s.view.id,
178
- hasOnOpenSourceRelation: !!s.onOpenSourceRelation,
179
+ hasOnOpenSourceRelation: !!s.onOpenSource,
179
180
  hasOnNavigateTo: !!s.onNavigateTo
180
181
  }))
181
182
  const sourceId = nameFromFqn(edge.source) + r.source.id.slice(edge.source.length)
182
183
  const targetId = nameFromFqn(edge.target) + r.target.id.slice(edge.target.length)
183
- const navigateTo = hasOnNavigateTo && r.relationship.navigateTo !== viewId ? r.relationship.navigateTo : undefined
184
+ const navigateTo = hasOnNavigateTo && r.navigateTo?.id !== viewId ? r.navigateTo?.id : undefined
185
+ const links = r.links
184
186
 
185
187
  return (
186
188
  <Stack ref={ref} className={clsx(css.menuItemRelationship, className)} {...props}>
@@ -223,7 +225,9 @@ const Relationship = forwardRef<
223
225
  onPointerDownCapture={stopPropagation}
224
226
  onClick={event => {
225
227
  event.stopPropagation()
226
- diagramApi.getState().onOpenSourceRelation?.(r.id)
228
+ diagramApi.getState().onOpenSource?.({
229
+ relation: r.id
230
+ })
227
231
  }}
228
232
  role="button"
229
233
  >
@@ -235,6 +239,15 @@ const Relationship = forwardRef<
235
239
  )}
236
240
  </Group>
237
241
  <Box className={css.title}>{r.title || 'untitled'}</Box>
242
+ {r.description && <Text size="xs" c="dimmed">{r.description}</Text>}
243
+ {links.length > 0 && (
244
+ <Stack
245
+ gap={3}
246
+ justify="stretch"
247
+ align="stretch">
248
+ {links.map((link) => <Link key={link.url} link={link} />)}
249
+ </Stack>
250
+ )}
238
251
  </Stack>
239
252
  )
240
253
  })
@@ -49,7 +49,20 @@ globalStyle(`.react-flow__node-compound:has(${dimmed})`, {
49
49
  willChange: 'opacity, filter'
50
50
  })
51
51
 
52
+ globalStyle(`:where([data-mantine-color-scheme='dark'] .likec4-compound-transparent)`, {
53
+ vars: {
54
+ ['--_compound-border-color']: `color-mix(in srgb, ${vars.compound.titleColor} 25%, ${vars.element.stroke})`
55
+ }
56
+ })
57
+ globalStyle(`:where([data-mantine-color-scheme='light'] .likec4-compound-transparent)`, {
58
+ vars: {
59
+ ['--_compound-title-color']: vars.element.stroke
60
+ }
61
+ })
62
+
52
63
  export const varOpacity = createVar('opacity')
64
+ export const varBorderColor = createVar('border-color')
65
+ export const varBorderRadius = createVar('border-transparency')
53
66
  export const varBorderTransparency = createVar('border-transparency')
54
67
 
55
68
  const outlineColor = fallbackVar(
@@ -62,14 +75,17 @@ export const compoundBody = style({
62
75
  width: '100%',
63
76
  height: '100%',
64
77
  position: 'relative',
65
- borderRadius: 6,
78
+ borderRadius: varBorderRadius,
66
79
  boxShadow: '0 4px 10px 0.5px rgba(0,0,0,0.1) , 0 2px 2px -1px rgba(0,0,0,0.4)',
67
80
  padding: 0,
68
81
  margin: 0,
69
82
  transition: 'all 200ms ease-out',
70
- backgroundClip: 'padding-box',
83
+ // backgroundClip: 'padding-box',
71
84
  // overflow: 'hidden',
72
85
  cursor: 'default',
86
+ vars: {
87
+ [varBorderRadius]: '6px'
88
+ },
73
89
  selectors: {
74
90
  [`:where(.react-flow__node.selected) &`]: {
75
91
  boxShadow: 'none'
@@ -88,8 +104,11 @@ export const compoundBody = style({
88
104
  width: '100%',
89
105
  height: '100%',
90
106
  pointerEvents: 'none',
107
+ border: '1px solid transparent',
108
+ borderRadius: varBorderRadius,
91
109
  transition: 'background 175ms ease-out, opacity 175ms ease-out',
92
- background: vars.element.fill
110
+ background: vars.element.fill,
111
+ backgroundClip: 'border-box'
93
112
  }
94
113
  })
95
114
  export const compoundTitle = style({
@@ -97,19 +116,20 @@ export const compoundTitle = style({
97
116
  alignItems: 'center',
98
117
  gap: 6,
99
118
  paddingLeft: 14,
100
- paddingTop: 9,
119
+ paddingTop: 6,
101
120
  minHeight: 30,
121
+ paddingRight: 2,
102
122
  selectors: {
103
123
  [`:where(.react-flow__node.draggable) &`]: {
104
124
  cursor: 'grab'
105
125
  },
106
126
  [`:where(.likec4-compound-transparent) &`]: {
107
- paddingTop: 4
127
+ paddingTop: 3
108
128
  }
109
129
  }
110
130
  })
111
131
  export const withNavigation = style({
112
- paddingLeft: 30
132
+ paddingLeft: 32
113
133
  })
114
134
 
115
135
  const opacityDeltaOnHover = createVar('opacityDeltaOnHover')
@@ -117,8 +137,9 @@ export const transparent = style({
117
137
  borderStyle: 'dashed',
118
138
  borderWidth: 3,
119
139
  boxShadow: 'none',
120
- borderColor: `color-mix(in srgb , ${vars.element.stroke}, transparent ${fallbackVar(varBorderTransparency, '10%')})`,
140
+ borderColor: `color-mix(in srgb , ${varBorderColor}, transparent ${fallbackVar(varBorderTransparency, '5%')})`,
121
141
  vars: {
142
+ [varBorderColor]: `var(--_compound-border-color,${vars.element.stroke})`,
122
143
  [opacityDeltaOnHover]: '0'
123
144
  },
124
145
  ':before': {
@@ -156,12 +177,6 @@ export const title = style({
156
177
  mixBlendMode: 'screen'
157
178
  })
158
179
 
159
- globalStyle(`:where([data-mantine-color-scheme='light'] .likec4-compound-transparent)`, {
160
- vars: {
161
- ['--_compound-title-color']: vars.element.stroke
162
- }
163
- })
164
-
165
180
  const indicatorKeyframes = keyframes({
166
181
  'from': {
167
182
  opacity: 0.6