@likec4/diagram 1.9.0 → 1.12.1

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 (113) hide show
  1. package/package.json +20 -43
  2. package/src/LikeC4CustomColors.tsx +5 -2
  3. package/src/LikeC4Diagram.css.ts +1 -1
  4. package/src/LikeC4Diagram.props.ts +40 -94
  5. package/src/LikeC4Diagram.tsx +14 -4
  6. package/src/LikeC4ModelProvider.tsx +21 -0
  7. package/src/StaticLikeC4Diagram.tsx +1 -0
  8. package/src/globals.css.ts +2 -3
  9. package/src/hooks/index.ts +1 -0
  10. package/src/hooks/useMantinePortalProps.ts +8 -0
  11. package/src/hooks/useXYFlow.ts +1 -2
  12. package/src/index.ts +9 -0
  13. package/src/likec4model/LikeC4ModelContext.ts +4 -0
  14. package/src/likec4model/index.ts +1 -0
  15. package/src/likec4model/useLikeC4Model.ts +58 -0
  16. package/src/state/diagram-to-xyflow.ts +1 -2
  17. package/src/state/diagramStore.ts +185 -66
  18. package/src/{theme.css.ts → theme-vars.ts} +11 -4
  19. package/src/ui/DiagramTitlePanel.css.ts +2 -3
  20. package/src/ui/DiagramTitlePanel.tsx +25 -3
  21. package/src/ui/DynamicViewWalkthrough.css.ts +28 -2
  22. package/src/ui/DynamicViewWalkthrough.tsx +97 -52
  23. package/src/ui/OptionsPanel.css.ts +1 -2
  24. package/src/ui/OptionsPanel.tsx +5 -2
  25. package/src/ui/notation/NotationPanel.css.ts +2 -3
  26. package/src/ui/notation/NotationPanel.tsx +10 -11
  27. package/src/ui/options/NodeOptions.tsx +4 -1
  28. package/src/ui/top-left/ChangeAutoLayoutButton.tsx +122 -71
  29. package/src/ui/top-left/TopLeftPanel.tsx +128 -117
  30. package/src/ui/top-left/_shared.ts +7 -6
  31. package/src/ui/top-left/styles.css.ts +48 -8
  32. package/src/utils/vector.ts +57 -0
  33. package/src/xyflow/FitviewOnDiagramChange.tsx +5 -4
  34. package/src/xyflow/SelectEdgesOnNodeFocus.tsx +7 -5
  35. package/src/xyflow/XYFlow.tsx +13 -17
  36. package/src/xyflow/XYFlowEvents.tsx +58 -58
  37. package/src/xyflow/XYFlowInner.tsx +4 -8
  38. package/src/xyflow/edges/EdgeLabel.tsx +185 -0
  39. package/src/xyflow/edges/RelationshipEdge.tsx +170 -137
  40. package/src/xyflow/edges/RelationshipsDropdownMenu.css.ts +60 -0
  41. package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +214 -0
  42. package/src/xyflow/edges/edges.css.ts +75 -7
  43. package/src/xyflow/nodes/compound/CompoundNode.css.ts +1 -2
  44. package/src/xyflow/nodes/compound/CompoundNode.tsx +25 -8
  45. package/src/xyflow/nodes/element/ElementLink.css.ts +1 -1
  46. package/src/xyflow/nodes/element/ElementLink.tsx +4 -8
  47. package/src/xyflow/nodes/element/ElementNode.tsx +47 -8
  48. package/src/xyflow/nodes/element/element.css.ts +1 -2
  49. package/src/xyflow/nodes/shared/Toolbar.tsx +426 -0
  50. package/src/xyflow/types.ts +1 -24
  51. package/src/xyflow/useLayoutConstraints.ts +10 -6
  52. package/dist/LikeC4CustomColors.d.ts +0 -3
  53. package/dist/LikeC4CustomColors.props.d.ts +0 -5
  54. package/dist/LikeC4Diagram.css.d.ts +0 -4
  55. package/dist/LikeC4Diagram.d.ts +0 -7
  56. package/dist/LikeC4Diagram.props.d.ts +0 -154
  57. package/dist/StaticLikeC4Diagram.d.ts +0 -4
  58. package/dist/globals.css.d.ts +0 -1
  59. package/dist/hooks/index.d.ts +0 -4
  60. package/dist/hooks/useDiagramState.d.ts +0 -6
  61. package/dist/hooks/useSetState.d.ts +0 -3
  62. package/dist/hooks/useUpdateEffect.d.ts +0 -5
  63. package/dist/hooks/useXYFlow.d.ts +0 -267
  64. package/dist/index.d.ts +0 -9
  65. package/dist/index.mjs +0 -3684
  66. package/dist/mantine/EnsureMantine.d.ts +0 -8
  67. package/dist/mantine.css.d.ts +0 -3
  68. package/dist/state/DiagramContext.d.ts +0 -1192
  69. package/dist/state/diagram-to-xyflow.d.ts +0 -12
  70. package/dist/state/diagramStore.d.ts +0 -1268
  71. package/dist/style.css +0 -2139
  72. package/dist/theme.css.d.ts +0 -63
  73. package/dist/ui/DiagramTitlePanel.css.d.ts +0 -4
  74. package/dist/ui/DiagramTitlePanel.d.ts +0 -1
  75. package/dist/ui/DynamicViewWalkthrough.css.d.ts +0 -3
  76. package/dist/ui/DynamicViewWalkthrough.d.ts +0 -1
  77. package/dist/ui/OptionsPanel.css.d.ts +0 -1
  78. package/dist/ui/OptionsPanel.d.ts +0 -1
  79. package/dist/ui/notation/NotationPanel.css.d.ts +0 -6
  80. package/dist/ui/notation/NotationPanel.d.ts +0 -1
  81. package/dist/ui/notation/index.d.ts +0 -1
  82. package/dist/ui/options/EdgeOptions.d.ts +0 -19
  83. package/dist/ui/options/NodeOptions.d.ts +0 -19
  84. package/dist/ui/top-left/ChangeAutoLayoutButton.d.ts +0 -3
  85. package/dist/ui/top-left/TopLeftPanel.d.ts +0 -4
  86. package/dist/ui/top-left/_shared.d.ts +0 -5
  87. package/dist/ui/top-left/styles.css.d.ts +0 -3
  88. package/dist/xyflow/EdgeMarkers.d.ts +0 -12
  89. package/dist/xyflow/FitviewOnDiagramChange.d.ts +0 -4
  90. package/dist/xyflow/SelectEdgesOnNodeFocus.d.ts +0 -1
  91. package/dist/xyflow/XYFlow.d.ts +0 -12
  92. package/dist/xyflow/XYFlowBackground.d.ts +0 -11
  93. package/dist/xyflow/XYFlowEvents.d.ts +0 -161
  94. package/dist/xyflow/XYFlowInner.d.ts +0 -10
  95. package/dist/xyflow/const.d.ts +0 -7
  96. package/dist/xyflow/edges/RelationshipEdge.d.ts +0 -32
  97. package/dist/xyflow/edges/edges.css.d.ts +0 -12
  98. package/dist/xyflow/edges/utils.d.ts +0 -17
  99. package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +0 -11
  100. package/dist/xyflow/nodes/compound/CompoundNode.d.ts +0 -6
  101. package/dist/xyflow/nodes/compound/index.d.ts +0 -1
  102. package/dist/xyflow/nodes/element/ElementLink.css.d.ts +0 -3
  103. package/dist/xyflow/nodes/element/ElementLink.d.ts +0 -7
  104. package/dist/xyflow/nodes/element/ElementNode.d.ts +0 -6
  105. package/dist/xyflow/nodes/element/ElementShapeSvg.d.ts +0 -18
  106. package/dist/xyflow/nodes/element/element.css.d.ts +0 -19
  107. package/dist/xyflow/nodes/element/index.d.ts +0 -1
  108. package/dist/xyflow/nodes/shared/NavigateToBtn.d.ts +0 -5
  109. package/dist/xyflow/types.d.ts +0 -50
  110. package/dist/xyflow/useLayoutConstraints.d.ts +0 -9
  111. package/dist/xyflow/utils.d.ts +0 -18
  112. package/src/mantine.css.ts +0 -7
  113. package/src/reset.d.ts +0 -2
