@likec4/diagram 1.0.0-next.2 → 1.0.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 (124) hide show
  1. package/dist/LikeC4Diagram.css.d.ts +0 -1
  2. package/dist/LikeC4Diagram.d.ts +1 -2
  3. package/dist/LikeC4Diagram.props.d.ts +24 -25
  4. package/dist/StaticLikeC4Diagram.d.ts +1 -2
  5. package/dist/globals.css.d.ts +1 -0
  6. package/dist/hooks/index.d.ts +2 -3
  7. package/dist/hooks/useSetState.d.ts +3 -0
  8. package/dist/hooks/useUpdateEffect.d.ts +4 -0
  9. package/dist/icons/Clipboard.d.ts +0 -1
  10. package/dist/icons/Link.d.ts +0 -1
  11. package/dist/icons/ZoomIn.d.ts +0 -1
  12. package/dist/icons/index.d.ts +0 -1
  13. package/dist/index.d.ts +1 -4
  14. package/dist/index.mjs +9388 -0
  15. package/dist/mantine/EnsureMantine.d.ts +2 -4
  16. package/dist/mantine/theme.d.ts +3 -0
  17. package/dist/mantine.css.d.ts +1 -0
  18. package/dist/state/DiagramContext.d.ts +386 -0
  19. package/dist/state/WhenInitialized.d.ts +3 -0
  20. package/dist/state/diagramStore.d.ts +428 -0
  21. package/dist/state/index.d.ts +4 -2
  22. package/dist/state/useDiagramStore.d.ts +387 -0
  23. package/dist/style.css +1212 -1
  24. package/dist/theme.css.d.ts +16 -2
  25. package/dist/ui/Camera.d.ts +0 -1
  26. package/dist/ui/KeepAspectRatio.css.d.ts +0 -1
  27. package/dist/ui/KeepAspectRatio.d.ts +0 -1
  28. package/dist/ui/OptionsPanel.css.d.ts +0 -1
  29. package/dist/ui/OptionsPanel.d.ts +0 -1
  30. package/dist/ui/index.d.ts +0 -1
  31. package/dist/ui/options/NodeOptions.d.ts +0 -1
  32. package/dist/xyflow/FitviewOnDiagramChange.d.ts +1 -5
  33. package/dist/xyflow/SyncWithDiagram.d.ts +5 -0
  34. package/dist/xyflow/XYFlowBackground.d.ts +0 -1
  35. package/dist/xyflow/XYFlowEvents.d.ts +5 -15
  36. package/dist/xyflow/XYFlowWrapper.d.ts +4 -5
  37. package/dist/xyflow/const.d.ts +1 -2
  38. package/dist/xyflow/diagram-to-xyflow.d.ts +4 -2
  39. package/dist/xyflow/edges/RelationshipEdge.d.ts +7 -7
  40. package/dist/xyflow/edges/edges.css.d.ts +2 -1
  41. package/dist/xyflow/edges/utils.d.ts +3 -4
  42. package/dist/xyflow/hooks/index.d.ts +2 -37
  43. package/dist/xyflow/hooks/{use-layout-/321/201onstraints.d.ts → useLayoutConstraints.d.ts} +2 -4
  44. package/dist/xyflow/hooks/useXYFlow.d.ts +266 -0
  45. package/dist/xyflow/nodes/compound/CompoundNode.css.d.ts +0 -1
  46. package/dist/xyflow/nodes/compound/CompoundNode.d.ts +0 -1
  47. package/dist/xyflow/nodes/compound/index.d.ts +0 -1
  48. package/dist/xyflow/nodes/element/ElementIcon.d.ts +1 -2
  49. package/dist/xyflow/nodes/element/ElementLink.css.d.ts +0 -1
  50. package/dist/xyflow/nodes/element/ElementLink.d.ts +1 -2
  51. package/dist/xyflow/nodes/element/ElementNode.d.ts +1 -3
  52. package/dist/xyflow/nodes/element/ElementShapeSvg.d.ts +2 -3
  53. package/dist/xyflow/nodes/element/element.css.d.ts +0 -1
  54. package/dist/xyflow/nodes/element/index.d.ts +1 -2
  55. package/dist/xyflow/nodes/shared/NavigateToBtn.d.ts +0 -1
  56. package/dist/xyflow/types.d.ts +20 -8
  57. package/dist/xyflow/utils.d.ts +0 -1
  58. package/package.json +36 -34
  59. package/src/LikeC4Diagram.css.ts +31 -0
  60. package/src/LikeC4Diagram.props.ts +154 -0
  61. package/src/LikeC4Diagram.tsx +119 -0
  62. package/src/StaticLikeC4Diagram.tsx +39 -0
  63. package/src/globals.css.ts +128 -0
  64. package/src/hooks/index.ts +2 -0
  65. package/src/hooks/useSetState.ts +19 -0
  66. package/src/hooks/useUpdateEffect.ts +28 -0
  67. package/src/icons/Clipboard.tsx +24 -0
  68. package/src/icons/Link.tsx +23 -0
  69. package/src/icons/ZoomIn.tsx +25 -0
  70. package/src/icons/index.ts +3 -0
  71. package/src/index.ts +11 -0
  72. package/src/mantine/EnsureMantine.tsx +14 -0
  73. package/src/mantine/theme.ts +19 -0
  74. package/src/mantine.css.ts +4 -0
  75. package/src/reset.d.ts +2 -0
  76. package/src/state/DiagramContext.tsx +42 -0
  77. package/src/state/WhenInitialized.tsx +24 -0
  78. package/src/state/diagramStore.ts +358 -0
  79. package/src/state/index.ts +4 -0
  80. package/src/state/useDiagramStore.ts +39 -0
  81. package/src/theme.css.ts +64 -0
  82. package/src/ui/Camera.tsx +174 -0
  83. package/src/ui/KeepAspectRatio.css.ts +7 -0
  84. package/src/ui/KeepAspectRatio.tsx +35 -0
  85. package/src/ui/OptionsPanel.css.ts +10 -0
  86. package/src/ui/OptionsPanel.tsx +65 -0
  87. package/src/ui/index.ts +3 -0
  88. package/src/ui/options/NodeOptions.tsx +253 -0
  89. package/src/xyflow/FitviewOnDiagramChange.tsx +93 -0
  90. package/src/xyflow/SyncWithDiagram.tsx +113 -0
  91. package/src/xyflow/XYFlowBackground.tsx +33 -0
  92. package/src/xyflow/XYFlowEvents.tsx +100 -0
  93. package/src/xyflow/XYFlowWrapper.tsx +166 -0
  94. package/src/xyflow/const.ts +7 -0
  95. package/src/xyflow/diagram-to-xyflow.ts +197 -0
  96. package/src/xyflow/edges/RelationshipEdge.tsx +288 -0
  97. package/src/xyflow/edges/edges.css.ts +124 -0
  98. package/src/xyflow/edges/utils.ts +110 -0
  99. package/src/xyflow/hooks/index.ts +2 -0
  100. package/src/xyflow/hooks/useLayoutConstraints.ts +289 -0
  101. package/src/xyflow/hooks/useXYFlow.ts +20 -0
  102. package/src/xyflow/nodes/compound/CompoundNode.css.ts +191 -0
  103. package/src/xyflow/nodes/compound/CompoundNode.tsx +105 -0
  104. package/src/xyflow/nodes/compound/index.ts +1 -0
  105. package/src/xyflow/nodes/element/ElementIcon.tsx +44 -0
  106. package/src/xyflow/nodes/element/ElementLink.css.ts +67 -0
  107. package/src/xyflow/nodes/element/ElementLink.tsx +94 -0
  108. package/src/xyflow/nodes/element/ElementNode.tsx +225 -0
  109. package/src/xyflow/nodes/element/ElementShapeSvg.tsx +181 -0
  110. package/src/xyflow/nodes/element/element.css.ts +261 -0
  111. package/src/xyflow/nodes/element/index.ts +1 -0
  112. package/src/xyflow/nodes/shared/NavigateToBtn.tsx +25 -0
  113. package/src/xyflow/types.ts +100 -0
  114. package/src/xyflow/utils.ts +12 -0
  115. package/dist/EmbeddedLikeC4Diagram.css.d.ts +0 -6
  116. package/dist/EmbeddedLikeC4Diagram.d.ts +0 -18
  117. package/dist/bundle.d.ts +0 -3
  118. package/dist/hooks/use-set-state.d.ts +0 -2
  119. package/dist/hooks/use-update-effect.d.ts +0 -4
  120. package/dist/index.css.d.ts +0 -2
  121. package/dist/index.js +0 -13043
  122. package/dist/mantine/index.d.ts +0 -3
  123. package/dist/state/DiagramState.d.ts +0 -162
  124. package/dist/xyflow/UpdateOnDiagramChange.d.ts +0 -10
