@likec4/diagram 1.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@likec4/diagram",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "license": "MIT",
5
5
  "bugs": "https://github.com/likec4/likec4/issues",
6
6
  "homepage": "https://likec4.dev",
@@ -30,9 +30,9 @@
30
30
  "clean": "rm -r -f -g 'dist/*' .tsbuildinfo"
31
31
  },
32
32
  "dependencies": {
33
- "@likec4/core": "1.12.0",
33
+ "@likec4/core": "1.12.1",
34
34
  "@lume/kiwi": "^0.4.4",
35
- "@mantine/vanilla-extract": "7.12.2",
35
+ "@mantine/vanilla-extract": "7.13.2",
36
36
  "@react-hookz/web": "^24.0.4",
37
37
  "@tabler/icons-react": "^3.19.0",
38
38
  "@vanilla-extract/css": "^1.16.0",
@@ -45,22 +45,22 @@
45
45
  "d3-shape": "^3.2.0",
46
46
  "esm-env": "^1.0.0",
47
47
  "fast-equals": "^5.0.1",
48
- "framer-motion": "^11.5.6",
48
+ "framer-motion": "^11.11.1",
49
49
  "khroma": "^2.1.0",
50
50
  "remeda": "^2.14.0",
51
51
  "type-fest": "4.26.1",
52
52
  "zustand": "^4.5.5"
53
53
  },
54
54
  "peerDependencies": {
55
- "@mantine/core": "^7.12.2",
56
- "@mantine/hooks": "^7.12.2",
55
+ "@mantine/core": "^7.13.2",
56
+ "@mantine/hooks": "^7.13.2",
57
57
  "react": "^18.3.1",
58
58
  "react-dom": "^18.3.1"
59
59
  },