@@ -0,0 +1,214 @@
1
+ import { type DiagramNode, LikeC4Model, nameFromFqn } from '@likec4/core'
2
+ import {
3
+ ActionIcon,
4
+ Box,
5
+ Group,
6
+ Menu,
7
+ MenuDivider,
8
+ MenuDropdown,
9
+ MenuItem,
10
+ MenuLabel,
11
+ MenuTarget,
12
+ Space,
13
+ Stack,
14
+ type StackProps,
15
+ Text,
16
+ Tooltip as MantineTooltip,
17
+ TooltipGroup
18
+ } from '@mantine/core'
19
+ import { IconArrowRight, IconFileSymlink, IconZoomScan } from '@tabler/icons-react'
20
+ import clsx from 'clsx'
21
+ import { forwardRef, Fragment, type MouseEventHandler, type PropsWithChildren } from 'react'
22
+ import { filter, isTruthy, map, partition, pipe } from 'remeda'
23
+ import { useDiagramState, useDiagramStoreApi, useMantinePortalProps, useXYNodesData } from '../../hooks'
24
+ import type { RelationshipData } from '../types'
25
+ import * as css from './RelationshipsDropdownMenu.css'
26
+
27
+ const stopPropagation: MouseEventHandler = (e) => e.stopPropagation()
28
+
29
+ export const Tooltip = MantineTooltip.withProps({
30
+ color: 'gray',
31
+ fz: 'xs',
32
+ openDelay: 300,
33
+ closeDelay: 150,
34
+ label: '',
35
+ children: null,
36
+ offset: 8,
37
+ withinPortal: false
38
+ })
39
+
40
+ export function RelationshipsDropdownMenu({
41
+ edge,
42
+ disabled = false,
43
+ likec4model,
44
+ children
45
+ }: PropsWithChildren<{
46
+ edge: RelationshipData['edge']
47
+ disabled?: boolean | undefined
48
+ likec4model: LikeC4Model
49
+ }>) {
50
+ const portalProps = useMantinePortalProps()
51
+ const [sourceXYNode, targetXYNode] = useXYNodesData([edge.source, edge.target])
52
+
53
+ if (!sourceXYNode || !targetXYNode) return children
54
+
55
+ const [direct, nested] = pipe(
56
+ edge.relations,
57
+ map(id => {
58
+ try {
59
+ return likec4model.relationship(id)
60
+ } catch (e) {
61
+ // View was cached, but likec4model based on new data
62
+ console.error(
63
+ `View is cached and likec4model missing relationship ${id} from ${edge.source} -> ${edge.target}`,
64
+ e
65
+ )
66
+ return null
67
+ }
68
+ }),
69
+ filter(isTruthy),
70
+ partition(r => r.relationship.source === edge.source && r.relationship.target === edge.target)
71
+ )
72
+
73
+ if (direct.length + nested.length === 0) {
74
+ return <>{children}</>
75
+ }
76
+
77
+ const renderRelationship = (relationship: LikeC4Model.ViewModel.Relationship, index: number) => (
78
+ <Fragment key={relationship.id}>
79
+ {index > 0 && <MenuDivider opacity={0.65} />}
80
+ <MenuItem
81
+ component={Relationship}
82
+ relationship={relationship}
83
+ sourceNode={sourceXYNode.data.element}
84
+ targetNode={targetXYNode.data.element}
85
+ edge={edge} />
86
+ </Fragment>
87
+ )
88
+
89
+ return (
90
+ <Menu
91
+ trigger={'click-hover'}
92
+ openDelay={300}
93
+ closeDelay={450}
94
+ floatingStrategy={'fixed'}
95
+ closeOnClickOutside
96
+ clickOutsideEvents={['pointerdown', 'mousedown', 'click']}
97
+ closeOnEscape
98
+ closeOnItemClick={false}
99
+ disabled={disabled}
100
+ {...portalProps}
101
+ >
102
+ <MenuTarget>
103
+ {children}
104
+ </MenuTarget>
105
+ <MenuDropdown
106
+ className={css.menuDropdown}
107
+ onPointerDownCapture={stopPropagation}
108
+ onPointerDown={stopPropagation}
109
+ onClick={stopPropagation}
110
+ onDoubleClick={stopPropagation}
111
+ >
112
+ {direct.length > 0 && (
113
+ <>
114
+ <MenuLabel>direct relationships</MenuLabel>
115
+ {direct.map(renderRelationship)}
116
+ </>
117
+ )}
118
+ {nested.length > 0 && (
119
+ <>
120
+ {direct.length > 0 && <MenuDivider />}
121
+ <MenuLabel>resolved from nested</MenuLabel>
122
+ {nested.map(renderRelationship)}
123
+ </>
124
+ )}
125
+ </MenuDropdown>
126
+ </Menu>
127
+ )
128
+ }
129
+
130
+ const Relationship = forwardRef<
131
+ HTMLDivElement,
132
+ StackProps & {
133
+ relationship: LikeC4Model.ViewModel.Relationship
134
+ edge: RelationshipData['edge']
135
+ sourceNode: DiagramNode
136
+ targetNode: DiagramNode
137
+ }
138
+ >(({
139
+ className,
140
+ relationship: r,
141
+ edge,
142
+ sourceNode,
143
+ targetNode,
144
+ ...props
145
+ }, ref) => {
146
+ const diagramApi = useDiagramStoreApi()
147
+ const {
148
+ viewId,
149
+ hasOnOpenSourceRelation,
150
+ hasOnNavigateTo
151
+ } = useDiagramState(s => ({
152
+ viewId: s.view.id,
153
+ hasOnOpenSourceRelation: !!s.onOpenSourceRelation,
154
+ hasOnNavigateTo: !!s.onNavigateTo
155
+ }))
156
+ const sourceId = nameFromFqn(edge.source) + r.source.id.slice(edge.source.length)
157
+ const targetId = nameFromFqn(edge.target) + r.target.id.slice(edge.target.length)
158
+ const navigateTo = hasOnNavigateTo && r.relationship.navigateTo !== viewId ? r.relationship.navigateTo : undefined
159
+
160
+ return (
161
+ <Stack ref={ref} className={clsx(css.menuItemRelationship, className)} {...props}>
162
+ <Group gap={4}>
163
+ <Text component="div" className={css.endpoint} data-likec4-color={sourceNode.color}>
164
+ {sourceId}
165
+ </Text>
166
+ <IconArrowRight stroke={2.5} size={11} />
167
+ <Text component="div" className={css.endpoint} data-likec4-color={targetNode.color}>
168
+ {targetId}
169
+ </Text>
170
+ {(navigateTo || hasOnOpenSourceRelation) && (
171
+ <TooltipGroup openDelay={100}>
172
+ <Space w={'xs'} />
173
+ {navigateTo && (
174
+ <Tooltip label={'Open dynamic view'}>
175
+ <ActionIcon
176
+ className={clsx('nodrag nopan')}
177
+ size={'sm'}
178
+ radius="sm"
179
+ variant="default"
180
+ onPointerDownCapture={stopPropagation}
181
+ onClick={event => {
182
+ diagramApi.getState().onNavigateTo?.(navigateTo, event)
183
+ }}
184
+ role="button"
185
+ >
186
+ <IconZoomScan size="80%" stroke={2} />
187
+ </ActionIcon>
188
+ </Tooltip>
189
+ )}
190
+ {hasOnOpenSourceRelation && (
191
+ <Tooltip label={'Open source'}>
192
+ <ActionIcon
193
+ className={clsx('nodrag nopan')}
194
+ size={'sm'}
195
+ radius="sm"
196
+ variant="default"
197
+ onPointerDownCapture={stopPropagation}
198
+ onClick={event => {
199
+ event.stopPropagation()
200
+ diagramApi.getState().onOpenSourceRelation?.(r.id)
201
+ }}
202
+ role="button"
203
+ >
204
+ <IconFileSymlink size="80%" stroke={2} />
205
+ </ActionIcon>
206
+ </Tooltip>
207
+ )}
208
+ </TooltipGroup>
209
+ )}
210
+ </Group>
211
+ <Box className={css.title}>{r.title || 'untitled'}</Box>
212
+ </Stack>
213
+ )
214
+ })
@@ -1,7 +1,6 @@
1
1
  import { rem } from '@mantine/core'
