@likec4/diagram 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/LikeC4Diagram.css.d.ts +0 -1
  2. package/dist/LikeC4Diagram.d.ts +1 -1
  3. package/dist/LikeC4Diagram.props.d.ts +7 -2
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.mjs +1580 -1194
  6. package/dist/state/DiagramContext.d.ts +97 -4169
  7. package/dist/state/diagramStore.d.ts +98 -4172
  8. package/dist/state/hooks.d.ts +4 -0
  9. package/dist/state/index.d.ts +1 -3
  10. package/dist/state/useDiagramStore.d.ts +3 -4622
  11. package/dist/state/useNavigationHistory.d.ts +5 -0
  12. package/dist/style.css +217 -159
  13. package/dist/theme.css.d.ts +5 -0
  14. package/dist/ui/BackwardForwardPanel.css.d.ts +1 -0
  15. package/dist/ui/BackwardForwardPanel.d.ts +1 -0
  16. package/dist/ui/DiagramTitlePanel.css.d.ts +1 -1
  17. package/dist/ui/OptionsPanel.d.ts +1 -3
  18. package/dist/ui/options/EdgeOptions.d.ts +19 -0
  19. package/dist/ui/options/NodeOptions.d.ts +2 -2
  20. package/dist/xyflow/EdgeMarkers.d.ts +11 -0
  21. package/dist/xyflow/XYFlowEvents.d.ts +12 -9
  22. package/dist/xyflow/XYFlowInner.d.ts +1 -0
  23. package/dist/xyflow/const.d.ts +3 -3
  24. package/dist/xyflow/diagram-to-xyflow.d.ts +1 -1
  25. package/dist/xyflow/edges/RelationshipEdge.d.ts +4 -3
  26. package/dist/xyflow/edges/edges.css.d.ts +1 -0
  27. package/dist/xyflow/edges/utils.d.ts +1 -0
  28. package/dist/xyflow/hooks/index.d.ts +3 -2
  29. package/dist/xyflow/hooks/useXYFlow.d.ts +22 -21
  30. package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +1 -0
  31. package/dist/xyflow/nodes/element/ElementIcon.d.ts +1 -4
  32. package/dist/xyflow/nodes/element/element.css.d.ts +3 -1
  33. package/dist/xyflow/types.d.ts +2 -32
  34. package/package.json +21 -21
  35. package/src/LikeC4Diagram.css.ts +10 -8
  36. package/src/LikeC4Diagram.props.ts +12 -5
  37. package/src/LikeC4Diagram.tsx +43 -26
  38. package/src/globals.css.ts +11 -4
  39. package/src/index.ts +1 -1
  40. package/src/mantine.css.ts +2 -2
  41. package/src/state/DiagramContext.tsx +47 -16
  42. package/src/state/diagramStore.ts +89 -80
  43. package/src/state/hooks.ts +5 -0
  44. package/src/state/index.ts +1 -3
  45. package/src/state/useDiagramStore.ts +6 -13
  46. package/src/state/useNavigationHistory.ts +39 -0
  47. package/src/theme.css.ts +5 -0
  48. package/src/ui/BackwardForwardPanel.css.ts +22 -0
  49. package/src/ui/BackwardForwardPanel.tsx +59 -0
  50. package/src/ui/DiagramTitlePanel.css.ts +17 -6
  51. package/src/ui/DiagramTitlePanel.tsx +108 -94
  52. package/src/ui/DynamicViewWalkthrough.tsx +6 -4
  53. package/src/ui/OptionsPanel.tsx +37 -18
  54. package/src/ui/options/EdgeOptions.tsx +157 -0
  55. package/src/ui/options/NodeOptions.tsx +37 -29
  56. package/src/xyflow/EdgeMarkers.tsx +148 -0
  57. package/src/xyflow/FitviewOnDiagramChange.tsx +8 -4
  58. package/src/xyflow/SelectEdgesOnNodeFocus.tsx +59 -55
  59. package/src/xyflow/SyncWithDiagram.tsx +13 -20
  60. package/src/xyflow/XYFlow.tsx +2 -4
  61. package/src/xyflow/XYFlowEvents.tsx +48 -33
  62. package/src/xyflow/XYFlowInner.tsx +6 -3
  63. package/src/xyflow/const.ts +3 -3
  64. package/src/xyflow/diagram-to-xyflow.ts +18 -53
  65. package/src/xyflow/edges/RelationshipEdge.tsx +218 -126
  66. package/src/xyflow/edges/edges.css.ts +44 -26
  67. package/src/xyflow/edges/utils.ts +10 -0
  68. package/src/xyflow/hooks/index.ts +4 -2
  69. package/src/xyflow/hooks/useLayoutConstraints.ts +5 -5
  70. package/src/xyflow/hooks/useXYFlow.ts +28 -1
  71. package/src/xyflow/nodes/compound/CompoundNode.css.ts +41 -31
  72. package/src/xyflow/nodes/compound/CompoundNode.tsx +52 -53
  73. package/src/xyflow/nodes/element/ElementIcon.tsx +11 -22
  74. package/src/xyflow/nodes/element/ElementLink.tsx +11 -16
  75. package/src/xyflow/nodes/element/ElementNode.tsx +69 -114
  76. package/src/xyflow/nodes/element/element.css.ts +48 -15
  77. package/src/xyflow/nodes/shared/NavigateToBtn.tsx +1 -1
  78. package/src/xyflow/types.ts +2 -4
  79. package/src/xyflow/utils.ts +3 -6
  80. package/dist/KeepAspectRatioContainer.d.ts +0 -9
  81. package/dist/icons/Clipboard.d.ts +0 -3
  82. package/dist/icons/Link.d.ts +0 -3
  83. package/dist/icons/ZoomIn.d.ts +0 -3
  84. package/dist/icons/index.d.ts +0 -3
  85. package/dist/mantine/theme.d.ts +0 -3
  86. package/dist/state/WhenInitialized.d.ts +0 -3
  87. package/dist/ui/Camera.d.ts +0 -3
  88. package/src/KeepAspectRatioContainer.tsx +0 -36
  89. package/src/icons/Clipboard.tsx +0 -24
  90. package/src/icons/Link.tsx +0 -23
  91. package/src/icons/ZoomIn.tsx +0 -25
  92. package/src/icons/index.ts +0 -3
  93. package/src/mantine/theme.ts +0 -20
  94. package/src/state/WhenInitialized.tsx +0 -24
  95. package/src/ui/Camera.tsx +0 -174