@@ -0,0 +1,64 @@
1
+ import { createGlobalThemeContract } from '@vanilla-extract/css'
2
+
3
+ export const vars = createGlobalThemeContract({
4
+ default: {
5
+ font: 'default-font-family'
6
+ },
7
+ likec4: {
8
+ font: 'font-family',
9
+ background: {
10
+ color: 'background-color',
11
+ pattern: {
12
+ color: 'background-pattern-color'
13
+ }
14
+ }
15
+ },
16
+ compound: {
17
+ font: 'compound-font-family',
18
+ titleColor: 'compound-title-color'
19
+ },
20
+ element: {
21
+ font: 'element-font-family',
22
+ fill: 'element-fill',
23
+ stroke: 'element-stroke',
24
+ hiContrast: 'element-hiContrast',
25
+ loContrast: 'element-loContrast'
26
+ },
27
+ relation: {
28
+ lineColor: 'relation-lineColor',
29
+ labelColor: 'relation-labelColor',
30
+ labelBgColor: 'relation-labelBg'
31
+ },
32
+ optionsPanel: {
33
+ top: 'options-panel-top',
34
+ right: 'options-panel-right'
35
+ }
36
+ }, (value) => `likec4-${value}`)
37
+
38
+ export const xyvars = createGlobalThemeContract({
39
+ background: {
40
+ color: 'background-color',
41
+ pattern: {
42
+ color: 'background-pattern-color'
43
+ // dots: 'background-pattern-dots-color',
44
+ // lines: 'background-pattern-lines-color',
45
+ // cross: 'background-pattern-cross-color'
46
+ }
47
+ },
48
+ edge: {
49
+ stroke: 'edge-stroke',
50
+ strokeSelected: 'edge-stroke-selected',
51
+ labelColor: 'edge-label-color',
52
+ labelBgColor: 'edge-label-background-color',
53
+ strokeWidth: 'edge-stroke-width'
54
+ },
55
+ node: {
56
+ color: 'node-color',
57
+ border: 'node-border',
58
+ backgroundColor: 'node-background-color',
59
+ groupBackgroundColor: 'node-group-background-color',
60
+ boxshadowHover: 'node-boxshadow-hover',
61
+ boxshadowSelected: 'node-boxshadow-selected',
62
+ borderRadius: 'node-border-radius'
63
+ }
64
+ }, (value) => `xy-${value}`)
@@ -0,0 +1,174 @@
1
+ import { shallowEqual } from '@mantine/hooks'
2
+ import { useDebouncedEffect, useIsMounted } from '@react-hookz/web'
3
+ import {
4
+ type ReactFlowState,
5
+ useOnSelectionChange,
6
+ useOnViewportChange,
7
+ useStore,
8
+ useUpdateNodeInternals,
9
+ type Viewport
10
+ } from '@xyflow/react'
11
+ import { memo, useEffect, useRef, useState } from 'react'
12
+ import { uniq } from 'remeda'
13
+ import { useXYFlow } from '../xyflow/hooks'
14
+ import type { XYFlowNode } from '../xyflow/types'
15
+ import { distance } from '../xyflow/utils'
16
+
17
+ const selector = (state: ReactFlowState) => {
18
+ return {
19
+ isUserSelectionActive: state.userSelectionActive,
20
+ isDragging: state.paneDragging || state.nodes.some(n => n.dragging ?? false)
21
+ }
22
+ }
23
+
24
+ const CameraMemo = /* @__PURE__ */ memo(function Camera() {
25
+ const isMounted = useIsMounted()
26
+ // const isUserSelectionActive = useStore(selectUserSelectionActive)
27
+ const {
28
+ isUserSelectionActive,
29
+ isDragging
30
+ } = useStore(selector, shallowEqual)
31
+ const reactflow = useXYFlow()
32
+ const updateNd = useUpdateNodeInternals()
33
+
34
+ const padding = 0 // editor.fitViewPadding
35
+ const viewId = '' // editor.viewId
36
+ const fitOnSelect: boolean = false // editor.fitOnSelect
37
+
38
+ const previousViewport = useRef<Viewport | null>(null)
39
+ const isZoomPendingRef = useRef(false)
40
+
41
+ const viewportChangeStart = useRef<Viewport | null>(null)
42
+ const prevViewId = useRef(viewId)
43
+ const isReady = reactflow.viewportInitialized
44
+ const selectedNodesRef = useRef([] as string[])
45
+
46
+ const [selectedNodes, setSelectedNodes] = useState([] as XYFlowNode[])
47
+
48
+ // WORKAROUND
49
+
50
+ useOnViewportChange({
51
+ onStart: (viewport) => {
52
+ viewportChangeStart.current = { ...viewport }
53
+ },
54
+ onEnd: (end) => {
55
+ if (!previousViewport.current) {
56
+ viewportChangeStart.current = null
57
+ }
58
+ if (!viewportChangeStart.current) {
59
+ return
60
+ }
61
+ const start = {
62
+ ...viewportChangeStart.current
63
+ }
64
+ const d = distance(start, end)
65
+ if (d > 15) {
66
+ previousViewport.current = null
67
+ }
68
+ viewportChangeStart.current = null
69
+ }
70
+ })
71
+
72
+ const fixUseOnViewportChange = () => {
73
+ // WORKAROUND:
74
+ // react-flow triggers useOnViewportChange
75
+ // We reset the viewportChangeStart, before useOnViewportChange.onEnd called
76
+ setTimeout(() => {
77
+ viewportChangeStart.current = null
78
+ }, 50)
79
+ }
80
+
81
+ useOnSelectionChange({
82
+ onChange: ({ nodes, edges }) => {
83
+ if (!isMounted() || fitOnSelect === false) {
84
+ return
85
+ }
86
+ if (nodes.length === 0 && edges.length === 0) {
87
+ setSelectedNodes([])
88
+ return
89
+ }
90
+ const selected = new Set([
91
+ ...nodes.map(({ id }) => reactflow.getNode(id)),
92
+ ...edges.flatMap((edge) => [
93
+ reactflow.getNode(edge.source),
94
+ reactflow.getNode(edge.target)
95
+ ])
96
+ ].filter(Boolean))
97
+ setSelectedNodes([...selected])
98
+ }
99
+ })
100
+
101
+ const selectedNodesHash = selectedNodes.map((node) => node.id).sort().join('\n')
102
+ useEffect(() => {
103
+ isZoomPendingRef.current = selectedNodesHash !== ''
104
+ }, [selectedNodesHash])
105
+
106
+ useDebouncedEffect(
107
+ () => {
108
+ if (isUserSelectionActive || isDragging || !isReady) {
109
+ return
110
+ }
111
+ if (selectedNodes.length === 0) {
112
+ if (previousViewport.current) {
113
+ console.log(`Camera: revert viewport`)
114
+ reactflow.setViewport(previousViewport.current, {
115
+ duration: 300
116
+ })
117
+ previousViewport.current = null
118
+ }
119
+ return
120
+ }
121
+ if (!isZoomPendingRef.current) {
122
+ return
123
+ }
124
+ isZoomPendingRef.current = false
125
+ previousViewport.current ??= { ...reactflow.getViewport() }
126
+ const zoom = previousViewport.current.zoom
127
+ reactflow.fitView({
128
+ duration: 350,
129
+ maxZoom: Math.max(1, zoom),
130
+ padding,
131
+ nodes: selectedNodes
132
+ })
133
+ fixUseOnViewportChange()
134
+ },
135
+ [selectedNodesHash, isReady, isDragging, isUserSelectionActive],
136
+ previousViewport.current ? 100 : 1500
137
+ // previousViewport.current ? 200 : 2500
138
+ )
139
+
140
+ useEffect(() => {
141
+ const ids = selectedNodes.map((node) => node.id)
142
+ for (const id of uniq([...selectedNodesRef.current, ...ids])) {
143
+ try {
144
+ updateNd(id)
145
+ } catch (e) {
146
+ console.warn(`Camera: failed useUpdateNodeInternals`, e)
147
+ }
148
+ }
149
+ selectedNodesRef.current = ids
150
+ }, [selectedNodesHash, updateNd])
151
+
152
+ useDebouncedEffect(
153
+ () => {
154
+ if (!isReady || prevViewId.current === viewId) {
155
+ return
156
+ }
157
+ const zoom = previousViewport.current?.zoom ?? reactflow.getZoom()
158
+ reactflow.fitView({
159
+ duration: 400,
160
+ maxZoom: Math.max(1, zoom),
161
+ padding
162
+ })
163
+ previousViewport.current = null
164
+ prevViewId.current = viewId
165
+ },
166
+ [isReady, viewId],
167
+ 50,
168
+ 500
169
+ )
170
+
171
+ return null
172
+ })
173
+
174
+ export default CameraMemo
@@ -0,0 +1,7 @@
1
+ import { style } from '@vanilla-extract/css'
2
+
3
+ export const wrapper = style({
4
+ width: '100%',
5
+ height: '100%',
6
+ padding: 0
7
+ })
@@ -0,0 +1,35 @@
1
+ import { Box } from '@mantine/core'
2
+ import clsx from 'clsx'
3
+ import type { HTMLAttributes } from 'react'
4
+ import { wrapper } from './KeepAspectRatio.css'
5
+
6
+ type KeepAspectRatioProps = HTMLAttributes<HTMLDivElement> & {
7
+ enabled: boolean
8
+ width: number
9
+ height: number
10
+ }
11
+
12
+ export function KeepAspectRatio({
13
+ children,
14
+ width,
15
+ height,
16
+ enabled,
17
+ className,
18
+ style,
19
+ ...rest
20
+ }: KeepAspectRatioProps) {
21
+ return (
22
+ <Box
23
+ {...rest}
24
+ className={clsx(wrapper, className)}
25
+ style={enabled
26
+ ? {
27
+ ...style,
28
+ aspectRatio: `${Math.ceil(width)}/${Math.ceil(height)}`,
29
+ maxHeight: Math.ceil(height)
30
+ }
31
+ : style}>
32
+ {children}
33
+ </Box>
34
+ )
35
+ }
@@ -0,0 +1,10 @@
1
+ import { fallbackVar, style } from '@vanilla-extract/css'
2
+ import { mantine } from '../mantine.css'
3
+ import { vars } from '../theme.css'
4
+
5
+ export const panel = style({
6
+ top: fallbackVar(vars.optionsPanel.top, '1rem'),
7
+ right: fallbackVar(vars.optionsPanel.right, '1rem'),
8
+ margin: 0,
9
+ boxShadow: mantine.shadows.xl
10
+ })
@@ -0,0 +1,65 @@
1
+ import { Card } from '@mantine/core'
2
+ import { useOnSelectionChange } from '@xyflow/react'
3
+ import clsx from 'clsx'
4
+ import { shallowEqual } from 'fast-equals'
5
+ import { AnimatePresence, motion } from 'framer-motion'
6
+ import { memo, useState } from 'react'
7
+ import { NodeOptions } from './options/NodeOptions'
8
+ import * as styles from './OptionsPanel.css'
9
+
10
+ const OptionsPanelMemo = /* @__PURE__ */ memo(function OptionsPanel() {
11
+ const [selectedNodes, setSelectedNodes] = useState([] as string[])
12
+
13
+ useOnSelectionChange({
14
+ onChange: ({ nodes }) => {
15
+ const next = nodes.map(n => n.id).sort()
16
+ setSelectedNodes(prev => {
17
+ return shallowEqual(prev, next) ? prev : next
18
+ })
19
+ // if (nodes.length === 0 && edges.length === 0) {
20
+ // setSelectedNodes([])
21
+ // // setSelectedEdges([])
22
+ // return
23
+ // }
24
+ // const selected = new Set([
25
+ // ...nodes.map((n) => n.id),
26
+ // ...edges.flatMap((edge) => [
27
+ // edge.source,
28
+ // edge.target
29
+ // ])
30
+ // ])
31
+ // setSelectedNodes([...selected])
32
+ // setSelectedEdges(edges.map(e => e.id))
33
+ }
34
+ })
35
+
36
+ // useUpdateEffect(() => {
37
+ // setSelectedNodes([])
38
+ // }, [viewId])
39
+
40
+ return (
41
+ <AnimatePresence mode="wait">
42
+ {selectedNodes.length > 0 && (
43
+ <motion.div
44
+ initial={{ opacity: 0, scale: 0.85 }}
45
+ animate={{ opacity: 1, scale: 1 }}
46
+ exit={{
47
+ opacity: 0,
48
+ scale: 0.85
49
+ }}
50
+ transition={{ duration: 0.14 }}
51
+ className={clsx('react-flow__panel', styles.panel)}
52
+ style={{
53
+ transformOrigin: 'center right'
54
+ }}
55
+ >
56
+ <Card shadow="sm">
57
+ <NodeOptions selectedNodeIds={selectedNodes} />
58
+ </Card>
59
+ </motion.div>
60
+ )}
61
+ </AnimatePresence>
62
+ )
63
+ })
64
+
65
+ export default OptionsPanelMemo
@@ -0,0 +1,3 @@
1
+ // export { default as Camera } from './Camera'
2
+ export { KeepAspectRatio } from './KeepAspectRatio'
3
+ export { default as OptionsPanel } from './OptionsPanel'
@@ -0,0 +1,253 @@
1
+ import {
2
+ defaultTheme,
3
+ type ElementShape,
4
+ ElementShapes,
5
+ type Fqn,
6
+ type NonEmptyArray,
7
+ type ThemeColor
8
+ } from '@likec4/core'
9
+ import {
10
+ Box,
11
+ CheckIcon,
12
+ ColorSwatch,
13
+ Divider,
14
+ Flex,
15
+ rem,
16
+ Select,
17
+ Stack,
18
+ Text,
19
+ Tooltip,
20
+ TooltipGroup
21
+ } from '@mantine/core'
22
+ import { hasAtLeast, keys, takeWhile } from 'remeda'
23
+ import { useDiagramStoreApi } from '../../state'
24
+ import { useXYNodesData } from '../../xyflow/hooks'
25
+ import { XYFlowNode } from '../../xyflow/types'
26
+
27
+ // const ColorPanel = () => {
28
+ // const selectedNodes = useStore(state => state.nodeInternals
29
+ // }
30
+
31
+ const {
32
+ primary,
33
+ secondary,
34
+ muted,
35
+ ...otherColors
36
+ } = defaultTheme.elements
37
+
38
+ export const themedColors = [
39
+ { key: 'primary', value: primary.fill },
40
+ { key: 'secondary', value: secondary.fill },
41
+ { key: 'muted', value: muted.fill }
42
+ ] satisfies Array<{ key: ThemeColor; value: string }>
43
+
44
+ export const colors = keys.strict(otherColors).map(key => ({
45
+ key,
46
+ value: defaultTheme.elements[key].fill
47
+ }))
48
+
49
+ export type ThemeColorKey = typeof themedColors[0]['key']
50
+ export type ColorKey = typeof colors[0]['key']
51
+
52
+ type XYNodesData = Pick<XYFlowNode, 'id' | 'data' | 'type'>
53
+
54
+ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] }) {
55
+ const diagramApi = useDiagramStoreApi()
56
+ const nodes = useXYNodesData(selectedNodeIds)
57
+ if (!hasAtLeast(nodes, 1)) {
58
+ return null
59
+ }
60
+ // Makes sense to show shape option only if there is at least one element node
61
+ const showShapeOption = nodes.some(node => node.type === 'element')
62
+
63
+ const [firstNode, ...rest] = nodes
64
+ return (
65
+ <Stack>
66
+ <Box maw={200}>
67
+ <Text size="xs" c={rest.length > 0 ? 'dimmed' : ''} truncate>
68
+ {rest.length === 0 ? firstNode.data.element.title : `[ multiple ]`}
69
+ </Text>
70
+ </Box>
71
+ {showShapeOption && (
72
+ <ShapeOption
73
+ nodes={nodes}
74
+ onShapeChange={(shape: ElementShape) => {
75
+ const targets = [] as Fqn[]
76
+ for (const nd of nodes) {
77
+ if (nd.data.element.shape !== shape) {
78
+ targets.push(nd.data.element.id)
79
+ }
80
+ }
81
+ if (hasAtLeast(targets, 1)) {
82
+ diagramApi.getState().triggerOnChange([{
83
+ op: 'change-shape',
84
+ shape,
85
+ targets
86
+ }])
87
+ }
88
+ }} />
89
+ )}
90
+ <Colors
91
+ nodes={nodes}
92
+ onColorChange={(color: ColorKey | ThemeColorKey) => {
93
+ const targets = [] as Fqn[]
94
+ for (const nd of nodes) {
95
+ if (nd.data.element.color !== color) {
96
+ targets.push(nd.data.element.id)
97
+ }
98
+ }
99
+ if (hasAtLeast(targets, 1)) {
100
+ diagramApi.getState().triggerOnChange([{
101
+ op: 'change-color',
102
+ color,
103
+ targets
104
+ }])
105
+ }
106
+ }} />
107
+ <NavigateToOption nodes={nodes} />
108
+ </Stack>
109
+ )
110
+ }
111
+
112
+ function Colors({
113
+ nodes: [firstNode, ...nodes],
114
+ onColorChange
115
+ }: {
116
+ nodes: NonEmptyArray<XYNodesData>
117
+ onColorChange: (color: ColorKey | ThemeColorKey) => void
118
+ }) {
119
+ let selectedColor = firstNode.data.element.color as ThemeColor | null
120
+ takeWhile(nodes, node => {
121
+ if (node.data.element.color !== selectedColor) {
122
+ selectedColor = null
123
+ return false
124
+ }
125
+ return true
126
+ })
127
+ const changeColor = (color: ColorKey | ThemeColorKey) => () => {
128
+ if (selectedColor === color) {
129
+ return
130
+ }
131
+ onColorChange(color)
132
+ }
133
+ return (
134
+ <Box>
135
+ <TooltipGroup openDelay={400} closeDelay={300}>
136
+ <Divider label="color" labelPosition="left" />
137
+ <Flex mt={'xs'} maw={150} gap="xs" justify="flex-start" align="flex-start" direction="row" wrap="wrap">
138
+ {themedColors.map(({ key, value }) => (
139
+ <Tooltip
140
+ key={key}
141
+ label={key}
142
+ fz={'xs'}
143
+ color="dark"
144
+ offset={2}
145
+ transitionProps={{ duration: 140, transition: 'slide-up' }}>
146
+ <ColorSwatch
147
+ color={value}
148
+ size={20}
149
+ withShadow
150
+ onClick={changeColor(key)}
151
+ style={{ color: '#fff', cursor: 'pointer' }}
152
+ >
153
+ {selectedColor === key && <CheckIcon style={{ width: rem(12), height: rem(12) }} />}
154
+ </ColorSwatch>
155
+ </Tooltip>
156
+ ))}
157
+ </Flex>
158
+
159
+ <Flex mt="sm" maw={150} gap="xs" justify="flex-start" align="flex-start" direction="row" wrap="wrap">
160
+ {colors.map(({ key, value }) => (
161
+ <Tooltip
162
+ key={key}
163
+ label={key}
164
+ fz={'xs'}
165
+ color="dark"
166
+ offset={2}
167
+ transitionProps={{ duration: 140, transition: 'slide-up' }}>
168
+ <ColorSwatch
169
+ color={value}
170
+ size={20}
171
+ onClick={changeColor(key)}
172
+ style={{ color: '#fff', cursor: 'pointer' }}
173
+ >
174
+ {selectedColor === key && <CheckIcon style={{ width: rem(12), height: rem(12) }} />}
175
+ </ColorSwatch>
176
+ </Tooltip>
177
+ ))}
178
+ </Flex>
179
+ </TooltipGroup>
180
+ </Box>
181
+ )
182
+ }
183
+
184
+ function ShapeOption({
185
+ nodes: [firstNode, ...nodes],
186
+ onShapeChange
187
+ }: {
188
+ nodes: NonEmptyArray<XYNodesData>
189
+ onShapeChange: (shape: ElementShape) => void
190
+ }) {
191
+ let selectedShape = firstNode.data.element.shape as ElementShape | null
192
+ takeWhile(nodes, node => {
193
+ if (node.data.element.shape !== selectedShape) {
194
+ selectedShape = null
195
+ return false
196
+ }
197
+ return true
198
+ })
199
+
200
+ return (
201
+ <Box>
202
+ <Divider label="shape" labelPosition="left" />
203
+ <Select
204
+ mt={'xs'}
205
+ size="xs"
206
+ variant="filled"
207
+ value={selectedShape}
208
+ placeholder={'[ multiple ]'}
209
+ data={ElementShapes}
210
+ allowDeselect={false}
211
+ checkIconPosition="right"
212
+ onChange={(value) => {
213
+ if (!value || value === selectedShape) {
214
+ return
215
+ }
216
+ onShapeChange(value as ElementShape)
217
+ }} />
218
+ </Box>
219
+ )
220
+ }
221
+
222
+ function NavigateToOption({
223
+ nodes: [node, ...nodes]
224
+ }: {
225
+ nodes: NonEmptyArray<XYNodesData>
226
+ }) {
227
+ const isMultiple = nodes.length >= 1
228
+ return (
229
+ <Box>
230
+ <Divider label="navigate to" labelPosition="left" />
231
+ <Select
232
+ mt={'xs'}
233
+ size="xs"
234
+ variant="filled"
235
+ value={!isMultiple ? (node.data.element.navigateTo ?? null) : null}
236
+ disabled={isMultiple}
237
+ placeholder={isMultiple ? '[ multiple ]' : 'select'}
238
+ data={node.data.element.navigateTo
239
+ ? [{ value: node.data.element.navigateTo, label: node.data.element.navigateTo }]
240
+ : []}
241
+ // data={ElementShapes}
242
+ allowDeselect={true}
243
+ checkIconPosition="right"
244
+ // onChange={(value) => {
245
+ // if (!value || value === selectedShape) {
246
+ // return
247
+ // }
248
+ // onShapeChange(value as ElementShape)
249
+ // }} />
250
+ />
251
+ </Box>
252
+ )
253
+ }