2
2
  import { createVar, fallbackVar, globalStyle, keyframes, style } from '@vanilla-extract/css'
3
- import { mantine } from '../../mantine.css'
4
- import { vars, xyvars } from '../../theme.css'
3
+ import { mantine, vars, xyvars } from '../../theme-vars'
5
4
 
6
5
  const mixColor = createVar('mix-color')
7
6
 
@@ -145,7 +144,7 @@ export const cssEdgePath = style({
145
144
  animationDelay: '450ms',
146
145
  transition: 'stroke 130ms ease-out,stroke-width 130ms ease-out'
147
146
  },
148
- [`:where(${isSelected}, [data-edge-active='true']) &`]: {
147
+ [`:where(${isSelected}, [data-edge-active='true'], [data-edge-animated='true']) &`]: {
149
148
  animationName: strokeKeyframes,
150
149
  animationDelay: '0ms',
151
150
  transition: 'stroke 130ms ease-out,stroke-width 130ms ease-out'
@@ -171,10 +170,15 @@ export const stepEdgeNumber = style({
171
170
  borderTopLeftRadius: labelBorderRadius,
172
171
  borderBottomLeftRadius: labelBorderRadius,
173
172
  backgroundColor: `color-mix(in srgb, ${vars.relation.labelBgColor}, ${mixColor} 5%)`,
174
- fontVariantNumeric: 'tabular-nums'
175
- })
176
- globalStyle(`:where([data-mantine-color-scheme="dark"]) :where([data-likec4-color="gray"]) ${stepEdgeNumber}`, {
177
- backgroundColor: `color-mix(in srgb, ${vars.relation.labelBgColor}, ${mixColor} 15%)`
173
+ fontVariantNumeric: 'tabular-nums',
174
+ selectors: {
175
+ [`:where([data-mantine-color-scheme="dark"]) :where([data-likec4-color="gray"]) &`]: {
176
+ backgroundColor: `color-mix(in srgb, ${vars.relation.labelBgColor}, ${mixColor} 15%)`
177
+ },
178
+ [`:where([data-edge-active='true']) &`]: {
179
+ backgroundColor: 'transparent'
180
+ }
181
+ }
178
182
  })
179
183
 
180
184
  export const varLabelX = createVar('label-x')
@@ -230,3 +234,67 @@ export const edgeLabelText = style({
230
234
  globalStyle(`${edgeLabel}:has(${stepEdgeNumber}) ${edgeLabelText}`, {
231
235
  padding: '2px 5px 4px 2px'
232
236
  })
237
+
238
+ export const edgeNoteCloseButton = style({
239
+ position: 'absolute',
240
+ top: 4,
241
+ right: 4,
242
+ zIndex: 9
243
+ })
244
+
245
+ export const edgeNoteText = style({
246
+ userSelect: 'all',
247
+ textAlign: 'left',
248
+ whiteSpaceCollapse: 'preserve-breaks',
249
+ textWrap: 'pretty',
250
+ lineHeight: 1.25,
251
+ vars: {
252
+ '--text-fz': mantine.fontSizes.sm
253
+ },
254
+ '@media': {
255
+ [mantine.largerThan('md')]: {
256
+ vars: {
257
+ '--text-fz': mantine.fontSizes.md
258
+ }
259
+ }
260
+ }
261
+ })
262
+
263
+ export const cssNavigateBtn = style({
264
+ zIndex: 'calc(var(--layer-overlays, 1) + 1)',
265
+ position: 'absolute',
266
+ pointerEvents: 'all',
267
+ left: '50%',
268
+ top: 'calc(100% + 1px)',
269
+ color: xyvars.edge.labelColor,
270
+ cursor: 'pointer',
271
+ transformOrigin: 'top center',
272
+ opacity: 0.8,
273
+ transition: 'all 150ms ease-in-out',
274
+ transform: 'translate(-50%, 0)',
275
+ transitionDelay: '0ms',
276
+ backgroundColor: 'var(--ai-bg)',
277
+ vars: {
278
+ '--ai-bg': vars.relation.labelBgColor,
279
+ '--ai-hover': `color-mix(in srgb , ${vars.relation.labelBgColor}, ${mixColor} 10%)`
280
+ },
281
+ selectors: {
282
+ [`:is(${dimmed}) &`]: {
283
+ display: 'none'
284
+ }
285
+ },
286
+ ':hover': {
287
+ boxShadow: mantine.shadows.lg,
288
+ transform: 'translate(-50%, 0) scale(1.3)',
289
+ opacity: 1
290
+ },
291
+ ':active': {
292
+ transform: 'translate(-50%, 0) scale(1.02)'
293
+ }
294
+ })
295
+ globalStyle(`${cssNavigateBtn} .tabler-icon`, {
296
+ width: '75%',
297
+ height: '75%',
298
+ strokeWidth: 2
299
+ })
300
+ export const cssNavigateBtnIcon = style({})
@@ -1,8 +1,7 @@
1
1
  import { rem } from '@mantine/core'
2
2
  import { createVar, fallbackVar, globalStyle, keyframes, style } from '@vanilla-extract/css'
3
3
  import { calc } from '@vanilla-extract/css-utils'
4
- import { mantine } from '../../../mantine.css'
5
- import { vars } from '../../../theme.css'
4
+ import { mantine, vars } from '../../../theme-vars'
6
5
 
7
6
  export const container = style({
8
7
  width: '100%',
@@ -1,13 +1,16 @@
1
+ import type { ThemeColor } from '@likec4/core'
1
2
  import { Box, Text } from '@mantine/core'
3
+ import { useDebouncedValue } from '@mantine/hooks'
2
4
  import { assignInlineVars } from '@vanilla-extract/dynamic'
3
5
  import { Handle, type NodeProps, Position } from '@xyflow/react'
4
6
  import clsx from 'clsx'
5
7
  import { deepEqual as eq } from 'fast-equals'
6
- import { memo } from 'react'
8
+ import { memo, useState } from 'react'
7
9
  import { clamp } from 'remeda'
8
10
  import { useDiagramState } from '../../../hooks/useDiagramState'
9
11
  import type { CompoundXYFlowNode } from '../../types'
10
12
  import { NavigateToBtn } from '../shared/NavigateToBtn'
13
+ import { CompoundToolbar } from '../shared/Toolbar'
11
14
  import * as css from './CompoundNode.css'
12
15
 
13
16
  type CompoundNodeProps = Pick<
@@ -17,7 +20,7 @@ type CompoundNodeProps = Pick<
17
20
 
18
21
  const isEqualProps = (prev: CompoundNodeProps, next: CompoundNodeProps) => (
19
22
  prev.id === next.id
20
- // && prev.selected === next.selected
23
+ && eq(prev.selected ?? false, next.selected ?? false)
21
24
  // && eq(prev.width, next.width)
22
25
  // && eq(prev.height, next.height)
23
26
  && eq(prev.data.element, next.data.element)
@@ -25,6 +28,7 @@ const isEqualProps = (prev: CompoundNodeProps, next: CompoundNodeProps) => (
25
28
 
26
29
  export const CompoundNodeMemo = /* @__PURE__ */ memo<CompoundNodeProps>(function CompoundNode({
27
30
  id,
31
+ selected = false,
28
32
  data: {
29
33
  element
30
34
  }
@@ -41,15 +45,28 @@ export const CompoundNodeMemo = /* @__PURE__ */ memo<CompoundNodeProps>(function
41
45
  max: 50
42
46
  })
43
47
 
44
- const { isHovered, isDimmed, hasOnNavigateTo } = useDiagramState(s => ({
48
+ const { isEditable, isHovered, isDimmed, hasOnNavigateTo } = useDiagramState(s => ({
49
+ isEditable: s.readonly !== true,
45
50
  isHovered: s.hoveredNodeId === id,
46
51
  isDimmed: s.dimmed.has(id),
47
52
  hasOnNavigateTo: !!s.onNavigateTo
48
53
  }))
49
54
  const isnavigable = !!compound.navigateTo && hasOnNavigateTo
50
55
 
56
+ const _isToolbarVisible = isEditable && (isHovered || (import.meta.env.DEV && selected))
57
+ const [isToolbarVisible] = useDebouncedValue(_isToolbarVisible, _isToolbarVisible ? 500 : 300)
58
+
59
+ const [previewColor, setPreviewColor] = useState<ThemeColor | null>(null)
60
+
51
61
  return (
52
62
  <>
63
+ {isToolbarVisible && (
64
+ <CompoundToolbar
65
+ isVisible={isToolbarVisible}
66
+ element={element}
67
+ align="start"
68
+ onColorPreview={setPreviewColor} />
69
+ )}
53
70
  <Handle
54
71
  type="target"
55
72
  position={Position.Top}
@@ -64,7 +81,7 @@ export const CompoundNodeMemo = /* @__PURE__ */ memo<CompoundNodeProps>(function
64
81
  )}
65
82
  mod={{
66
83
  'compound-depth': depth,
67
- 'likec4-color': color,
84
+ 'likec4-color': previewColor ?? color,
68
85
  hovered: isHovered
69
86
  }}
70
87
  >
@@ -83,15 +100,15 @@ export const CompoundNodeMemo = /* @__PURE__ */ memo<CompoundNodeProps>(function
83
100
  opacity < 1 && css.transparent,
84
101
  'likec4-compound'
85
102
  )}
86
- style={opacity < 1
87
- ? {
103
+ style={{
104
+ ...(opacity < 1 && {
88
105
  ...assignInlineVars({
89
106
  [css.varBorderTransparency]: `${borderTransparency}%`,
90
107
  [css.varOpacity]: opacity.toFixed(2)
91
108
  }),
92
109
  borderStyle: style.border ?? 'dashed'
93
- }
94
- : undefined}
110
+ })
111
+ }}
95
112
  >
96
113
  <Text
97
114
  component="div"
@@ -1,5 +1,5 @@
1
1
  import { globalStyle, style } from '@vanilla-extract/css'
2
- import { vars } from '../../../theme.css'
2
+ import { vars } from '../../../theme-vars'
3
3
 
4
4
  export const elementLink = style({
5
5
  position: 'absolute',
@@ -15,7 +15,7 @@ import { IconLink } from '@tabler/icons-react'
15
15
  import clsx from 'clsx'
16
16
  import { useId } from 'react'
17
17
  import { clamp } from 'remeda'
18
- import { useDiagramState } from '../../../hooks/useDiagramState'
18
+ import { useMantinePortalProps } from '../../../hooks/useMantinePortalProps'
19
19
  import { type DiagramState } from '../../../state/diagramStore'
20
20
  import type { XYFlowNode } from '../../types'
21
21
  import { elementLink, trigger } from './ElementLink.css'
@@ -24,17 +24,12 @@ type ElementLinkProps = {
24
24
  element: XYFlowNode['data']['element']
25
25
  }
26
26
 
27
- const selector = (s: DiagramState) => {
28
- const target = s.getContainer()
29
- return target ? { target } : null
30
- }
31
-
32
27
  export function ElementLink({
33
28
  element
34
29
  }: ElementLinkProps) {
35
30
  invariant(element.links, 'ElementLink: links are required')
36
31
  const id = useId()
37
- const portalProps = useDiagramState(selector)
32
+ const portalProps = useMantinePortalProps()
38
33
  return (
39
34
  <div className={elementLink} data-likec4-linkid={id}>
40
35
  <HoverCard
@@ -47,7 +42,7 @@ export function ElementLink({
47
42
  transitionProps={{
48
43
  transition: 'pop'
49
44
  }}
50
- {...(portalProps ? { portalProps } : { withinPortal: false })}
45
+ {...portalProps}
51
46
  floatingStrategy={'fixed'}
52
47
  openDelay={350}
53
48
  closeDelay={800}
@@ -57,6 +52,7 @@ export function ElementLink({
57
52
  }}>
58
53
  <HoverCardTarget>
59
54
  <UnstyledButton
55
+ tabIndex={-1}
60
56
  className={clsx('nodrag nopan', trigger)}
61
57
  autoFocus={false}>
62
58
  <IconLink size={12} />
@@ -1,19 +1,42 @@
1
- import type { DiagramNode } from '@likec4/core'
1
+ import { defaultTheme, type DiagramNode, type ThemeColor } from '@likec4/core'
2
2
  import { Text as MantineText } from '@mantine/core'
3
+ import { useDebouncedValue } from '@mantine/hooks'
3
4
  import { Handle, type NodeProps, Position } from '@xyflow/react'
4
5
  import clsx from 'clsx'
5
6
  import { deepEqual as eq } from 'fast-equals'
6
7
  import { m, type Variants } from 'framer-motion'
7
- import { memo } from 'react'
8
- import { isNumber, isTruthy } from 'remeda'
8
+ import { memo, useState } from 'react'
9
+ import { isNumber, isTruthy, keys } from 'remeda'
9
10
  import { type DiagramStoreApi, useDiagramState, useDiagramStoreApi } from '../../../hooks/useDiagramState'
10
11
  import type { ElementXYFlowNode } from '../../types'
11
12
  import { toDomPrecision } from '../../utils'
12
13
  import { NavigateToBtn } from '../shared/NavigateToBtn'
14
+ import { ElementToolbar } from '../shared/Toolbar'
13
15
  import * as css from './element.css'
14
16
  import { ElementLink } from './ElementLink'
15
17
  import { ElementShapeSvg, SelectedIndicator } from './ElementShapeSvg'
16
18
 
19
+ const {
20
+ primary,
21
+ secondary,
22
+ muted,
23
+ ...otherColors
24
+ } = defaultTheme.elements
25
+
26
+ export const themedColors = [
27
+ { key: 'primary', value: primary.fill },
28
+ { key: 'secondary', value: secondary.fill },
29
+ { key: 'muted', value: muted.fill }
30
+ ] satisfies Array<{ key: ThemeColor; value: string }>
31
+
32
+ export const colors = keys(otherColors).map(key => ({
33
+ key,
34
+ value: defaultTheme.elements[key].fill
35
+ }))
36
+
37
+ export type ThemeColorKey = typeof themedColors[0]['key']
38
+ export type ColorKey = typeof colors[0]['key']
39
+
17
40
  const Text = MantineText.withProps({
18
41
  component: 'div'
19
42
  })
@@ -89,15 +112,21 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
89
112
  height
90
113
  }) {
91
114
  const store = useDiagramStoreApi()
92
- const { isHovered, isDimmed, hasOnNavigateTo, isHovercards, isInteractive, hasGoToSource } = useDiagramState(s => ({
115
+ const { isEditable, isHovered, isDimmed, hasOnNavigateTo, isHovercards, isInteractive } = useDiagramState(s => ({
116
+ isEditable: s.readonly !== true,
93
117
  isHovered: s.hoveredNodeId === id,
94
118
  isDimmed: s.dimmed.has(id),
95
- isInteractive: s.nodesDraggable || s.nodesSelectable || !!s.onNavigateTo,
119
+ isInteractive: s.nodesDraggable || s.nodesSelectable || (!!s.onNavigateTo && !!element.navigateTo),
96
120
  isHovercards: s.showElementLinks,
97
- hasOnNavigateTo: !!s.onNavigateTo,
98
- hasGoToSource: !!s.onOpenSourceElement
121
+ hasOnNavigateTo: !!s.onNavigateTo
122
+ // hasGoToSource: !!s.onOpenSourceElement
99
123
  }))
100
124
  const isNavigable = hasOnNavigateTo && !!element.navigateTo
125
+ // const isIdle = useIdle(300)
126
+ // const [isToolbarVisible] = useDebouncedValue(isEditable && (isHovered || (import.meta.env.DEV && selected)), isIdle ? 50 : 350)
127
+ const _isToolbarVisible = isEditable && (isHovered || (import.meta.env.DEV && selected))
128
+
129
+ const [isToolbarVisible] = useDebouncedValue(_isToolbarVisible, _isToolbarVisible ? 500 : 300)
101
130
 
102
131
  const w = toDomPrecision(width ?? element.width)
103
132
  const h = toDomPrecision(height ?? element.height)
@@ -120,8 +149,17 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
120
149
 
121
150
  const elementIcon = ElementIcon({ node: element, store })
122
151
 
152
+ const [previewColor, setPreviewColor] = useState<ThemeColor | null>(null)
153
+
123
154
  return (
124
155
  <>
156
+ {isToolbarVisible && (
157
+ <ElementToolbar
158
+ element={element}
159
+ isVisible={isToolbarVisible}
160
+ onColorPreview={setPreviewColor}
161
+ />
162
+ )}
125
163
  <Handle
126
164
  type="target"
127
165
  position={Position.Top}
@@ -136,11 +174,12 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
136
174
  'likec4-element-node'
137
175
  ])}
138
176
  data-hovered={!dragging && isHovered}
139
- data-likec4-color={element.color}
177
+ data-likec4-color={previewColor ?? element.color}
140
178
  data-likec4-shape={element.shape}
141
179
  variants={variants}
142
180
  initial={false}
143
181
  animate={animate}
182
+ tabIndex={-1}
144
183
  {...(isInteractive && {
145
184
  whileTap: dragging ? animate : 'tap'
146
185
  })}
@@ -1,7 +1,6 @@
1
1
  import { rem } from '@mantine/core'
2
2
  import { createVar, fallbackVar, globalStyle, keyframes, style } from '@vanilla-extract/css'
3
- import { mantine } from '../../../mantine.css'
4
- import { vars } from '../../../theme.css'
3
+ import { mantine, vars } from '../../../theme-vars'
5
4
 
6
5
  export const stokeFillMix = createVar('stroke-fill-mix')
7
6