@@ -1,8 +1,9 @@
1
- import { Anchor, Box, Button, CopyButton, Group, Paper, Spoiler, Stack, Text } from '@mantine/core'
1
+ import { Anchor, Box, Button, Card, CardSection, CopyButton, Group, Spoiler, Stack, Text } from '@mantine/core'
2
2
  import { useLocalStorage } from '@mantine/hooks'
3
+ import { IconMenu } from '@tabler/icons-react'
3
4
  import clsx from 'clsx'
4
5
  import { AnimatePresence, m } from 'framer-motion'
5
- import { type DiagramState, useDiagramState } from '../state'
6
+ import { type DiagramState, useDiagramState } from '../state/hooks'
6
7
  import * as css from './DiagramTitlePanel.css'
7
8
 
8
9
  const selector = (s: DiagramState) => ({
@@ -35,103 +36,116 @@ export default function DiagramTitlePanel() {
35
36
  transformOrigin: 'left center'
36
37
  }}
37
38
  >
38
- <Paper
39
+ <Card
39
40
  radius="sm"
40
- withBorder={!isCollapsed}
41
- className={css.paper}
42
- p={isCollapsed ? 'xs' : 'lg'}
41
+ className={css.card}
42
+ withBorder
43
+ p={'md'}
44
+ pb={isCollapsed ? 'sm' : 'md'}
43
45
  onDoubleClick={e => e.stopPropagation()}>
44
- <Stack gap={'sm'} justify="stretch" align="stretch">
45
- <Group justify="stretch" wrap="nowrap" onClick={toggle} style={{ cursor: 'pointer' }}>
46
- <Text
47
- component={'div'}
48
- flex={'1'}
49
- size={isCollapsed ? 'sm' : 'lg'}
50
- fw={500}
51
- lh={1.1}
52
- className={css.title}
53
- >
54
- {title}
55
- </Text>
56
- <Text
57
- hidden={isCollapsed}
58
- component={'div'}
59
- flex={'0 0 auto'}
60
- inline
61
- size="xs"
62
- fz={9}
63
- fw={500}
64
- c={'dimmed'}
65
- style={{
66
- userSelect: 'all'
67
- }}>
68
- <span style={{ userSelect: 'none' }}>id:{' '}</span>
69
- {id}
70
- </Text>
71
- </Group>
72
- {!isCollapsed && (
73
- <>
74
- {description && (
75
- <Spoiler
76
- maxHeight={42}
77
- showLabel={<Button color="gray" variant="light" fz={'10'} size="compact-xs">show more</Button>}
78
- hideLabel={<Button color="gray" variant="light" fz={'10'} size="compact-xs">hide</Button>}>
79
- <Text
80
- component={'div'}
81
- size="sm"
82
- className={css.description}>
83
- {description || 'no description'}
84
- </Text>
85
- </Spoiler>
86
- )}
87
- {!description && (
46
+ <CardSection mb={isCollapsed ? 10 : 'sm'}>
47
+ <Button
48
+ fullWidth
49
+ size="xs"
50
+ h={'sm'}
51
+ py={2}
52
+ radius={'0'}
53
+ variant={isCollapsed ? 'light' : 'subtle'}
54
+ color="gray"
55
+ onClick={toggle}
56
+ >
57
+ <IconMenu size={11} opacity={0.7} />
58
+ </Button>
59
+ </CardSection>
60
+ <Group justify="stretch" wrap="nowrap" mb={isCollapsed ? 0 : 'sm'}>
61
+ <Text
62
+ component={'div'}
63
+ flex={'1'}
64
+ size={isCollapsed ? 'sm' : 'lg'}
65
+ fw={500}
66
+ lh={1.1}
67
+ className={css.title}
68
+ >
69
+ {title}
70
+ </Text>
71
+ <Text
72
+ hidden={isCollapsed}
73
+ component={'div'}
74
+ flex={'0 0 auto'}
75
+ inline
76
+ size="xs"
77
+ fz={9}
78
+ fw={500}
79
+ c={'dimmed'}
80
+ style={{
81
+ userSelect: 'all'
82
+ }}>
83
+ <span style={{ userSelect: 'none' }}>id:{' '}</span>
84
+ {id}
85
+ </Text>
86
+ </Group>
87
+ {!isCollapsed && (
88
+ <>
89
+ {description && (
90
+ <Spoiler
91
+ maxHeight={42}
92
+ showLabel={<Button color="gray" variant="light" fz={'10'} size="compact-xs">show more</Button>}
93
+ hideLabel={<Button color="gray" variant="light" fz={'10'} size="compact-xs">hide</Button>}>
88
94
  <Text
89
95
  component={'div'}
90
- size="xs"
91
- c={'dimmed'}>
92
- no description
96
+ size="sm"
97
+ className={css.description}>
98
+ {description || 'no description'}
93
99
  </Text>
94
- )}
95
- {links && (
96
- <Stack
97
- gap={3}
98
- justify="stretch"
99
- align="stretch">
100
- {links.map((link) => (
101
- <Group key={link} wrap="nowrap" align="center" gap={'sm'}>
102
- <Box flex={'1'} style={{ overflow: 'hidden' }}>
103
- <Anchor
104
- href={link}
105
- target="_blank"
106
- fz="xs"
107
- truncate="end"
108
- display={'inline-block'}
109
- w={'100%'}>
110
- {link}
111
- </Anchor>
112
- </Box>
113
- <CopyButton value={link}>
114
- {({ copied, copy }) => (
115
- <Button
116
- size="compact-xs"
117
- fz={'10'}
118
- variant="light"
119
- onClick={copy}
120
- color={copied
121
- ? 'teal'
122
- : 'gray'}>
123
- {copied ? 'copied' : 'copy'}
124
- </Button>
125
- )}
126
- </CopyButton>
127
- </Group>
128
- ))}
129
- </Stack>
130
- )}
131
- </>
132
- )}
133
- </Stack>
134
- </Paper>
100
+ </Spoiler>
101
+ )}
102
+ {!description && (
103
+ <Text
104
+ component={'div'}
105
+ size="xs"
106
+ c={'dimmed'}>
107
+ no description
108
+ </Text>
109
+ )}
110
+ {links && (
111
+ <Stack
112
+ gap={3}
113
+ justify="stretch"
114
+ align="stretch">
115
+ {links.map((link) => (
116
+ <Group key={link} wrap="nowrap" align="center" gap={'sm'}>
117
+ <Box flex={'1'} style={{ overflow: 'hidden' }}>
118
+ <Anchor
119
+ href={link}
120
+ target="_blank"
121
+ fz="xs"
122
+ truncate="end"
123
+ display={'inline-block'}
124
+ w={'100%'}>
125
+ {link}
126
+ </Anchor>
127
+ </Box>
128
+ <CopyButton value={link}>
129
+ {({ copied, copy }) => (
130
+ <Button
131
+ size="compact-xs"
132
+ fz={'10'}
133
+ variant="light"
134
+ onClick={copy}
135
+ color={copied
136
+ ? 'teal'
137
+ : 'gray'}>
138
+ {copied ? 'copied' : 'copy'}
139
+ </Button>
140
+ )}
141
+ </CopyButton>
142
+ </Group>
143
+ ))}
144
+ </Stack>
145
+ )}
146
+ </>
147
+ )}
148
+ </Card>
135
149
  </m.div>
136
150
  </AnimatePresence>
137
151
  )