60
60
  "devDependencies": {
61
- "@likec4/tsconfig": "1.12.0",
62
- "@mantine/core": "7.12.2",
63
- "@mantine/hooks": "7.12.2",
61
+ "@likec4/tsconfig": "1.12.1",
62
+ "@mantine/core": "7.13.2",
63
+ "@mantine/hooks": "7.13.2",
64
64
  "@types/bezier-js": "^4.1.3",
65
65
  "@types/d3-shape": "^3.1.6",
66
66
  "@types/react": "^18.3.4",
@@ -2,6 +2,7 @@ import { type ThemeColorValues } from '@likec4/core'
2
2
  import { entries } from 'remeda'
3
3
  import type { LikeC4CustomColorsProperties } from './LikeC4CustomColors.props'
4
4
  import { vars } from './theme-vars'
5
+ import { useMantineStyleNonce } from '@mantine/core'
5
6
 
6
7
  type CSSVarFunction = `var(--${string})` | `var(--${string}, ${string | number} )`
7
8
 
@@ -28,10 +29,12 @@ export function LikeC4CustomColors({ customColors }: LikeC4CustomColorsPropertie
28
29
  const styles = entries(customColors)
29
30
  .map(([name, color]) => toStyle(name, color))
30
31
  .join('\n')
32
+
33
+ const nonce = useMantineStyleNonce()?.()
31
34
 
32
35
  return (
33
36
  <>
34
- <style type="text/css" dangerouslySetInnerHTML={{ __html: styles }} />
37
+ <style type="text/css" dangerouslySetInnerHTML={{ __html: styles }} {...(nonce && { nonce })} />
35
38
  </>
36
39
  )
37
40
  }
@@ -2,7 +2,7 @@ import type { DiagramView } from '@likec4/core'
2
2
  import { ReactFlowProvider as XYFlowProvider } from '@xyflow/react'
3
3
  import clsx from 'clsx'
4
4
  import { shallowEqual } from 'fast-equals'
5
- import { domAnimation, LazyMotion } from 'framer-motion'
5
+ import { domMax, LazyMotion } from 'framer-motion'
6
6
  import { memo, useEffect, useRef } from 'react'
7
7
  import { rootClassName } from './globals.css'
8
8
  import { useDiagramState } from './hooks/useDiagramState'
@@ -76,7 +76,7 @@ export function LikeC4Diagram({
76
76
 
77
77
  return (
78
78
  <EnsureMantine>
79
- <LazyMotion features={domAnimation} strict>
79
+ <LazyMotion features={domMax} strict>
80
80
  <XYFlowProvider
81
81
  fitView={fitView}
82
82
  {...initialRef.current}
@@ -1,48 +1,20 @@
1
- import { LikeC4Model } from '@likec4/core'
2
- import { useCustomCompareMemo } from '@react-hookz/web'
1
+ import type { LikeC4Model } from '@likec4/core'
3
2
  import { type PropsWithChildren } from 'react'
4
- import type { RequireExactlyOne } from 'type-fest'
5
- import { depsShallowEqual } from './hooks/useUpdateEffect'
6
3
  import { LikeC4ModelContext } from './likec4model/LikeC4ModelContext'
7
- import { useLikeC4Model } from './likec4model/useLikeC4Model'
8
4
 
9
- export type LikeC4ModelProviderProps = PropsWithChildren<
10
- RequireExactlyOne<{
11
- likec4model: LikeC4Model
12
- layouted: LikeC4Model.Layouted.SourceModel
13
- computed: LikeC4Model.Computed.SourceModel
14
- }>
15
- >
5
+ export type LikeC4ModelProviderProps = PropsWithChildren<{
6
+ likec4model: LikeC4Model
7
+ }>
16
8
 
17
9
  /**
18
10
  * Ensures LikeC4Model context
19
11
  */
20
12
  export function LikeC4ModelProvider({
21
13
  children,
22
- ...props
14
+ likec4model
23
15
  }: LikeC4ModelProviderProps) {
24
- const model = useLikeC4Model()
25
-
26
- if (model) {
27
- return <>{children}</>
28
- }
29
-
30
- const value = useCustomCompareMemo(
31
- () => {
32
- if (props.likec4model) {
33
- return props.likec4model
34
- }
35
- if (props.layouted) {
36
- return LikeC4Model.layouted(props.layouted)
37
- }
38
- return LikeC4Model.computed(props.computed)
39
- },
40
- [props.likec4model ?? props.layouted ?? props.computed],
41
- depsShallowEqual
42
- )
43
-
44
16
  return (
45
- <LikeC4ModelContext.Provider value={value}>
17
+ <LikeC4ModelContext.Provider value={likec4model}>
46
18
  {children}
47
19
  </LikeC4ModelContext.Provider>
48
20
  )
package/src/index.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import '@xyflow/react/dist/style.css'
2
2
 
3
3
  export { LikeC4Diagram } from './LikeC4Diagram'
4
- export * from './likec4model/useLikeC4Model'
4
+ export {
5
+ useLikeC4DiagramView,
6
+ useLikeC4Model,
7
+ useLikeC4View,
8
+ useLikeC4ViewModel,
9
+ useLikeC4Views
10
+ } from './likec4model/useLikeC4Model'
5
11
  export { LikeC4ModelProvider } from './LikeC4ModelProvider'
6
12
  export { StaticLikeC4Diagram } from './StaticLikeC4Diagram'
7
13
 
@@ -34,12 +34,15 @@ export function useLikeC4ViewModel(viewId: LiteralUnion<ViewID, string>): LikeC4
34
34
  return useLikeC4Model(true).view(viewId)
35
35
  }
36
36
 
37
+ /**
38
+ * Parsed view, computed or layouted
39
+ */
37
40
  export function useLikeC4View(viewId: LiteralUnion<ViewID, string>): ComputedView | DiagramView | null {
38
41
  const model = useLikeC4Model(true)
39
42
  try {
40
43
  return model.view(viewId).view
41
44
  } catch (error) {
42
- console.error(error)
45
+ console.warn(error)
43
46
  return null
44
47
  }
45
48
  }
@@ -49,7 +52,7 @@ export function useLikeC4DiagramView(viewId: LiteralUnion<ViewID, string>): Diag
49
52
  try {
50
53
  return model.view(viewId).view
51
54
  } catch (error) {
52
- console.error(error)
55
+ console.warn(error)
53
56
  return null
54
57
  }
55
58
  }
@@ -70,6 +70,7 @@ export default function DiagramTitlePanel() {
70
70
  onClick={toggle}
71
71
  fw={500}
72
72
  fz={'sm'}
73
+ tabIndex={-1}
73
74
  >
74
75
  {title}
75
76
  </Button>
@@ -87,6 +88,7 @@ export default function DiagramTitlePanel() {
87
88
  variant={'subtle'}
88
89
  color="gray"
89
90
  onClick={toggle}
91
+ tabIndex={-1}
90
92
  >
91
93
  <IconMenu size={11} opacity={0.7} />
92
94
  </Button>
@@ -122,8 +124,12 @@ export default function DiagramTitlePanel() {
122
124
  {description && (
123
125
  <Spoiler
124
126
  maxHeight={42}
125
- showLabel={<Button color="gray" variant="light" fz={'10'} size="compact-xs">show more</Button>}
126
- hideLabel={<Button color="gray" variant="light" fz={'10'} size="compact-xs">hide</Button>}>
127
+ showLabel={
128
+ <Button color="gray" variant="light" fz={'10'} size="compact-xs" tabIndex={-1}>show more</Button>
129
+ }
130
+ hideLabel={
131
+ <Button color="gray" variant="light" fz={'10'} size="compact-xs" tabIndex={-1}>hide</Button>
132
+ }>
127
133
  <Text
128
134
  component={'div'}
129
135
  size="sm"
@@ -8,7 +8,10 @@ import { useDiagramState } from '../hooks/useDiagramState'
8
8
  import { NodeOptions } from './options/NodeOptions'
9
9
  import * as styles from './OptionsPanel.css'
10
10
 
11
- export default function OptionsPanel() {
11
+ /**
12
+ * @deprecated
13
+ */
14
+ export function OptionsPanel() {
12
15
  const { isFocused, viewId } = useDiagramState(s => ({
13
16
  viewId: s.view.id,
14
17
  isFocused: s.focusedNodeId !== null || s.activeWalkthrough !== null
@@ -176,7 +176,7 @@ export function NotationPanel() {
176
176
  <Tooltip label="Show notation" color="dark" fz={'xs'} {...portalProps}>
177
177
  <ActionIcon
178
178
  size={'lg'}
179
- variant="light"
179
+ variant="default"
180
180
  color="gray"
181
181
  radius={'md'}
182
182
  onClick={() => setCollapsed(false)}
@@ -57,6 +57,9 @@ export type ColorKey = typeof colors[0]['key']
57
57
 
58
58
  type XYNodesData = Pick<XYFlowNode, 'id' | 'data' | 'type'>
59
59
 
60
+ /**
61
+ * @deprecated
62
+ */
60
63
  export const NodeOptions = memo<{ selectedNodeIds: string[] }>(({ selectedNodeIds }) => {
61
64
  // export function NodeOptions(props: { nodes: XYFlowNode[] }) {
62
65
  const diagramApi = useDiagramStoreApi()
@@ -1,46 +1,64 @@
1
1
  import type { AutoLayoutDirection } from '@likec4/core'
2
2
  import {
3
- ActionIcon as MantineActionIcon,
4
3
  Box,
4
+ Flex,
5
5
  FloatingIndicator,
6
- Group,
7
6
  Popover,
8
7
  PopoverDropdown,
9
8
  type PopoverProps,
10
9
  PopoverTarget,
11
- Stack,
12
10
  Text,
13
- TooltipGroup
11
+ UnstyledButton
14
12
  } from '@mantine/core'
15
- import { IconArrowBigDownLines, IconLayoutDashboard } from '@tabler/icons-react'
13
+ import { clampUseMovePosition, useMove, type UseMovePosition, useUncontrolled } from '@mantine/hooks'
14
+ import { useDebouncedCallback } from '@react-hookz/web'
15
+ import { IconLayoutDashboard } from '@tabler/icons-react'
16
16
  import { useState } from 'react'
17
- import { useDiagramState, useDiagramStoreApi } from '../../hooks/useDiagramState'
17
+ import { type DiagramState, useDiagramState, useDiagramStoreApi } from '../../hooks/useDiagramState'
18
18
  import { ActionIcon, Tooltip } from './_shared'
19
19
  import * as css from './styles.css'
20
20
 
21
- const DirectionActionIcon = MantineActionIcon.withProps({
22
- size: 'md',
23
- variant: 'subtle',
24
- color: 'gray'
21
+ const selector = (state: DiagramState) => ({
22
+ viewId: state.view.id,
23
+ autoLayout: state.view.autoLayout
25
24
  })
26
25
 
27
26
  export const ChangeAutoLayoutButton = (props: PopoverProps) => {
28
27
  const store = useDiagramStoreApi()
29
28
  const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null)
30
29
  const [controlsRefs, setControlsRefs] = useState<Record<AutoLayoutDirection, HTMLButtonElement | null>>({} as any)
31
- const autoLayout = useDiagramState(s => s.view.autoLayout)
30
+ const {
31
+ autoLayout,
32
+ viewId
33
+ } = useDiagramState(selector)
32
34
 
33
35
  const setControlRef = (name: AutoLayoutDirection) => (node: HTMLButtonElement) => {
34
36
  controlsRefs[name] = node
35
37
  setControlsRefs(controlsRefs)
36
38
  }
37
39
 
38
- const setAutoLayout = (layout: AutoLayoutDirection) => (event: React.MouseEvent<HTMLButtonElement>) => {
40
+ const setAutoLayout = (direction: AutoLayoutDirection) => (event: React.MouseEvent<HTMLButtonElement>) => {
39
41
  event.stopPropagation()
40
42
  store.getState().onChange?.({
41
43
  change: {
42
44
  op: 'change-autolayout',
43
- layout
45
+ layout: {
46
+ ...autoLayout,
47
+ direction
48
+ }
49
+ }
50
+ })
51
+ }
52
+
53
+ const setSpacing = (nodeSep: number, rankSep: number) => {
54
+ store.getState().onChange?.({
55
+ change: {
56
+ op: 'change-autolayout',
57
+ layout: {
58
+ ...autoLayout,
59
+ nodeSep,
60
+ rankSep
61
+ }
44
62
  }
45
63
  })
46
64
  }
@@ -64,7 +82,7 @@ export const ChangeAutoLayoutButton = (props: PopoverProps) => {
64
82
  </ActionIcon>
65
83
  </Tooltip>
66
84
  </PopoverTarget>
67
- <PopoverDropdown className="likec4-top-left-panel" p={8} pt={4}>
85
+ <PopoverDropdown className="likec4-top-left-panel" p={8} pt={6}>
68
86
  <Box pos={'relative'} ref={setRootRef}>
69
87
  <FloatingIndicator
70
88
  target={controlsRefs[autoLayout.direction]}
@@ -74,46 +92,91 @@ export const ChangeAutoLayoutButton = (props: PopoverProps) => {
74
92
  <Box mb={10}>
75
93
  <Text inline fz={'xs'} c={'dimmed'} fw={500}>Auto layout:</Text>
76
94
  </Box>
77
- <TooltipGroup openDelay={100}>
78
- <Stack align="center" gap={1}>
79
- <Tooltip label="Top to Bottom">
80
- <DirectionActionIcon
81
- className={css.autolayoutIcon}
82
- ref={setControlRef('TB')}
83
- onClick={setAutoLayout('TB')}>
84
- <IconArrowBigDownLines />
85
- </DirectionActionIcon>
86
- </Tooltip>
87
- <Group gap={30}>
88
- <Tooltip label="Left to Right">
89
- <DirectionActionIcon
90
- className={css.autolayoutIcon}
91
- ref={setControlRef('LR')}
92
- onClick={setAutoLayout('LR')}>
93
- <IconArrowBigDownLines style={{ rotate: '270deg' }} />
94
- </DirectionActionIcon>
95
- </Tooltip>
96
- <Tooltip label="Right to Left">
97
- <DirectionActionIcon
98
- className={css.autolayoutIcon}
99
- ref={setControlRef('RL')}
100
- onClick={setAutoLayout('RL')}>
101
- <IconArrowBigDownLines style={{ rotate: '90deg' }} />
102
- </DirectionActionIcon>
103
- </Tooltip>
104
- </Group>
105
- <Tooltip label="Bottom to Top">
106
- <DirectionActionIcon
107
- className={css.autolayoutIcon}
108
- ref={setControlRef('BT')}
109
- onClick={setAutoLayout('BT')}>
110
- <IconArrowBigDownLines style={{ rotate: '180deg' }} />
111
- </DirectionActionIcon>
112
- </Tooltip>
113
- </Stack>
114
- </TooltipGroup>
95
+ <Flex gap={2} wrap={'wrap'} justify={'stretch'} maw={160}>
96
+ <UnstyledButton className={css.autolayoutButton} ref={setControlRef('TB')} onClick={setAutoLayout('TB')}>
97
+ Top-Bottom
98
+ </UnstyledButton>
99
+ <UnstyledButton className={css.autolayoutButton} ref={setControlRef('BT')} onClick={setAutoLayout('BT')}>
100
+ Bottom-Top
101
+ </UnstyledButton>
102
+ <UnstyledButton className={css.autolayoutButton} ref={setControlRef('LR')} onClick={setAutoLayout('LR')}>
103
+ Left-Right
104
+ </UnstyledButton>
105
+ <UnstyledButton className={css.autolayoutButton} ref={setControlRef('RL')} onClick={setAutoLayout('RL')}>
106
+ Right-Left
107
+ </UnstyledButton>
108
+ </Flex>
109
+ <Box my={10}>
110
+ <Text inline fz={'xs'} c={'dimmed'} fw={500}>Spacing:</Text>
111
+ </Box>
112
+ <SpacingSliders
113
+ isVertical={autoLayout.direction === 'TB' || autoLayout.direction === 'BT'}
114
+ key={viewId}
115
+ nodeSep={autoLayout.nodeSep}
116
+ rankSep={autoLayout.rankSep}
117
+ onChange={setSpacing}
118
+ />
115
119
  </Box>
116
120
  </PopoverDropdown>
117
121
  </Popover>
118
122
  )
119
123
  }
124
+
125
+ const SpacingSliders = ({
126
+ isVertical,
127
+ nodeSep,
128
+ rankSep,
129
+ onChange
130
+ }: {
131
+ isVertical: boolean
132
+ nodeSep: number | undefined
133
+ rankSep: number | undefined
134
+ onChange: (nodeSep: number, rankSep: number) => void
135
+ }) => {
136
+ if (!isVertical) {
137
+ ;[nodeSep, rankSep] = [rankSep, nodeSep]
138
+ }
139
+
140
+ const propagateChange = useDebouncedCallback(
141
+ ({ x, y }: UseMovePosition) => {
142
+ if (!isVertical) {
143
+ ;[x, y] = [y, x]
144
+ }
145
+ onChange(Math.round(x * 500), Math.round(y * 500))
146
+ },
147
+ [onChange, isVertical],
148
+ 150,
149
+ 2000
150
+ )
151
+
152
+ const [value, setValue] = useUncontrolled({
153
+ defaultValue: clampUseMovePosition({
154
+ x: (nodeSep ?? 250) / 500,
155
+ y: (rankSep ?? 250) / 500
156
+ }),
157
+ onChange: propagateChange
158
+ })
159
+
160
+ const { ref } = useMove(setValue)
161
+
162
+ let nodeSepValue = Math.round(value.x * 500)
163
+ let rankSepValue = Math.round(value.y * 500)
164
+ if (!isVertical) {
165
+ ;[nodeSepValue, rankSepValue] = [rankSepValue, nodeSepValue]
166
+ }
167
+
168
+ return (
169
+ <Box ref={ref} className={css.spacingSliderBody} pt={'100%'}>
170
+ <Box
171
+ className={css.spacingSliderThumb}
172
+ style={{
173
+ left: `${value.x * 100}%`,
174
+ top: `${value.y * 100}%`
175
+ }}
176
+ />
177
+ <Box pos={'absolute'} left={2} bottom={2}>
178
+ <Text component="div" fz={8} c={'dimmed'} fw={500}>{rankSepValue}, {nodeSepValue}</Text>
179
+ </Box>
180
+ </Box>
181
+ )
182
+ }
@@ -1,3 +1,4 @@
1
+ import { rem } from '@mantine/core'
1
2
  import { fallbackVar, globalStyle, style } from '@vanilla-extract/css'
2
3
  import { calc } from '@vanilla-extract/css-utils'
3
4
  import { mantine, vars } from '../../theme-vars'
@@ -34,6 +35,15 @@ globalStyle(`${autolayoutIcon} .tabler-icon`, {
34
35
  height: '65%'
35
36
  })
36
37
 
38
+ export const autolayoutButton = style({
39
+ flex: '1 1 40%',
40
+ textAlign: 'center',
41
+ fontWeight: 500,
42
+ padding: '4px 6px',
43
+ fontSize: rem(11),
44
+ zIndex: 1
45
+ })
46
+
37
47
  export const autolayoutIndicator = style({
38
48
  background: mantine.colors.gray[2],
39
49
  borderRadius: mantine.radius.sm,
@@ -45,3 +55,24 @@ export const autolayoutIndicator = style({
45
55
  }
46
56
  }
47
57
  })
58
+
59
+ export const spacingSliderBody = style({
60
+ position: 'relative',
61
+ borderRadius: mantine.radius.sm,
62
+ background: mantine.colors.gray[3],
63
+ boxShadow: 'inset 1px 1px 3px 0px #00000024',
64
+ selectors: {
65
+ [`:where([data-mantine-color-scheme="dark"]) &`]: {
66
+ background: mantine.colors.dark[7]
67
+ }
68
+ }
69
+ })
70
+
71
+ export const spacingSliderThumb = style({
72
+ position: 'absolute',
73
+ width: 8,
74
+ height: 8,
75
+ border: `2px solid ${mantine.colors.gray[5]}`,
76
+ borderRadius: 3,
77
+ transform: 'translate(-50%, -50%)'
78
+ })
@@ -1,6 +1,6 @@
1
1
  import { ReactFlow, useOnViewportChange } from '@xyflow/react'
2
2
  import { shallowEqual } from 'fast-equals'
3
- import { type CSSProperties, type PropsWithChildren, useEffect, useMemo, useState } from 'react'
3
+ import { type CSSProperties, type PropsWithChildren, useMemo } from 'react'
4
4
  import { useDiagramState } from '../hooks/useDiagramState'
5
5
  import { useXYStoreApi } from '../hooks/useXYFlow'
6
6
  import type { DiagramState } from '../state/diagramStore'
@@ -46,8 +46,8 @@ const selector = (s: DiagramState) => ({
46
46
  nodesDraggable: s.nodesDraggable,
47
47
  fitView: s.fitViewEnabled,
48
48
  fitViewPadding: s.fitViewPadding,
49
- hasOnNavigateTo: !!s.onNavigateTo,
50
- hasOnNodeClick: !!s.onNodeClick,
49
+ // hasOnNavigateTo: !!s.onNavigateTo,
50
+ // hasOnNodeClick: !!s.onNodeClick,
51
51
  hasOnNodeContextMenu: !!s.onNodeContextMenu,
52
52
  hasOnCanvasContextMenu: !!s.onCanvasContextMenu,
53
53
  hasOnEdgeContextMenu: !!s.onEdgeContextMenu,
@@ -79,15 +79,13 @@ export function XYFlow({
79
79
  fitViewPadding,
80
80
  pannable,
81
81
  zoomable,
82
- hasOnNodeClick,
83
- hasOnNavigateTo,
84
82
  hasOnNodeContextMenu,
85
83
  hasOnCanvasContextMenu,
86
84
  hasOnEdgeContextMenu,
87
85
  translateX,
88
86
  translateY
89
87
  } = useDiagramState(selector, shallowEqual)
90
- const [zoomOnDoubleClick, setZoomOnDoubleClick] = useState(zoomable)
88
+ // const [zoomOnDoubleClick, setZoomOnDoubleClick] = useState(zoomable)
91
89
  const layoutConstraints = useLayoutConstraints()
92
90
  const handlers = useXYFlowEvents()
93
91
 
@@ -104,14 +102,14 @@ export function XYFlow({
104
102
  if (x !== roundedX || y !== roundedY) {
105
103
  xyflowApi.setState({ transform: [roundedX, roundedY, zoom] })
106
104
  }
107
- setZoomOnDoubleClick(zoomable && zoom < 0.75)
105
+ // setZoomOnDoubleClick(zoomable && zoom < 0.75)
108
106
  }
109
107
  })
110
108
 
111
- useEffect(() => {
112
- const zoom = xyflowApi.getState().transform[2]
113
- setZoomOnDoubleClick(zoomable && zoom < 0.75)
114
- }, [])
109
+ // useEffect(() => {
110
+ // const zoom = xyflowApi.getState().transform[2]
111
+ // setZoomOnDoubleClick(zoomable && zoom < 0.75)
112
+ // }, [])
115
113
 
116
114
  return (
117
115
  <ReactFlow<XYFlowNode, XYFlowEdge>
@@ -127,7 +125,7 @@ export function XYFlow({
127
125
  {...(!zoomable && {
128
126
  zoomActivationKeyCode: null
129
127
  })}
130
- zoomOnDoubleClick={zoomOnDoubleClick}
128
+ zoomOnDoubleClick={false}
131
129
  maxZoom={zoomable ? MaxZoom : 1}
132
130
  minZoom={zoomable ? MinZoom : 1}
133
131
  fitView={fitView}
@@ -154,7 +152,7 @@ export function XYFlow({
154
152
  selectionKeyCode: null
155
153
  })}
156
154
  elementsSelectable={nodesSelectable}
157
- nodesFocusable={nodesDraggable || nodesSelectable || hasOnNodeClick || hasOnNavigateTo}
155
+ nodesFocusable={nodesDraggable || nodesSelectable}
158
156
  edgesFocusable={false}
159
157
  nodesDraggable={nodesDraggable}
160
158
  {...nodesDraggable && {
@@ -116,7 +116,6 @@ export function useXYFlowEvents() {
116
116
  )
117
117
  },
118
118
  onNodeClick: (event, xynode) => {
119
- console.log('onNodeClick')
120
119
  const {
121
120
  focusedNodeId,
122
121
  fitDiagram,
@@ -137,6 +136,9 @@ export function useXYFlowEvents() {
137
136
 
138
137
  if (clickedRecently && !!onOpenSourceElement) {
139
138
  onOpenSourceElement(xynode.data.element.id)
139
+ if (!onNodeClick) {
140
+ event.stopPropagation()
141
+ }
140
142
  }
141
143
 
142
144
  if (enableFocusMode || nodesSelectable) {
@@ -156,10 +158,9 @@ export function useXYFlowEvents() {
156
158
  break
157
159
  }
158
160
  }
159
- }
160
- if (!onNodeClick) {
161
- // user did not provide a custom handler, stop propagation
162
- event.stopPropagation()
161
+ if (!onNodeClick) {
162
+ event.stopPropagation()
163
+ }
163
164
  }
164
165
  onNodeClick?.(
165
166
  xynode.data.element,
@@ -7,7 +7,6 @@ import type { LikeC4DiagramProperties } from '../LikeC4Diagram.props'
7
7
  import DiagramTitlePanel from '../ui/DiagramTitlePanel'
8
8
  import { DynamicViewWalkthrough } from '../ui/DynamicViewWalkthrough'
9
9
  import NotationPanel from '../ui/notation'
10
- import OptionsPanel from '../ui/OptionsPanel'
11
10
  import { TopLeftPanel } from '../ui/top-left/TopLeftPanel'
12
11
  import { XYFlowBackground } from './XYFlowBackground'
13
12
 
@@ -27,12 +26,10 @@ export const XYFlowInner = /* @__PURE__ */ memo(function XYFlowInnerR({
27
26
  showNotations,
28
27
  isDynamicView,
29
28
  isDynamicViewActive,
30
- readonly,
31
29
  enableDynamicViewWalkthrough
32
30
  } = useDiagramState(s => ({
33
31
  showNotations: s.showNotations,
34
32
  isDynamicView: s.isDynamicView,
35
- readonly: s.readonly,
36
33
  enableDynamicViewWalkthrough: s.enableDynamicViewWalkthrough,
37
34
  isDynamicViewActive: isNonNullish(s.activeWalkthrough)
38
35
  }))
@@ -45,7 +42,6 @@ export const XYFlowInner = /* @__PURE__ */ memo(function XYFlowInnerR({
45
42
  {isBgWithPattern && <XYFlowBackground background={background} />}
46
43
  {controls && <Controls showInteractive={false} position={'top-center'} />}
47
44
  <TopLeftPanel />
48
- {!readonly && <OptionsPanel />}
49
45
  {isDiagramTitleVisible && <DiagramTitlePanel />}
50
46
  {isDynamicView && enableDynamicViewWalkthrough && <DynamicViewWalkthrough />}
51
47
  {showNotations && <NotationPanel />}
@@ -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
  >
@@ -52,6 +52,7 @@ export function ElementLink({
52
52
  }}>
53
53
  <HoverCardTarget>
54
54
  <UnstyledButton
55
+ tabIndex={-1}
55
56
  className={clsx('nodrag nopan', trigger)}
56
57
  autoFocus={false}>
57
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,52 +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
  <>
125
- {
126
- /* <NodeToolbar isVisible={isTollbarVisible} offset={0}>
127
- <ActionIconGroup>
128
- <ActionIcon
129
- size={'md'}
130
- variant="default"
131
- color="dark"
132
- onClick={e => {
133
- e.stopPropagation()
134
- store.getState().onOpenSourceElement?.(element.id)
135
- }}>
136
- <IconFileSymlink
137
- // stroke={1.2}
138
- style={{
139
- width: '70%',
140
- height: '70%'
141
- }} />
142
- </ActionIcon>
143
- <ActionIcon
144
- size={'md'}
145
- variant="default"
146
- color="dark"
147
- >
148
- <ColorSwatch
149
- color={defaultTheme.elements[element.color as ThemeColor]?.fill}
150
- size={16}
151
- withShadow
152
- style={{ color: '#fff', cursor: 'pointer' }}
153
- />
154
- </ActionIcon>
155
- <ActionIcon
156
- size={'md'}
157
- variant="default"
158
- color="dark"
159
- w={'fit-content'}
160
- fz={'xs'}
161
- >
162
- {element.shape}
163
- </ActionIcon>
164
-
165
- </ActionIconGroup>
166
- </NodeToolbar> */
167
- }
168
-
156
+ {isToolbarVisible && (
157
+ <ElementToolbar
158
+ element={element}
159
+ isVisible={isToolbarVisible}
160
+ onColorPreview={setPreviewColor}
161
+ />
162
+ )}
169
163
  <Handle
170
164
  type="target"
171
165
  position={Position.Top}
@@ -180,11 +174,12 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
180
174
  'likec4-element-node'
181
175
  ])}
182
176
  data-hovered={!dragging && isHovered}
183
- data-likec4-color={element.color}
177
+ data-likec4-color={previewColor ?? element.color}
184
178
  data-likec4-shape={element.shape}
185
179
  variants={variants}
186
180
  initial={false}
187
181
  animate={animate}
182
+ tabIndex={-1}
188
183
  {...(isInteractive && {
189
184
  whileTap: dragging ? animate : 'tap'
190
185
  })}
@@ -0,0 +1,426 @@
1
+ import {
2
+ type BorderStyle,
3
+ defaultTheme,
4
+ type Element,
5
+ ElementShapes,
6
+ type Fqn,
7
+ type NonEmptyArray,
8
+ type ThemeColor,
9
+ type ViewChange
10
+ } from '@likec4/core'
11
+ import {
12
+ ActionIcon,
13
+ Box,
14
+ Button,
15
+ CheckIcon,
16
+ ColorSwatch,
17
+ Divider,
18
+ Flex,
19
+ Group,
20
+ keys,
21
+ Menu,
22
+ MenuDropdown,
23
+ MenuItem,
24
+ MenuTarget,
25
+ Paper,
26
+ Popover,
27
+ PopoverDropdown,
28
+ type PopoverProps,
29
+ PopoverTarget,
30
+ rem,
31
+ SegmentedControl,
32
+ Slider,
33
+ Space,
34
+ Stack,
35
+ Text,
36
+ Tooltip,
37
+ TooltipGroup
38
+ } from '@mantine/core'
39
+ import { IconCheck, IconFileSymlink, IconSelector } from '@tabler/icons-react'
40
+ import { NodeToolbar, type NodeToolbarProps } from '@xyflow/react'
41
+ import clsx from 'clsx'
42
+ import { useEffect, useState } from 'react'
43
+ import { useDiagramState, useDiagramStoreApi, useMantinePortalProps, useUpdateEffect } from '../../../hooks'
44
+
45
+ const {
46
+ primary,
47
+ secondary,
48
+ muted,
49
+ ...otherColors
50
+ } = defaultTheme.elements
51
+
52
+ const themedColors = [
53
+ { key: 'primary', value: primary.fill },
54
+ { key: 'secondary', value: secondary.fill },
55
+ { key: 'muted', value: muted.fill }
56
+ ] satisfies Array<{ key: ThemeColor; value: string }>
57
+
58
+ const colors = keys(otherColors).map(key => ({
59
+ key,
60
+ value: defaultTheme.elements[key].fill
61
+ }))
62
+
63
+ type ThemeColorKey = typeof themedColors[0]['key']
64
+ type ColorKey = typeof colors[0]['key']
65
+
66
+ const stopEventPropagation = (e: React.MouseEvent) => e.stopPropagation()
67
+
68
+ type ToolbarProps = NodeToolbarProps & {
69
+ element: Element
70
+ onColorPreview: (color: ThemeColor | null) => void
71
+ }
72
+
73
+ type OnStyleChange = (style: ViewChange.ChangeElementStyle['style']) => void
74
+
75
+ export function CompoundToolbar({
76
+ element,
77
+ onColorPreview,
78
+ ...props
79
+ }: ToolbarProps) {
80
+ const targets = [element.id] as NonEmptyArray<Fqn>
81
+ const diagramApi = useDiagramStoreApi()
82
+ const { hasGoToSource } = useDiagramState(s => ({
83
+ hasGoToSource: !!s.onOpenSourceElement
84
+ }))
85
+
86
+ const onChange: OnStyleChange = (style) => {
87
+ diagramApi.getState().triggerChangeElementStyle({
88
+ op: 'change-element-style',
89
+ style,
90
+ targets
91
+ })
92
+ }
93
+
94
+ return (
95
+ <Toolbar element={element} {...props}>
96
+ <ColorButton
97
+ element={element}
98
+ isOpacityEditable
99
+ onColorPreview={onColorPreview}
100
+ onChange={onChange}
101
+ position="left-start"
102
+ />
103
+ <BorderStyleOption
104
+ elementBorderStyle={element.style?.border}
105
+ onChange={onChange}
106
+ />
107
+ {hasGoToSource && <GoToSourceButton elementId={element.id} />}
108
+ </Toolbar>
109
+ )
110
+ }
111
+
112
+ function Toolbar({ element, children, ...props }: Omit<ToolbarProps, 'onColorPreview'>) {
113
+ return (
114
+ <NodeToolbar {...props}>
115
+ <Paper
116
+ className={clsx('nodrag', 'nopan')}
117
+ px={5}
118
+ pb={8}
119
+ pt={4}
120
+ radius={'sm'}
121
+ shadow="xl"
122
+ onDoubleClickCapture={stopEventPropagation}
123
+ onPointerDown={stopEventPropagation}
124
+ onClick={stopEventPropagation}
125
+ onDoubleClick={stopEventPropagation}
126
+ withBorder>
127
+ <Stack gap={4}>
128
+ <Box pl={2}>
129
+ <Text c="dimmed" fz={10} fw={500}>{element.id}</Text>
130
+ </Box>
131
+ <Group gap={3}>
132
+ {children}
133
+ </Group>
134
+ </Stack>
135
+ </Paper>
136
+ </NodeToolbar>
137
+ )
138
+ }
139
+
140
+ export function ElementToolbar({
141
+ element,
142
+ onColorPreview,
143
+ ...props
144
+ }: ToolbarProps) {
145
+ const targets = [element.id] as NonEmptyArray<Fqn>
146
+ const diagramApi = useDiagramStoreApi()
147
+ const { hasGoToSource } = useDiagramState(s => ({
148
+ hasGoToSource: !!s.onOpenSourceElement
149
+ }))
150
+ const portalProps = useMantinePortalProps()
151
+
152
+ const onChange: OnStyleChange = (style) => {
153
+ diagramApi.getState().triggerChangeElementStyle({
154
+ op: 'change-element-style',
155
+ style,
156
+ targets
157
+ })
158
+ }
159
+
160
+ return (
161
+ <Toolbar element={element} {...props}>
162
+ <Menu
163
+ openDelay={300}
164
+ closeDelay={450}
165
+ floatingStrategy={'fixed'}
166
+ closeOnClickOutside
167
+ clickOutsideEvents={['pointerdown', 'mousedown', 'click']}
168
+ closeOnEscape
169
+ closeOnItemClick={false}
170
+ position="bottom-start"
171
+ offset={2}
172
+ styles={{
173
+ item: {
174
+ padding: 'calc(var(--mantine-spacing-xs) / 1.5) var(--mantine-spacing-xs)'
175
+ }
176
+ }}
177
+ {...portalProps}
178
+ >
179
+ <MenuTarget>
180
+ <Button
181
+ variant="light"
182
+ color="gray"
183
+ size="compact-xs"
184
+ rightSection={<IconSelector size={12} />}
185
+ >
186
+ {element.shape}
187
+ </Button>
188
+ </MenuTarget>
189
+ <MenuDropdown
190
+ // className={css.menuDropdown}
191
+ // onPointerDownCapture={stopEventPropagation}
192
+ // onPointerDown={stopEventPropagation}
193
+ onDoubleClick={stopEventPropagation}
194
+ onClick={stopEventPropagation}
195
+ >
196
+ {ElementShapes.map(shape => (
197
+ <MenuItem
198
+ fz={12}
199
+ fw={500}
200
+ key={shape}
201
+ value={shape}
202
+ rightSection={element.shape === shape ? <IconCheck size={12} /> : undefined}
203
+ onClick={e => {
204
+ e.stopPropagation()
205
+ onChange({ shape })
206
+ }}
207
+ >
208
+ {shape}
209
+ </MenuItem>
210
+ ))}
211
+ </MenuDropdown>
212
+ </Menu>
213
+ <ColorButton
214
+ element={element}
215
+ onColorPreview={onColorPreview}
216
+ onChange={onChange}
217
+ position="right-end"
218
+ />
219
+ {hasGoToSource && <GoToSourceButton elementId={element.id} />}
220
+ </Toolbar>
221
+ )
222
+ }
223
+
224
+ type ColorButtonProps = Omit<PopoverProps, 'onChange'> & {
225
+ element: Element
226
+ isOpacityEditable?: boolean
227
+ onColorPreview: (color: ThemeColor | null) => void
228
+ onChange: OnStyleChange
229
+ }
230
+
231
+ function GoToSourceButton({ elementId }: { elementId: Fqn }) {
232
+ const diagramApi = useDiagramStoreApi()
233
+ return (
234
+ <ActionIcon
235
+ size={'sm'}
236
+ variant="subtle"
237
+ color="gray"
238
+ onClick={e => {
239
+ e.stopPropagation()
240
+ diagramApi.getState().onOpenSourceElement?.(elementId)
241
+ }}>
242
+ <IconFileSymlink size={'70%'} stroke={1.8} />
243
+ </ActionIcon>
244
+ )
245
+ }
246
+
247
+ function ColorButton({
248
+ element,
249
+ onColorPreview,
250
+ isOpacityEditable = false,
251
+ onChange,
252
+ ...props
253
+ }: ColorButtonProps) {
254
+ const elementColor: ThemeColor = (element.color as ThemeColor) ?? 'primary'
255
+ return (
256
+ <Popover
257
+ clickOutsideEvents={['pointerdown', 'mousedown', 'click']}
258
+ position="right-end"
259
+ offset={2}
260
+ {...props}
261
+ >
262
+ <PopoverTarget>
263
+ <Button variant="subtle" color="gray" size="compact-xs" px={3}>
264
+ <ColorSwatch
265
+ color={defaultTheme.elements[elementColor]?.fill}
266
+ size={14}
267
+ withShadow
268
+ style={{ color: '#fff', cursor: 'pointer' }}
269
+ />
270
+ </Button>
271
+ </PopoverTarget>
272
+ <PopoverDropdown p={'xs'}>
273
+ <ColorSwatches
274
+ elementColor={elementColor}
275
+ onColorPreview={onColorPreview}
276
+ onChange={(color) => onChange({ color })}
277
+ />
278
+ {isOpacityEditable && (
279
+ <>
280
+ <Space h={'xs'} />
281
+ <Divider label="opacity" labelPosition="left" />
282
+ <Space h={'xs'} />
283
+ <OpacityOption
284
+ elementOpacity={element.style?.opacity}
285
+ onOpacityChange={(opacity) => {
286
+ onChange({ opacity })
287
+ }} />
288
+ </>
289
+ )}
290
+ </PopoverDropdown>
291
+ </Popover>
292
+ )
293
+ }
294
+
295
+ function ColorSwatches({
296
+ elementColor,
297
+ onColorPreview,
298
+ onChange
299
+ }: {
300
+ elementColor: ThemeColor
301
+ onColorPreview: (color: ThemeColor | null) => void
302
+ onChange: (color: ThemeColor) => void
303
+ }) {
304
+ const changeColor = (color: ColorKey | ThemeColorKey) => (e: React.MouseEvent) => {
305
+ e.stopPropagation()
306
+ onColorPreview(null)
307
+ if (elementColor === color) {
308
+ return
309
+ }
310
+ onChange(color)
311
+ }
312
+ return (
313
+ <Stack gap={2} onMouseLeave={() => onColorPreview(null)}>
314
+ <TooltipGroup openDelay={1000} closeDelay={300}>
315
+ <Flex maw={120} gap="6" justify="flex-start" align="flex-start" direction="row" wrap="wrap">
316
+ {themedColors.map(({ key, value }) => (
317
+ <Tooltip
318
+ key={key}
319
+ label={key}
320
+ fz={'xs'}
321
+ color="dark"
322
+ offset={2}
323
+ transitionProps={{ duration: 140, transition: 'slide-up' }}>
324
+ <ColorSwatch
325
+ color={value}
326
+ size={18}
327
+ withShadow
328
+ onMouseEnter={() => onColorPreview(key)}
329
+ onClick={changeColor(key)}
330
+ style={{ color: '#fff', cursor: 'pointer' }}
331
+ >
332
+ {elementColor === key && <CheckIcon style={{ width: rem(10), height: rem(10) }} />}
333
+ </ColorSwatch>
334
+ </Tooltip>
335
+ ))}
336
+ </Flex>
337
+
338
+ <Flex
339
+ mt="sm"
340
+ maw={110}
341
+ gap="6"
342
+ justify="flex-start"
343
+ align="flex-start"
344
+ direction="row"
345
+ wrap="wrap">
346
+ {colors.map(({ key, value }) => (
347
+ <Tooltip
348
+ key={key}
349
+ label={key}
350
+ fz={'xs'}
351
+ color="dark"
352
+ offset={2}
353
+ transitionProps={{ duration: 140, transition: 'slide-up' }}>
354
+ <ColorSwatch
355
+ color={value}
356
+ size={18}
357
+ onMouseEnter={() => onColorPreview(key)}
358
+ onClick={changeColor(key)}
359
+ style={{ color: '#fff', cursor: 'pointer' }}
360
+ >
361
+ {elementColor === key && <CheckIcon style={{ width: rem(10), height: rem(10) }} />}
362
+ </ColorSwatch>
363
+ </Tooltip>
364
+ ))}
365
+ </Flex>
366
+ </TooltipGroup>
367
+ </Stack>
368
+ )
369
+ }
370
+
371
+ function BorderStyleOption({
372
+ elementBorderStyle = 'dashed',
373
+ onChange
374
+ }: {
375
+ elementBorderStyle: BorderStyle | undefined
376
+ onChange: OnStyleChange
377
+ }) {
378
+ const [value, setValue] = useState(elementBorderStyle)
379
+ useEffect(() => {
380
+ setValue(elementBorderStyle)
381
+ }, [elementBorderStyle])
382
+
383
+ return (
384
+ <Box>
385
+ <SegmentedControl
386
+ size="xs"
387
+ fullWidth
388
+ withItemsBorders={false}
389
+ value={value}
390
+ onChange={v => {
391
+ const border = v as BorderStyle
392
+ setValue(border)
393
+ onChange({ border })
394
+ }}
395
+ data={[
396
+ { label: 'Solid', value: 'solid' },
397
+ { label: 'Dashed', value: 'dashed' },
398
+ { label: 'Dotted', value: 'dotted' },
399
+ { label: 'None', value: 'none' }
400
+ ]}
401
+ />
402
+ </Box>
403
+ )
404
+ }
405
+
406
+ function OpacityOption({
407
+ elementOpacity = 100,
408
+ onOpacityChange
409
+ }: {
410
+ elementOpacity: number | undefined
411
+ onOpacityChange: (opacity: number) => void
412
+ }) {
413
+ const [value, setValue] = useState(elementOpacity)
414
+ useUpdateEffect(() => {
415
+ setValue(elementOpacity)
416
+ }, [elementOpacity])
417
+
418
+ return (
419
+ <Slider
420
+ size={'sm'}
421
+ color={'dark'}
422
+ value={value}
423
+ onChange={setValue}
424
+ onChangeEnd={onOpacityChange} />
425
+ )
426
+ }