You need to sign in or sign up before continuing.
@likec4/diagram 1.12.0 → 1.12.2
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 +10 -10
- package/src/LikeC4CustomColors.tsx +4 -1
- package/src/LikeC4Diagram.tsx +2 -2
- package/src/LikeC4ModelProvider.tsx +6 -34
- package/src/index.ts +7 -1
- package/src/likec4model/useLikeC4Model.ts +5 -2
- package/src/ui/DiagramTitlePanel.tsx +8 -2
- package/src/ui/OptionsPanel.tsx +4 -1
- package/src/ui/notation/NotationPanel.tsx +1 -1
- package/src/ui/options/NodeOptions.tsx +3 -0
- package/src/ui/top-left/ChangeAutoLayoutButton.tsx +133 -53
- package/src/ui/top-left/styles.css.ts +31 -0
- package/src/xyflow/XYFlow.tsx +11 -13
- package/src/xyflow/XYFlowEvents.tsx +6 -5
- package/src/xyflow/XYFlowInner.tsx +0 -4
- package/src/xyflow/nodes/compound/CompoundNode.tsx +21 -4
- package/src/xyflow/nodes/element/ElementLink.tsx +1 -0
- package/src/xyflow/nodes/element/ElementNode.tsx +47 -52
- package/src/xyflow/nodes/shared/Toolbar.tsx +426 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@likec4/diagram",
|
|
3
|
-
"version": "1.12.
|
|
3
|
+
"version": "1.12.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"bugs": "https://github.com/likec4/likec4/issues",
|
|
6
6
|
"homepage": "https://likec4.dev",
|
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
"clean": "rm -r -f -g 'dist/*' .tsbuildinfo"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@likec4/core": "1.12.
|
|
33
|
+
"@likec4/core": "1.12.2",
|
|
34
34
|
"@lume/kiwi": "^0.4.4",
|
|
35
|
-
"@mantine/vanilla-extract": "7.
|
|
35
|
+
"@mantine/vanilla-extract": "7.13.2",
|
|
36
36
|
"@react-hookz/web": "^24.0.4",
|
|
37
|
-
"@tabler/icons-react": "
|
|
37
|
+
"@tabler/icons-react": "3.17.0",
|
|
38
38
|
"@vanilla-extract/css": "^1.16.0",
|
|
39
39
|
"@vanilla-extract/css-utils": "^0.1.4",
|
|
40
40
|
"@vanilla-extract/dynamic": "^2.1.2",
|
|
@@ -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.
|
|
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.
|
|
56
|
-
"@mantine/hooks": "^7.
|
|
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.
|
|
62
|
-
"@mantine/core": "7.
|
|
63
|
-
"@mantine/hooks": "7.
|
|
61
|
+
"@likec4/tsconfig": "1.12.2",
|
|
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
|
}
|
package/src/LikeC4Diagram.tsx
CHANGED
|
@@ -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 {
|
|
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={
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
|
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.
|
|
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.
|
|
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={
|
|
126
|
-
|
|
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"
|
package/src/ui/OptionsPanel.tsx
CHANGED
|
@@ -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
|
-
|
|
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="
|
|
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,77 @@
|
|
|
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
|
-
|
|
11
|
+
UnstyledButton
|
|
14
12
|
} from '@mantine/core'
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
import {
|
|
14
|
+
clampUseMovePosition,
|
|
15
|
+
useHover,
|
|
16
|
+
useMergedRef,
|
|
17
|
+
useMove,
|
|
18
|
+
type UseMovePosition,
|
|
19
|
+
useUncontrolled
|
|
20
|
+
} from '@mantine/hooks'
|
|
21
|
+
import { useDebouncedCallback } from '@react-hookz/web'
|
|
22
|
+
import { IconLayoutDashboard } from '@tabler/icons-react'
|
|
23
|
+
import { forwardRef, useState } from 'react'
|
|
24
|
+
import { type DiagramState, useDiagramState, useDiagramStoreApi } from '../../hooks/useDiagramState'
|
|
18
25
|
import { ActionIcon, Tooltip } from './_shared'
|
|
19
26
|
import * as css from './styles.css'
|
|
20
27
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
color: 'gray'
|
|
28
|
+
const selector = (state: DiagramState) => ({
|
|
29
|
+
viewId: state.view.id,
|
|
30
|
+
autoLayout: state.view.autoLayout
|
|
25
31
|
})
|
|
26
32
|
|
|
27
33
|
export const ChangeAutoLayoutButton = (props: PopoverProps) => {
|
|
28
34
|
const store = useDiagramStoreApi()
|
|
29
35
|
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null)
|
|
30
36
|
const [controlsRefs, setControlsRefs] = useState<Record<AutoLayoutDirection, HTMLButtonElement | null>>({} as any)
|
|
31
|
-
const
|
|
37
|
+
const {
|
|
38
|
+
autoLayout,
|
|
39
|
+
viewId
|
|
40
|
+
} = useDiagramState(selector)
|
|
41
|
+
|
|
42
|
+
const { ref, hovered: isSpacingHovered } = useHover()
|
|
32
43
|
|
|
33
44
|
const setControlRef = (name: AutoLayoutDirection) => (node: HTMLButtonElement) => {
|
|
34
45
|
controlsRefs[name] = node
|
|
35
46
|
setControlsRefs(controlsRefs)
|
|
36
47
|
}
|
|
37
48
|
|
|
38
|
-
const setAutoLayout = (
|
|
49
|
+
const setAutoLayout = (direction: AutoLayoutDirection) => (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
39
50
|
event.stopPropagation()
|
|
40
51
|
store.getState().onChange?.({
|
|
41
52
|
change: {
|
|
42
53
|
op: 'change-autolayout',
|
|
43
|
-
layout
|
|
54
|
+
layout: {
|
|
55
|
+
...autoLayout,
|
|
56
|
+
direction
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const setSpacing = (nodeSep: number, rankSep: number) => {
|
|
63
|
+
// Force fitDiagram
|
|
64
|
+
store.setState({
|
|
65
|
+
viewportChanged: false
|
|
66
|
+
})
|
|
67
|
+
store.getState().onChange?.({
|
|
68
|
+
change: {
|
|
69
|
+
op: 'change-autolayout',
|
|
70
|
+
layout: {
|
|
71
|
+
...autoLayout,
|
|
72
|
+
nodeSep,
|
|
73
|
+
rankSep
|
|
74
|
+
}
|
|
44
75
|
}
|
|
45
76
|
})
|
|
46
77
|
}
|
|
@@ -64,7 +95,7 @@ export const ChangeAutoLayoutButton = (props: PopoverProps) => {
|
|
|
64
95
|
</ActionIcon>
|
|
65
96
|
</Tooltip>
|
|
66
97
|
</PopoverTarget>
|
|
67
|
-
<PopoverDropdown className="likec4-top-left-panel" p={8} pt={
|
|
98
|
+
<PopoverDropdown className="likec4-top-left-panel" p={8} pt={6} opacity={isSpacingHovered ? 0.6 : 1}>
|
|
68
99
|
<Box pos={'relative'} ref={setRootRef}>
|
|
69
100
|
<FloatingIndicator
|
|
70
101
|
target={controlsRefs[autoLayout.direction]}
|
|
@@ -74,46 +105,95 @@ export const ChangeAutoLayoutButton = (props: PopoverProps) => {
|
|
|
74
105
|
<Box mb={10}>
|
|
75
106
|
<Text inline fz={'xs'} c={'dimmed'} fw={500}>Auto layout:</Text>
|
|
76
107
|
</Box>
|
|
77
|
-
<
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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>
|
|
108
|
+
<Flex gap={2} wrap={'wrap'} justify={'stretch'} maw={160}>
|
|
109
|
+
<UnstyledButton className={css.autolayoutButton} ref={setControlRef('TB')} onClick={setAutoLayout('TB')}>
|
|
110
|
+
Top-Bottom
|
|
111
|
+
</UnstyledButton>
|
|
112
|
+
<UnstyledButton className={css.autolayoutButton} ref={setControlRef('BT')} onClick={setAutoLayout('BT')}>
|
|
113
|
+
Bottom-Top
|
|
114
|
+
</UnstyledButton>
|
|
115
|
+
<UnstyledButton className={css.autolayoutButton} ref={setControlRef('LR')} onClick={setAutoLayout('LR')}>
|
|
116
|
+
Left-Right
|
|
117
|
+
</UnstyledButton>
|
|
118
|
+
<UnstyledButton className={css.autolayoutButton} ref={setControlRef('RL')} onClick={setAutoLayout('RL')}>
|
|
119
|
+
Right-Left
|
|
120
|
+
</UnstyledButton>
|
|
121
|
+
</Flex>
|
|
122
|
+
<Box my={10}>
|
|
123
|
+
<Text inline fz={'xs'} c={'dimmed'} fw={500}>Spacing:</Text>
|
|
124
|
+
</Box>
|
|
125
|
+
<SpacingSliders
|
|
126
|
+
ref={ref}
|
|
127
|
+
isVertical={autoLayout.direction === 'TB' || autoLayout.direction === 'BT'}
|
|
128
|
+
key={viewId}
|
|
129
|
+
nodeSep={autoLayout.nodeSep}
|
|
130
|
+
rankSep={autoLayout.rankSep}
|
|
131
|
+
onChange={setSpacing}
|
|
132
|
+
/>
|
|
115
133
|
</Box>
|
|
116
134
|
</PopoverDropdown>
|
|
117
135
|
</Popover>
|
|
118
136
|
)
|
|
119
137
|
}
|
|
138
|
+
|
|
139
|
+
const MAX_SPACING = 400
|
|
140
|
+
const SpacingSliders = forwardRef<HTMLDivElement, {
|
|
141
|
+
isVertical: boolean
|
|
142
|
+
nodeSep: number | undefined
|
|
143
|
+
rankSep: number | undefined
|
|
144
|
+
onChange: (nodeSep: number, rankSep: number) => void
|
|
145
|
+
}>(({
|
|
146
|
+
isVertical,
|
|
147
|
+
nodeSep,
|
|
148
|
+
rankSep,
|
|
149
|
+
onChange
|
|
150
|
+
}, _ref) => {
|
|
151
|
+
if (!isVertical) {
|
|
152
|
+
;[nodeSep, rankSep] = [rankSep, nodeSep]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const propagateChange = useDebouncedCallback(
|
|
156
|
+
({ x, y }: UseMovePosition) => {
|
|
157
|
+
if (!isVertical) {
|
|
158
|
+
;[x, y] = [y, x]
|
|
159
|
+
}
|
|
160
|
+
onChange(Math.round(x * MAX_SPACING), Math.round(y * MAX_SPACING))
|
|
161
|
+
},
|
|
162
|
+
[onChange, isVertical],
|
|
163
|
+
150,
|
|
164
|
+
2000
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const [value, setValue] = useUncontrolled({
|
|
168
|
+
defaultValue: clampUseMovePosition({
|
|
169
|
+
x: (nodeSep ?? 100) / MAX_SPACING,
|
|
170
|
+
y: (rankSep ?? 120) / MAX_SPACING
|
|
171
|
+
}),
|
|
172
|
+
onChange: propagateChange
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const { ref } = useMove(setValue)
|
|
176
|
+
|
|
177
|
+
let nodeSepValue = Math.round(value.x * MAX_SPACING)
|
|
178
|
+
let rankSepValue = Math.round(value.y * MAX_SPACING)
|
|
179
|
+
if (!isVertical) {
|
|
180
|
+
;[nodeSepValue, rankSepValue] = [rankSepValue, nodeSepValue]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const mergedRef = useMergedRef(ref, _ref)
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<Box ref={mergedRef} className={css.spacingSliderBody} pt={'100%'}>
|
|
187
|
+
<Box
|
|
188
|
+
className={css.spacingSliderThumb}
|
|
189
|
+
style={{
|
|
190
|
+
left: `${value.x * 100}%`,
|
|
191
|
+
top: `${value.y * 100}%`
|
|
192
|
+
}}
|
|
193
|
+
/>
|
|
194
|
+
<Box pos={'absolute'} left={2} bottom={2}>
|
|
195
|
+
<Text component="div" fz={8} c={'dimmed'} fw={500}>{rankSepValue}, {nodeSepValue}</Text>
|
|
196
|
+
</Box>
|
|
197
|
+
</Box>
|
|
198
|
+
)
|
|
199
|
+
})
|
|
@@ -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
|
+
})
|
package/src/xyflow/XYFlow.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
113
|
-
|
|
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={
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
>
|
|
@@ -1,19 +1,42 @@
|
|
|
1
|
-
import type
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|