@@ -1,4 +1,4 @@
1
- import { Box, Button } from '@mantine/core'
1
+ import { Box, Button, ButtonGroup } from '@mantine/core'
2
2
  import { useHotkeys } from '@mantine/hooks'
3
3
  import {
4
4
  IconPlayerPlayFilled,
@@ -8,7 +8,7 @@ import {
8
8
  } from '@tabler/icons-react'
9
9
  import clsx from 'clsx'
10
10
  import { isNullish, isNumber } from 'remeda'
11
- import { useDiagramState } from '../state/useDiagramStore'
11
+ import { useDiagramState } from '../state/hooks'
12
12
  import { useXYStore } from '../xyflow/hooks/useXYFlow'
13
13
  import * as css from './DynamicViewWalkthrough.css'
14
14
 
@@ -58,6 +58,8 @@ export function DynamicViewWalkthrough() {
58
58
  return (
59
59
  <Box
60
60
  className={clsx('react-flow__panel', css.container)}
61
+ onClick={(e) => e.stopPropagation()}
62
+ onDoubleClick={(e) => e.stopPropagation()}
61
63
  >
62
64
  {isNullish(activeDynamicViewStep)
63
65
  && (
@@ -71,7 +73,7 @@ export function DynamicViewWalkthrough() {
71
73
  </Button>
72
74
  )}
73
75
  {isNumber(activeDynamicViewStep) && (
74
- <Button.Group className={css.buttons}>
76
+ <ButtonGroup className={css.buttons}>
75
77
  <Button
76
78
  {...buttonProps}
77
79
  pl={'lg'}
@@ -98,7 +100,7 @@ export function DynamicViewWalkthrough() {
98
100
  <IconPlayerSkipForwardFilled />
99
101
  </Button>
100
102
  )}
101
- </Button.Group>
103
+ </ButtonGroup>
102
104
  )}
103
105
  </Box>
104
106
  )
@@ -3,27 +3,42 @@ import { useOnSelectionChange } from '@xyflow/react'
3
3
  import clsx from 'clsx'
4
4
  import { deepEqual as eq } from 'fast-equals'
5
5
  import { AnimatePresence, m } from 'framer-motion'
6
- import { memo, useState } from 'react'
7
- import { isNullish } from 'remeda'
8
- import { useDiagramState } from '../state'
6
+ import { useState } from 'react'
7
+ import { useDiagramState } from '../state/hooks'
9
8
  import { NodeOptions } from './options/NodeOptions'
10
9
  import * as styles from './OptionsPanel.css'
11
10
 
12
- const OptionsPanelMemo = memo(function OptionsPanel() {
13
- const isFocusDisabled = useDiagramState(s => isNullish(s.focusedNodeId))
14
- const [selectedNodes, setSelectedNodes] = useState([] as string[])
15
-
11
+ export default function OptionsPanel() {
12
+ const { isFocused, viewId } = useDiagramState(s => ({
13
+ viewId: s.view.id,
14
+ isFocused: s.focusedNodeId !== null || s.activeDynamicViewStep !== null
15
+ }))
16
+ const [selected, setSelected] = useState<{
17
+ nodes: string[]
18
+ edges: string[]
19
+ }>({
20
+ nodes: [],
21
+ edges: []
22
+ })
16
23
  useOnSelectionChange({
17
- onChange: ({ nodes }) => {
18
- const next = nodes.map(n => n.id)
19
- setSelectedNodes(prev => eq(prev, next) ? prev : next)
24
+ onChange: ({ nodes, edges }) => {
25
+ const newSelected = {
26
+ nodes: nodes.map(n => n.id).sort(),
27
+ edges: edges.map(e => e.id).sort()
28
+ }
29
+ if (!eq(selected, newSelected)) {
30
+ setSelected(newSelected)
31
+ }
20
32
  }
21
33
  })
22
-
34
+ const { nodes } = selected
35
+ // const hasAnySelection = !isFocused && (nodes.length > 0 || edges.length > 0)
36
+ const hasAnySelection = !isFocused && nodes.length > 0
23
37
  return (
24
38
  <AnimatePresence mode="wait">
25
- {isFocusDisabled && selectedNodes.length > 0 && (
39
+ {hasAnySelection && (
26
40
  <m.div
41
+ key={viewId}
27
42
  initial={{ opacity: 0, scale: 0.85 }}
28
43
  animate={{ opacity: 1, scale: 1 }}
29
44
  exit={{
@@ -33,16 +48,20 @@ const OptionsPanelMemo = memo(function OptionsPanel() {
33
48
  transition={{ duration: 0.14 }}
34
49
  className={clsx('react-flow__panel', styles.panel)}
35
50
  style={{
36
- transformOrigin: 'center right'
51
+ transformOrigin: 'center right',
52
+ maxWidth: '320px'
37
53
  }}
38
54
  >
39
- <Card shadow="sm">
40
- <NodeOptions selectedNodeIds={selectedNodes} />
55
+ <Card shadow="md">
56
+ {nodes.length > 0 && <NodeOptions selectedNodeIds={nodes} />}
57
+ {
58
+ /* nodes.length === 0 && edges.length > 0 && (
59
+ <EdgeOptions selectedEdgeIds={edges} />
60
+ ) */
61
+ }
41
62
  </Card>
42
63
  </m.div>
43
64
  )}
44
65
  </AnimatePresence>
45
66
  )
46
- })
47
-
48
- export default OptionsPanelMemo
67
+ }
@@ -0,0 +1,157 @@
1
+ import { DefaultRelationshipColor, defaultTheme, type NonEmptyArray, type ThemeColor } from '@likec4/core'
2
+ import { CheckIcon, ColorSwatch, Divider, Flex, rem, Stack, Text, Tooltip, TooltipGroup } from '@mantine/core'
3
+ import { shallowEqual } from 'fast-equals'
4
+ import { memo } from 'react'
5
+ import { hasAtLeast, keys, takeWhile } from 'remeda'
6
+ import { useDiagramStoreApi } from '../../state/hooks'
7
+ import { useXYEdgesData } from '../../xyflow/hooks/useXYFlow'
8
+ import { type XYFlowEdge } from '../../xyflow/types'
9
+
10
+ const {
11
+ primary,
12
+ secondary,
13
+ muted,
14
+ ...otherColors
15
+ } = defaultTheme.elements
16
+
17
+ export const themedColors = [
18
+ { key: 'primary', value: primary.fill },
19
+ { key: 'secondary', value: secondary.fill },
20
+ { key: 'muted', value: muted.fill }
21
+ ] satisfies Array<{ key: ThemeColor; value: string }>
22
+
23
+ export const colors = keys(otherColors).map(key => ({
24
+ key,
25
+ value: defaultTheme.elements[key].fill
26
+ }))
27
+
28
+ export type ThemeColorKey = typeof themedColors[0]['key']
29
+ export type ColorKey = typeof colors[0]['key']
30
+
31
+ type XYEdgeData = Pick<XYFlowEdge, 'id' | 'data'>
32
+
33
+ export const EdgeOptions = memo<{ selectedEdgeIds: string[] }>(({ selectedEdgeIds }) => {
34
+ const diagramApi = useDiagramStoreApi()
35
+ const edges = useXYEdgesData(selectedEdgeIds)
36
+ if (!hasAtLeast(edges, 1)) {
37
+ return null
38
+ }
39
+ if (edges.length !== selectedEdgeIds.length) {
40
+ throw new Error('EdgeOptions: edges and props.edges should have the same length')
41
+ }
42
+ const [firstEdge, ...rest] = edges
43
+
44
+ // // Makes sense to show opacity option only if there is at least one compound node
45
+ // const showOpacityOption = firstNode.type === 'compound'
46
+ // && (rest.length === 0 || rest.every(node => node.type === 'compound'))
47
+
48
+ // const triggerChange = (style: Changes.ChangeElementStyle['style']) => {
49
+ // const targets = edges.map(node => node.data.element.id)
50
+ // invariant(hasAtLeast(targets, 1), 'At least one target is required')
51
+ // diagramApi.getState().triggerChangeElementStyle({
52
+ // op: 'change-element-style',
53
+ // style,
54
+ // targets
55
+ // })
56
+ // }
57
+
58
+ return (
59
+ <Stack gap={'xs'}>
60
+ <div>
61
+ <Text fz={rem(9)} fw={'500'} c={'dimmed'}>
62
+ RELATIONSHIP{rest.length > 0 ? 'S' : ''}
63
+ </Text>
64
+ <Text size="xs" c={rest.length > 0 ? 'dimmed' : ''} truncate>
65
+ {rest.length === 0 ? firstEdge.data.edge.label : `[ multiple ]`}
66
+ </Text>
67
+ </div>
68
+ {
69
+ /* {showShapeOption && (
70
+ <ShapeOption
71
+ nodes={edges}
72
+ onShapeChange={(shape: ElementShape) => {
73
+ triggerChange({ shape })
74
+ }} />
75
+ )} */
76
+ }
77
+ <Colors
78
+ edges={edges}
79
+ onColorChange={(_color: ColorKey | ThemeColorKey) => {
80
+ // triggerChange({ color })
81
+ }} />
82
+ </Stack>
83
+ )
84
+ }, (prevProps, nextProps) => shallowEqual(prevProps.selectedEdgeIds, nextProps.selectedEdgeIds))
85
+
86
+ function Colors({
87
+ edges: [firstEdge, ...edges],
88
+ onColorChange
89
+ }: {
90
+ edges: NonEmptyArray<XYEdgeData>
91
+ onColorChange: (color: ColorKey | ThemeColorKey) => void
92
+ }) {
93
+ let selectedColor = (firstEdge.data.edge.color ?? DefaultRelationshipColor) as ThemeColor | null
94
+ takeWhile(edges, edge => {
95
+ const color = edge.data.edge.color ?? DefaultRelationshipColor
96
+ if (color !== selectedColor) {
97
+ selectedColor = null
98
+ return false
99
+ }
100
+ return true
101
+ })
102
+ const changeColor = (color: ColorKey | ThemeColorKey) => () => {
103
+ if (selectedColor === color) {
104
+ return
105
+ }
106
+ onColorChange(color)
107
+ }
108
+ return (
109
+ <div>
110
+ <TooltipGroup openDelay={400} closeDelay={300}>
111
+ <Divider label="color" labelPosition="left" />
112
+ <Flex mt={'xs'} maw={150} gap="xs" justify="flex-start" align="flex-start" direction="row" wrap="wrap">
113
+ {themedColors.map(({ key, value }) => (
114
+ <Tooltip
115
+ key={key}
116
+ label={key}
117
+ fz={'xs'}
118
+ color="dark"
119
+ offset={2}
120
+ transitionProps={{ duration: 140, transition: 'slide-up' }}>
121
+ <ColorSwatch
122
+ color={value}
123
+ size={20}
124
+ withShadow
125
+ onClick={changeColor(key)}
126
+ style={{ color: '#fff', cursor: 'pointer' }}
127
+ >
128
+ {selectedColor === key && <CheckIcon style={{ width: rem(12), height: rem(12) }} />}
129
+ </ColorSwatch>
130
+ </Tooltip>
131
+ ))}
132
+ </Flex>
133
+
134
+ <Flex mt="sm" maw={150} gap="xs" justify="flex-start" align="flex-start" direction="row" wrap="wrap">
135
+ {colors.map(({ key, value }) => (
136
+ <Tooltip
137
+ key={key}
138
+ label={key}
139
+ fz={'xs'}
140
+ color="dark"
141
+ offset={2}
142
+ transitionProps={{ duration: 140, transition: 'slide-up' }}>
143
+ <ColorSwatch
144
+ color={value}
145
+ size={20}
146
+ onClick={changeColor(key)}
147
+ style={{ color: '#fff', cursor: 'pointer' }}
148
+ >
149
+ {selectedColor === key && <CheckIcon style={{ width: rem(12), height: rem(12) }} />}
150
+ </ColorSwatch>
151
+ </Tooltip>
152
+ ))}
153
+ </Flex>
154
+ </TooltipGroup>
155
+ </div>
156
+ )
157
+ }
@@ -17,16 +17,18 @@ import {
17
17
  SegmentedControl,
18
18
  Select,
19
19
  Slider,
20
+ Space,
20
21
  Stack,
21
22
  Text,
22
23
  Tooltip,
23
24
  TooltipGroup
24
25
  } from '@mantine/core'
25
- import { useState } from 'react'
26
+ import { shallowEqual } from 'fast-equals'
27
+ import { memo, useState } from 'react'
26
28
  import { hasAtLeast, keys, takeWhile } from 'remeda'
27
29
  import { useUpdateEffect } from '../../hooks/useUpdateEffect'
28
30
  import type { Changes } from '../../LikeC4Diagram.props'
29
- import { useDiagramStoreApi } from '../../state'
31
+ import { useDiagramStoreApi } from '../../state/hooks'
30
32
  import { useXYNodesData } from '../../xyflow/hooks'
31
33
  import { XYFlowNode } from '../../xyflow/types'
32
34
 
@@ -43,7 +45,7 @@ export const themedColors = [
43
45
  { key: 'muted', value: muted.fill }
44
46
  ] satisfies Array<{ key: ThemeColor; value: string }>
45
47
 
46
- export const colors = keys.strict(otherColors).map(key => ({
48
+ export const colors = keys(otherColors).map(key => ({
47
49
  key,
48
50
  value: defaultTheme.elements[key].fill
49
51
  }))
@@ -53,7 +55,7 @@ export type ColorKey = typeof colors[0]['key']
53
55
 
54
56
  type XYNodesData = Pick<XYFlowNode, 'id' | 'data' | 'type'>
55
57
 
56
- export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] }) {
58
+ export const NodeOptions = memo<{ selectedNodeIds: string[] }>(({ selectedNodeIds }) => {
57
59
  // export function NodeOptions(props: { nodes: XYFlowNode[] }) {
58
60
  const diagramApi = useDiagramStoreApi()
59
61
  // const nodes = useXYNodesData(props.nodes.map(node => node.id))
@@ -70,7 +72,8 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
70
72
  const [firstNode, ...rest] = nodes
71
73
 
72
74
  // Makes sense to show opacity option only if there is at least one compound node
73
- const showOpacityOption = rest.length === 0 && firstNode.type === 'compound'
75
+ const showOpacityOption = firstNode.type === 'compound'
76
+ && (rest.length === 0 || rest.every(node => node.type === 'compound'))
74
77
 
75
78
  const triggerChange = (style: Changes.ChangeElementStyle['style']) => {
76
79
  const targets = nodes.map(node => node.data.element.id)
@@ -83,8 +86,11 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
83
86
  }
84
87
 
85
88
  return (
86
- <Stack>
89
+ <Stack gap={'xs'}>
87
90
  <div>
91
+ <Text fz={rem(9)} fw={'500'} c={'dimmed'}>
92
+ ELEMENT{rest.length > 0 ? 'S' : ``}
93
+ </Text>
88
94
  <Text size="xs" c={rest.length > 0 ? 'dimmed' : ''} truncate>
89
95
  {rest.length === 0 ? firstNode.data.element.title : `[ multiple ]`}
90
96
  </Text>
@@ -105,11 +111,13 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
105
111
  {showOpacityOption && (
106
112
  <div key={firstNode.id}>
107
113
  <Divider label="opacity and border" labelPosition="left" />
114
+ <Space h={'xs'} />
108
115
  <OpacityOption
109
116
  node={firstNode}
110
117
  onOpacityChange={(opacity: number) => {
111
118
  triggerChange({ opacity })
112
119
  }} />
120
+ <Space h={'sm'} />
113
121
  <BorderStyleOption
114
122
  node={firstNode}
115
123
  onChange={(border: BorderStyle) => {
@@ -121,7 +129,7 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
121
129
  <NavigateToOption nodes={nodes} />
122
130
  </Stack>
123
131
  )
124
- }
132
+ }, (prevProps, nextProps) => shallowEqual(prevProps.selectedNodeIds, nextProps.selectedNodeIds))
125
133
 
126
134
  function Colors({
127
135
  nodes: [firstNode, ...nodes],
@@ -242,26 +250,27 @@ function NavigateToOption({
242
250
  return (
243
251
  <div>
244
252
  <Divider label="navigate to" labelPosition="left" />
245
- <Select
246
- mt={'xs'}
247
- size="xs"
248
- variant="filled"
249
- value={!isMultiple ? (node.data.element.navigateTo ?? null) : null}
250
- disabled={isMultiple}
251
- placeholder={isMultiple ? '[ multiple ]' : 'select'}
252
- data={node.data.element.navigateTo
253
- ? [{ value: node.data.element.navigateTo, label: node.data.element.navigateTo }]
254
- : []}
255
- // data={ElementShapes}
256
- allowDeselect={true}
257
- checkIconPosition="right"
258
- // onChange={(value) => {
259
- // if (!value || value === selectedShape) {
260
- // return
261
- // }
262
- // onShapeChange(value as ElementShape)
263
- // }} />
264
- />
253
+ {isMultiple && (
254
+ <Text size="xs" c={'dimmed'}>
255
+ {'[ multiple ]'}
256
+ </Text>
257
+ )}
258
+ {!isMultiple && (
259
+ <>
260
+ <Space h={'xs'} />
261
+ <Select
262
+ size="xs"
263
+ variant="filled"
264
+ value={node.data.element.navigateTo ?? null}
265
+ placeholder={'select'}
266
+ data={node.data.element.navigateTo
267
+ ? [{ value: node.data.element.navigateTo, label: node.data.element.navigateTo }]
268
+ : []}
269
+ allowDeselect={true}
270
+ checkIconPosition="right"
271
+ />
272
+ </>
273
+ )}
265
274
  </div>
266
275
  )
267
276
  }
@@ -281,7 +290,6 @@ function OpacityOption({
281
290
 
282
291
  return (
283
292
  <Slider
284
- mt={'xs'}
285
293
  size={'sm'}
286
294
  color={'dark'}
287
295
  value={value}
@@ -304,7 +312,7 @@ function BorderStyleOption({
304
312
  }, [selecteBorderStyle])
305
313
 
306
314
  return (
307
- <Box mt={'md'}>
315
+ <Box>
308
316
  <SegmentedControl
309
317
  size="xs"
310
318
  fullWidth