@likec4/diagram 1.17.0 → 1.17.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 +21 -17
- package/src/LikeC4Diagram.props.ts +6 -0
- package/src/LikeC4Diagram.tsx +9 -2
- package/src/LikeC4Search.tsx +146 -0
- package/src/StaticLikeC4Diagram.tsx +1 -0
- package/src/overlays/element-details/ElementDetailsCard.tsx +3 -3
- package/src/state/aligners.ts +473 -0
- package/src/state/diagramStore.layout.ts +67 -0
- package/src/state/diagramStore.ts +4 -67
- package/src/ui/top-left/ManualLayoutToolsButton.tsx +15 -0
- package/src/ui/top-left/TopLeftPanel.tsx +14 -1
- package/src/xyflow/nodes/element/ElementNode.tsx +62 -24
- package/src/xyflow/nodes/element/element.css.ts +12 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@likec4/diagram",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"bugs": "https://github.com/likec4/likec4/issues",
|
|
6
6
|
"homepage": "https://likec4.dev",
|
|
@@ -23,44 +23,47 @@
|
|
|
23
23
|
"exports": {
|
|
24
24
|
".": "./src/index.ts"
|
|
25
25
|
},
|
|
26
|
-
"packageManager": "yarn@4.5.
|
|
26
|
+
"packageManager": "yarn@4.5.3",
|
|
27
27
|
"scripts": {
|
|
28
28
|
"typecheck": "tsc --noEmit",
|
|
29
29
|
"lint": "run -T eslint src/ --fix",
|
|
30
|
-
"clean": "rm -r -f -g 'dist/*' .tsbuildinfo"
|
|
30
|
+
"clean": "rm -r -f -g 'dist/*' .tsbuildinfo",
|
|
31
|
+
"test": "vitest run --no-isolate"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
34
|
"@dagrejs/dagre": "^1.1.4",
|
|
34
|
-
"@likec4/core": "1.17.
|
|
35
|
-
"@mantine/vanilla-extract": "7.
|
|
35
|
+
"@likec4/core": "1.17.1",
|
|
36
|
+
"@mantine/vanilla-extract": "7.14.1",
|
|
36
37
|
"@react-hookz/web": "^24.0.4",
|
|
37
38
|
"@tabler/icons-react": "3.17.0",
|
|
38
|
-
"@vanilla-extract/css": "^1.16.
|
|
39
|
+
"@vanilla-extract/css": "^1.16.1",
|
|
39
40
|
"@vanilla-extract/css-utils": "^0.1.4",
|
|
40
41
|
"@vanilla-extract/dynamic": "^2.1.2",
|
|
41
|
-
"@xyflow/react": "12.3.
|
|
42
|
-
"@xyflow/system": "0.0.
|
|
42
|
+
"@xyflow/react": "12.3.5",
|
|
43
|
+
"@xyflow/system": "0.0.46",
|
|
43
44
|
"bezier-js": "^6.1.4",
|
|
44
45
|
"clsx": "^2.1.1",
|
|
45
46
|
"d3-shape": "^3.2.0",
|
|
46
47
|
"esm-env": "^1.1.4",
|
|
47
48
|
"fast-equals": "^5.0.1",
|
|
48
|
-
"framer-motion": "^11.11.
|
|
49
|
+
"framer-motion": "^11.11.17",
|
|
49
50
|
"khroma": "^2.1.0",
|
|
50
|
-
"remeda": "^2.
|
|
51
|
-
"type-fest": "4.
|
|
51
|
+
"remeda": "^2.17.3",
|
|
52
|
+
"type-fest": "4.27.1",
|
|
52
53
|
"zustand": "^4.5.5"
|
|
53
54
|
},
|
|
54
55
|
"peerDependencies": {
|
|
55
|
-
"@mantine/core": "^7.
|
|
56
|
-
"@mantine/hooks": "^7.
|
|
56
|
+
"@mantine/core": "^7.14.1",
|
|
57
|
+
"@mantine/hooks": "^7.14.1",
|
|
58
|
+
"@mantine/spotlight": "^7.14.1",
|
|
57
59
|
"react": "^18.3.1",
|
|
58
60
|
"react-dom": "^18.3.1"
|
|
59
61
|
},
|
|
60
62
|
"devDependencies": {
|
|
61
|
-
"@likec4/tsconfig": "1.17.
|
|
62
|
-
"@mantine/core": "7.
|
|
63
|
-
"@mantine/hooks": "7.
|
|
63
|
+
"@likec4/tsconfig": "1.17.1",
|
|
64
|
+
"@mantine/core": "7.14.1",
|
|
65
|
+
"@mantine/hooks": "7.14.1",
|
|
66
|
+
"@mantine/spotlight": "7.14.1",
|
|
64
67
|
"@types/bezier-js": "^4.1.3",
|
|
65
68
|
"@types/d3-shape": "^3.1.6",
|
|
66
69
|
"@types/react": "^18.3.11",
|
|
@@ -68,6 +71,7 @@
|
|
|
68
71
|
"execa": "^9.3.1",
|
|
69
72
|
"react": "^18.3.1",
|
|
70
73
|
"react-dom": "^18.3.1",
|
|
71
|
-
"typescript": "^5.
|
|
74
|
+
"typescript": "^5.7.2",
|
|
75
|
+
"vitest": "^2.1.5"
|
|
72
76
|
}
|
|
73
77
|
}
|
|
@@ -148,6 +148,12 @@ export interface LikeC4DiagramProperties {
|
|
|
148
148
|
*/
|
|
149
149
|
enableFocusMode?: boolean | undefined
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Enable search popup for elements and views
|
|
153
|
+
* @default true
|
|
154
|
+
*/
|
|
155
|
+
enableSearch?: boolean | undefined
|
|
156
|
+
|
|
151
157
|
/**
|
|
152
158
|
* Enable modal with element details
|
|
153
159
|
* @default false
|
package/src/LikeC4Diagram.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ReactFlowProvider as XYFlowProvider } from '@xyflow/react'
|
|
2
2
|
import clsx from 'clsx'
|
|
3
|
-
import { deepEqual
|
|
3
|
+
import { deepEqual } from 'fast-equals'
|
|
4
4
|
import { memo, useEffect, useRef } from 'react'
|
|
5
5
|
import { isEmpty } from 'remeda'
|
|
6
6
|
import { rootClassName } from './globals.css'
|
|
@@ -9,6 +9,7 @@ import { LikeC4CustomColors } from './LikeC4CustomColors'
|
|
|
9
9
|
import * as css from './LikeC4Diagram.css'
|
|
10
10
|
import { type LikeC4DiagramEventHandlers, type LikeC4DiagramProperties } from './LikeC4Diagram.props'
|
|
11
11
|
import { useLikeC4Model } from './likec4model'
|
|
12
|
+
import { LikeC4Search } from './LikeC4Search'
|
|
12
13
|
import { Overlays } from './overlays'
|
|
13
14
|
import { DiagramContextProvider } from './state/DiagramContext'
|
|
14
15
|
import { EnsureMantine } from './ui/EnsureMantine'
|
|
@@ -39,6 +40,7 @@ export function LikeC4Diagram({
|
|
|
39
40
|
enableElementDetails = false,
|
|
40
41
|
enableRelationshipDetails = enableElementDetails,
|
|
41
42
|
enableRelationshipBrowser = enableRelationshipDetails,
|
|
43
|
+
enableSearch = true,
|
|
42
44
|
initialWidth,
|
|
43
45
|
initialHeight,
|
|
44
46
|
keepAspectRatio = false,
|
|
@@ -111,6 +113,7 @@ export function LikeC4Diagram({
|
|
|
111
113
|
showNotations={showNotations}
|
|
112
114
|
enableFocusMode={enableFocusMode}
|
|
113
115
|
enableRelationshipDetails={enableRelationshipDetails && hasLikec4model}
|
|
116
|
+
enableSearch={hasLikec4model && enableSearch}
|
|
114
117
|
enableElementDetails={enableElementDetails && hasLikec4model}
|
|
115
118
|
enableDynamicViewWalkthrough={enableDynamicViewWalkthrough}
|
|
116
119
|
enableRelationshipBrowser={enableRelationshipBrowser && hasLikec4model}
|
|
@@ -134,6 +137,7 @@ export function LikeC4Diagram({
|
|
|
134
137
|
<LikeC4DiagramInnerMemo
|
|
135
138
|
background={background}
|
|
136
139
|
showDiagramTitle={showDiagramTitle}
|
|
140
|
+
enableSearch={hasLikec4model && enableSearch}
|
|
137
141
|
/>
|
|
138
142
|
</DiagramContextProvider>
|
|
139
143
|
</XYFlowProvider>
|
|
@@ -146,10 +150,12 @@ LikeC4Diagram.displayName = 'LikeC4Diagram'
|
|
|
146
150
|
type LikeC4DiagramInnerProps = {
|
|
147
151
|
background: NonNullable<LikeC4DiagramProperties['background']>
|
|
148
152
|
showDiagramTitle: boolean
|
|
153
|
+
enableSearch: boolean
|
|
149
154
|
}
|
|
150
155
|
const LikeC4DiagramInnerMemo = /* @__PURE__ */ memo<LikeC4DiagramInnerProps>(function LikeC4DiagramInner({
|
|
151
156
|
background,
|
|
152
|
-
showDiagramTitle
|
|
157
|
+
showDiagramTitle,
|
|
158
|
+
enableSearch
|
|
153
159
|
}) {
|
|
154
160
|
const {
|
|
155
161
|
isInitialized,
|
|
@@ -183,6 +189,7 @@ const LikeC4DiagramInnerMemo = /* @__PURE__ */ memo<LikeC4DiagramInnerProps>(fun
|
|
|
183
189
|
/>
|
|
184
190
|
</XYFlow>
|
|
185
191
|
{enableOverlays && <Overlays />}
|
|
192
|
+
{enableSearch && <LikeC4Search />}
|
|
186
193
|
{isInitialized && (
|
|
187
194
|
<>
|
|
188
195
|
{fitView && <FitViewOnDiagramChange />}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { DiagramView } from '@likec4/core'
|
|
2
|
+
import { Spotlight, SpotlightActionsGroup, type SpotlightActionData } from '@mantine/spotlight'
|
|
3
|
+
import { IconRectangularPrism, IconSitemap, IconSearch } from '@tabler/icons-react'
|
|
4
|
+
import { memo, useMemo, useState } from 'react'
|
|
5
|
+
import { filter, map, pipe } from 'remeda'
|
|
6
|
+
import { useDiagramState, useDiagramStoreApi, useMantinePortalProps } from './hooks'
|
|
7
|
+
import { useLikeC4Model } from './likec4model'
|
|
8
|
+
import { Center, Group, Highlight, Text } from '@mantine/core'
|
|
9
|
+
|
|
10
|
+
interface QueryMatch {
|
|
11
|
+
data: SpotlightActionData,
|
|
12
|
+
keyword: string,
|
|
13
|
+
match: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function filterAction(actionData: SpotlightActionData, query: string) : QueryMatch | undefined {
|
|
17
|
+
const queryLower = query.toLowerCase();
|
|
18
|
+
const keywords = Array.isArray(actionData.keywords) ? actionData.keywords : [];
|
|
19
|
+
const keywordMatch = keywords
|
|
20
|
+
.map(keyword => {
|
|
21
|
+
const index = keyword.toLowerCase().indexOf(queryLower);
|
|
22
|
+
return index !== -1 ? {
|
|
23
|
+
keyword: keyword,
|
|
24
|
+
match: keyword.substring(index, index + query.length)
|
|
25
|
+
} : null;
|
|
26
|
+
})
|
|
27
|
+
.find(match => !!match);
|
|
28
|
+
return !!keywordMatch ? {
|
|
29
|
+
data: actionData,
|
|
30
|
+
keyword: keywordMatch.keyword,
|
|
31
|
+
match: keywordMatch.match
|
|
32
|
+
} : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildFilteredActions(actionsData: SpotlightActionData[], query: string): JSX.Element[] {
|
|
36
|
+
return pipe(
|
|
37
|
+
actionsData,
|
|
38
|
+
map(a => filterAction(a, query)),
|
|
39
|
+
filter(qm => !!qm),
|
|
40
|
+
map(qm => buildSpotlightAction(qm))
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function highlightMatch(match: QueryMatch): JSX.Element {
|
|
45
|
+
return <Highlight highlight={match.match}>{match.keyword}</Highlight>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildSpotlightAction(matchedAction: QueryMatch): JSX.Element {
|
|
49
|
+
const isMatchInLabel = matchedAction.keyword == matchedAction.data.label;
|
|
50
|
+
return <Spotlight.Action onClick={matchedAction.data.onClick}>
|
|
51
|
+
<Center style={{ marginRight: '8px '}}>
|
|
52
|
+
{matchedAction.data.leftSection}
|
|
53
|
+
</Center>
|
|
54
|
+
<Group wrap="nowrap" w="100%">
|
|
55
|
+
<div style={{ flex: 1 }}>
|
|
56
|
+
<Text>
|
|
57
|
+
{isMatchInLabel ? highlightMatch(matchedAction) : matchedAction.data.label}
|
|
58
|
+
</Text>
|
|
59
|
+
{!isMatchInLabel && <Text opacity={0.6} size="xs">{highlightMatch(matchedAction)}</Text>}
|
|
60
|
+
</div>
|
|
61
|
+
</Group>
|
|
62
|
+
</Spotlight.Action>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const LikeC4Search = memo(() => {
|
|
66
|
+
const view = useDiagramState(s => s.view)
|
|
67
|
+
const model = useLikeC4Model(true)
|
|
68
|
+
const store = useDiagramStoreApi()
|
|
69
|
+
const [ query, setQuery ] = useState('')
|
|
70
|
+
|
|
71
|
+
const getNodeActionsData = (): SpotlightActionData[] => {
|
|
72
|
+
const { focusOnNode } = store.getState()
|
|
73
|
+
|
|
74
|
+
return pipe(
|
|
75
|
+
view.nodes,
|
|
76
|
+
filter(n => !!n.title),
|
|
77
|
+
map(n => ({
|
|
78
|
+
id: n.id,
|
|
79
|
+
label: n.title,
|
|
80
|
+
keywords: [
|
|
81
|
+
n.title,
|
|
82
|
+
...(n.tags ?? []).map(t => `#${t}`),
|
|
83
|
+
...(n.description ? [n.description] : [])
|
|
84
|
+
].filter(k => k.toLowerCase()),
|
|
85
|
+
onClick: () => focusOnNode(n.id),
|
|
86
|
+
leftSection: <IconRectangularPrism />
|
|
87
|
+
}))
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const getViewActionsData = (): SpotlightActionData[] => {
|
|
92
|
+
const { onNavigateTo } = store.getState()
|
|
93
|
+
const views = model.views()
|
|
94
|
+
|
|
95
|
+
return map(views, v => ({
|
|
96
|
+
id: v.id,
|
|
97
|
+
label: v.title ?? v.id,
|
|
98
|
+
keywords: [
|
|
99
|
+
v.title ?? v.id,
|
|
100
|
+
...(v.tags ?? []),
|
|
101
|
+
...(v.view.description ? [v.view.description] : [])
|
|
102
|
+
],
|
|
103
|
+
onClick: () => {
|
|
104
|
+
store.setState({
|
|
105
|
+
hoveredNodeId: null,
|
|
106
|
+
lastOnNavigate: {
|
|
107
|
+
fromView: view.id,
|
|
108
|
+
toView: v.id,
|
|
109
|
+
fromNode: null
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
onNavigateTo?.(v.id)
|
|
113
|
+
},
|
|
114
|
+
leftSection: <IconSitemap />
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { nodeActions, viewActions } = useMemo(() => ({
|
|
119
|
+
nodeActions: buildFilteredActions(getNodeActionsData(), query),
|
|
120
|
+
viewActions: buildFilteredActions(getViewActionsData(), query)
|
|
121
|
+
}),
|
|
122
|
+
[model, store, view, query]
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const portalProps = useMantinePortalProps();
|
|
126
|
+
return (
|
|
127
|
+
<Spotlight.Root
|
|
128
|
+
{...portalProps}
|
|
129
|
+
shortcut={['mod + f', 'mod + k']}
|
|
130
|
+
query={query}
|
|
131
|
+
onQueryChange={setQuery}
|
|
132
|
+
scrollable
|
|
133
|
+
maxHeight={'calc(100vh - 100px)'}
|
|
134
|
+
>
|
|
135
|
+
<Spotlight.Search
|
|
136
|
+
placeholder="Search elements in current view and other views..."
|
|
137
|
+
leftSection={<IconSearch stroke={1.5} />}
|
|
138
|
+
/>
|
|
139
|
+
<Spotlight.ActionsList>
|
|
140
|
+
{nodeActions.length > 0 && <SpotlightActionsGroup label="Elements">{nodeActions}</SpotlightActionsGroup> }
|
|
141
|
+
{viewActions.length > 0 && <SpotlightActionsGroup label="Views">{viewActions}</SpotlightActionsGroup> }
|
|
142
|
+
{nodeActions.length == 0 && viewActions.length == 0 && <Spotlight.Empty>Nothing found...</Spotlight.Empty> }
|
|
143
|
+
</Spotlight.ActionsList>
|
|
144
|
+
</Spotlight.Root>
|
|
145
|
+
);
|
|
146
|
+
});
|
|
@@ -278,8 +278,8 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
|
|
|
278
278
|
<Box
|
|
279
279
|
className={css.cardHeader}
|
|
280
280
|
onPointerDown={e => controls.start(e)}>
|
|
281
|
-
<Group align="start" justify="space-between" gap={'sm'} mb={'sm'}>
|
|
282
|
-
<Group align="start" gap={'sm'} style={{ cursor: 'default' }}>
|
|
281
|
+
<Group align="start" justify="space-between" gap={'sm'} mb={'sm'} wrap="nowrap">
|
|
282
|
+
<Group align="start" gap={'sm'} style={{ cursor: 'default' }} wrap="nowrap">
|
|
283
283
|
{elementIcon}
|
|
284
284
|
<Box>
|
|
285
285
|
<Text
|
|
@@ -304,7 +304,7 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
|
|
|
304
304
|
}}
|
|
305
305
|
/>
|
|
306
306
|
</Group>
|
|
307
|
-
<Group align="baseline" gap={'sm'}>
|
|
307
|
+
<Group align="baseline" gap={'sm'} wrap="nowrap">
|
|
308
308
|
<Box>
|
|
309
309
|
<SmallLabel>kind</SmallLabel>
|
|
310
310
|
<Badge radius={'sm'} size="sm" fw={600} color="gray">{element.kind}</Badge>
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { invariant, type NonEmptyArray } from '@likec4/core'
|
|
2
|
+
import type { Rect, XYPosition } from '@xyflow/react'
|
|
3
|
+
import { map, pick, pipe, reduce, sortBy } from 'remeda'
|
|
4
|
+
|
|
5
|
+
export type GridAlignmentMode = 'Column' | 'Row'
|
|
6
|
+
export type LinearAlignmentMode = 'Left' | 'Center' | 'Right' | 'Top' | 'Middle' | 'Bottom'
|
|
7
|
+
|
|
8
|
+
export abstract class Aligner {
|
|
9
|
+
abstract computeLayout(nodes: NodeRect[]): void
|
|
10
|
+
abstract applyPosition(node: NodeRect): Partial<XYPosition>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class LinearAligner extends Aligner {
|
|
14
|
+
private alignTo: number | undefined
|
|
15
|
+
constructor(
|
|
16
|
+
private getEdgePosition: (nodes: NodeRect[]) => number,
|
|
17
|
+
private computePosition: (alignTo: number, node: NodeRect) => number,
|
|
18
|
+
private propertyToEdit: keyof XYPosition
|
|
19
|
+
) {
|
|
20
|
+
super()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
override computeLayout(nodes: NodeRect[]) {
|
|
24
|
+
this.alignTo = this.getEdgePosition(nodes)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override applyPosition(node: NodeRect): Partial<XYPosition> {
|
|
28
|
+
return {
|
|
29
|
+
[this.propertyToEdit]: this.computePosition(this.alignTo!, node)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type NodeRect = Rect & { id: string }
|
|
35
|
+
type Layer = {
|
|
36
|
+
primaryAxisSize: number
|
|
37
|
+
nodes: NodeRect[]
|
|
38
|
+
occupiedSpace: number
|
|
39
|
+
layout: Layout | null
|
|
40
|
+
}
|
|
41
|
+
type Layout = {
|
|
42
|
+
nodePositions: Map<string, XYPosition>
|
|
43
|
+
refLayer: Layer | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class GridAligner extends Aligner {
|
|
47
|
+
private layout: Map<string, XYPosition> = new Map()
|
|
48
|
+
private axisPreset: {
|
|
49
|
+
primaryAxisDimension: 'height' | 'width'
|
|
50
|
+
secondaryAxisDimension: 'height' | 'width'
|
|
51
|
+
primaryAxisCoord: 'x' | 'y'
|
|
52
|
+
secondaryAxisCoord: 'x' | 'y'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private get primaryAxisCoord() {
|
|
56
|
+
return this.axisPreset.primaryAxisCoord
|
|
57
|
+
}
|
|
58
|
+
private get secondaryAxisCoord() {
|
|
59
|
+
return this.axisPreset.secondaryAxisCoord
|
|
60
|
+
}
|
|
61
|
+
private get primaryAxisDimension() {
|
|
62
|
+
return this.axisPreset.primaryAxisDimension
|
|
63
|
+
}
|
|
64
|
+
private get secondaryAxisDimension() {
|
|
65
|
+
return this.axisPreset.secondaryAxisDimension
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
constructor(alignmentMode: GridAlignmentMode) {
|
|
69
|
+
super()
|
|
70
|
+
|
|
71
|
+
this.axisPreset = alignmentMode === 'Column'
|
|
72
|
+
? {
|
|
73
|
+
primaryAxisDimension: 'width',
|
|
74
|
+
secondaryAxisDimension: 'height',
|
|
75
|
+
primaryAxisCoord: 'x',
|
|
76
|
+
secondaryAxisCoord: 'y'
|
|
77
|
+
}
|
|
78
|
+
: {
|
|
79
|
+
primaryAxisDimension: 'height',
|
|
80
|
+
secondaryAxisDimension: 'width',
|
|
81
|
+
primaryAxisCoord: 'y',
|
|
82
|
+
secondaryAxisCoord: 'x'
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override applyPosition(node: NodeRect): Partial<XYPosition> {
|
|
87
|
+
return this.layout?.get(node.id) ?? {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override computeLayout(nodes: NodeRect[]) {
|
|
91
|
+
// Sort by primary axis
|
|
92
|
+
const sortedNodeRects = pipe(
|
|
93
|
+
nodes,
|
|
94
|
+
sortBy(r => r[this.primaryAxisCoord])
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const layoutRect = this.getLayoutRect(sortedNodeRects)
|
|
98
|
+
|
|
99
|
+
const layers = this.getLayers(sortedNodeRects)
|
|
100
|
+
|
|
101
|
+
this.layout = this.buildLayout(layers, layoutRect, sortedNodeRects)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private getLayoutRect(nodeRects: NodeRect[]): Rect {
|
|
105
|
+
const x = Math.min(...nodeRects.map(n => n.x))
|
|
106
|
+
const y = Math.min(...nodeRects.map(n => n.y))
|
|
107
|
+
const right = Math.max(...nodeRects.map(n => n.x + n.width))
|
|
108
|
+
const bottom = Math.max(...nodeRects.map(n => n.y + n.height))
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
x,
|
|
112
|
+
y,
|
|
113
|
+
width: right - x,
|
|
114
|
+
height: bottom - y
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private getLayers(sortedNodeRects: NodeRect[]): NonEmptyArray<Layer> {
|
|
119
|
+
const layers: Layer[] = []
|
|
120
|
+
let layerEnd = 0
|
|
121
|
+
let layer = null
|
|
122
|
+
|
|
123
|
+
for (let node of sortedNodeRects) {
|
|
124
|
+
if (!!layer && node[this.primaryAxisCoord] < layerEnd) {
|
|
125
|
+
layer.nodes.push(node)
|
|
126
|
+
layer.primaryAxisSize = Math.max(layer.primaryAxisSize, node[this.primaryAxisDimension])
|
|
127
|
+
layer.occupiedSpace += node[this.secondaryAxisDimension]
|
|
128
|
+
layerEnd = Math.max(
|
|
129
|
+
node[this.primaryAxisCoord] + node[this.primaryAxisDimension],
|
|
130
|
+
layerEnd
|
|
131
|
+
)
|
|
132
|
+
} else {
|
|
133
|
+
layer = {
|
|
134
|
+
primaryAxisSize: node[this.primaryAxisDimension],
|
|
135
|
+
nodes: [node],
|
|
136
|
+
occupiedSpace: node[this.secondaryAxisDimension],
|
|
137
|
+
layout: null
|
|
138
|
+
}
|
|
139
|
+
layers.push(layer)
|
|
140
|
+
layerEnd = node[this.primaryAxisCoord] + node[this.primaryAxisDimension]
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
layers.forEach(l =>
|
|
146
|
+
l.nodes.sort((a, b) => a[this.secondaryAxisCoord] - b[this.secondaryAxisCoord])
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return layers as NonEmptyArray<Layer>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private buildLayout(layers: NonEmptyArray<Layer>, layoutRect: Rect, nodeRects: NodeRect[]): Map<string, XYPosition> {
|
|
153
|
+
const nodeMap = new Map(nodeRects.map(n => [n.id, n]))
|
|
154
|
+
const layout: [string, XYPosition][] = []
|
|
155
|
+
const occupiedSpace = layers.reduce((a, b) => a + b.primaryAxisSize, 0)
|
|
156
|
+
const rowMargin = layers.length > 1
|
|
157
|
+
? (layoutRect[this.primaryAxisDimension] - occupiedSpace) / (layers.length - 1)
|
|
158
|
+
: 0
|
|
159
|
+
|
|
160
|
+
// Find the widest layer and layout diagram from there
|
|
161
|
+
const baseLayerIndex = layers.reduce(
|
|
162
|
+
(widestLayerIndex, layer, i) =>
|
|
163
|
+
layers[widestLayerIndex]!.occupiedSpace < layer.occupiedSpace ? i : widestLayerIndex,
|
|
164
|
+
0
|
|
165
|
+
)
|
|
166
|
+
const baseLayer = layers[baseLayerIndex]!
|
|
167
|
+
const baseLayerPosition = layers.slice(0, baseLayerIndex).reduce(
|
|
168
|
+
(a, layer) => a + layer.primaryAxisSize + rowMargin,
|
|
169
|
+
layoutRect[this.primaryAxisCoord]
|
|
170
|
+
)
|
|
171
|
+
const baseLayerLayout = this.buildLayerLayout(
|
|
172
|
+
baseLayer,
|
|
173
|
+
layoutRect,
|
|
174
|
+
baseLayerPosition,
|
|
175
|
+
nodeMap,
|
|
176
|
+
null
|
|
177
|
+
)
|
|
178
|
+
baseLayer.layout = baseLayerLayout
|
|
179
|
+
layout.push(...baseLayerLayout.nodePositions)
|
|
180
|
+
|
|
181
|
+
// Layout layers after the base layer
|
|
182
|
+
let placeNextLayerAt = baseLayerPosition + baseLayer.primaryAxisSize + rowMargin
|
|
183
|
+
let refLayer = baseLayer
|
|
184
|
+
for (let i = baseLayerIndex + 1; i < layers.length; i++) {
|
|
185
|
+
const layer = layers[i]!
|
|
186
|
+
layer.layout = this.buildLayerLayout(layer, layoutRect, placeNextLayerAt, nodeMap, refLayer)
|
|
187
|
+
|
|
188
|
+
layout.push(...layer.layout.nodePositions)
|
|
189
|
+
refLayer = layer.layout.refLayer ?? layer
|
|
190
|
+
placeNextLayerAt += layer.primaryAxisSize + rowMargin
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Layout layers before the base layer
|
|
194
|
+
placeNextLayerAt = baseLayerPosition
|
|
195
|
+
refLayer = baseLayer
|
|
196
|
+
for (let i = baseLayerIndex - 1; i >= 0; i--) {
|
|
197
|
+
const layer = layers[i]!
|
|
198
|
+
placeNextLayerAt -= layer.primaryAxisSize + rowMargin
|
|
199
|
+
|
|
200
|
+
layer.layout = this.buildLayerLayout(layer, layoutRect, placeNextLayerAt, nodeMap, refLayer)
|
|
201
|
+
|
|
202
|
+
layout.push(...layer.layout.nodePositions)
|
|
203
|
+
refLayer = layer.layout.refLayer ?? layer
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return new Map(layout)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private buildLayerLayout(
|
|
210
|
+
layer: Layer,
|
|
211
|
+
layoutRect: Rect,
|
|
212
|
+
placeNextLayerAt: number,
|
|
213
|
+
nodeMap: Map<string, NodeRect>,
|
|
214
|
+
refLayer: Layer | null
|
|
215
|
+
): Layout {
|
|
216
|
+
let bestLayerLayout = this.scoreLayout(
|
|
217
|
+
this.spaceAround(layer, layoutRect, placeNextLayerAt),
|
|
218
|
+
nodeMap
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if (layer.nodes.length != 1) {
|
|
222
|
+
const currentlayerLayout = this.scoreLayout(
|
|
223
|
+
this.spaceBetween(layer, layoutRect, placeNextLayerAt),
|
|
224
|
+
nodeMap
|
|
225
|
+
)
|
|
226
|
+
bestLayerLayout = currentlayerLayout[0] < bestLayerLayout[0] ? currentlayerLayout : bestLayerLayout
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (refLayer && refLayer.nodes.length - 1 >= layer.nodes.length) {
|
|
230
|
+
const currentlayerLayout = this.scoreLayout(
|
|
231
|
+
this.placeInGaps(layer, placeNextLayerAt, refLayer),
|
|
232
|
+
nodeMap
|
|
233
|
+
)
|
|
234
|
+
bestLayerLayout = currentlayerLayout[0] < bestLayerLayout[0] ? currentlayerLayout : bestLayerLayout
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (refLayer && refLayer.nodes.length >= layer.nodes.length) {
|
|
238
|
+
const currentlayerLayout = this.scoreLayout(
|
|
239
|
+
this.placeInCells(layer, placeNextLayerAt, refLayer),
|
|
240
|
+
nodeMap
|
|
241
|
+
)
|
|
242
|
+
bestLayerLayout = currentlayerLayout[0] < bestLayerLayout[0] ? currentlayerLayout : bestLayerLayout
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return bestLayerLayout[1]
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private spaceBetween(
|
|
249
|
+
layer: Layer,
|
|
250
|
+
layoutRect: Rect,
|
|
251
|
+
placeNextLayerAt: number
|
|
252
|
+
): Layout {
|
|
253
|
+
const freeSpace = layoutRect[this.secondaryAxisDimension] - layer.occupiedSpace
|
|
254
|
+
const margin = freeSpace / (layer.nodes.length - 1)
|
|
255
|
+
|
|
256
|
+
let placeNextNodeAt = layoutRect[this.secondaryAxisCoord]
|
|
257
|
+
const result = new Map<string, XYPosition>()
|
|
258
|
+
|
|
259
|
+
let i = 0
|
|
260
|
+
for (let node of layer.nodes) {
|
|
261
|
+
result.set(node.id, {
|
|
262
|
+
[this.secondaryAxisCoord]: placeNextNodeAt,
|
|
263
|
+
[this.primaryAxisCoord]: placeNextLayerAt
|
|
264
|
+
} as XYPosition)
|
|
265
|
+
placeNextNodeAt += node[this.secondaryAxisDimension] + margin
|
|
266
|
+
i++
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { nodePositions: result, refLayer: null }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private spaceAround(
|
|
273
|
+
layer: Layer,
|
|
274
|
+
layoutRect: Rect,
|
|
275
|
+
placeNextLayerAt: number
|
|
276
|
+
): Layout {
|
|
277
|
+
const freeSpace = layoutRect[this.secondaryAxisDimension] - layer.occupiedSpace
|
|
278
|
+
const margin = freeSpace / (layer.nodes.length + 1)
|
|
279
|
+
|
|
280
|
+
let placeNextNodeAt = layoutRect[this.secondaryAxisCoord] + margin
|
|
281
|
+
const result = new Map<string, XYPosition>()
|
|
282
|
+
|
|
283
|
+
for (let node of sortBy(layer.nodes, n => n[this.secondaryAxisCoord])) {
|
|
284
|
+
result.set(node.id, {
|
|
285
|
+
[this.secondaryAxisCoord]: placeNextNodeAt,
|
|
286
|
+
[this.primaryAxisCoord]: placeNextLayerAt
|
|
287
|
+
} as XYPosition)
|
|
288
|
+
placeNextNodeAt += node[this.secondaryAxisDimension] + margin
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { nodePositions: result, refLayer: null }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private placeInGaps(
|
|
295
|
+
layer: Layer,
|
|
296
|
+
placeNextLayerAt: number,
|
|
297
|
+
refLayer: Layer
|
|
298
|
+
): Layout {
|
|
299
|
+
const result = new Map<string, XYPosition>()
|
|
300
|
+
|
|
301
|
+
const nodes = layer.nodes
|
|
302
|
+
const placementOptions = this.getGapsPositions(refLayer)
|
|
303
|
+
|
|
304
|
+
let optionIndex = 0
|
|
305
|
+
for (let i = 0, node = nodes[i]!; i < nodes.length; i++, node = nodes[i]!) {
|
|
306
|
+
const nodeCenter = node[this.secondaryAxisCoord] + node[this.secondaryAxisDimension] / 2
|
|
307
|
+
|
|
308
|
+
let bestOffset = Infinity
|
|
309
|
+
while (optionIndex - i <= placementOptions.length - nodes.length) {
|
|
310
|
+
let position = placementOptions[optionIndex]!
|
|
311
|
+
const offset = position - nodeCenter
|
|
312
|
+
|
|
313
|
+
if (Math.abs(offset) < Math.abs(bestOffset)) {
|
|
314
|
+
bestOffset = offset
|
|
315
|
+
|
|
316
|
+
optionIndex++
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
result.set(node.id, {
|
|
324
|
+
[this.secondaryAxisCoord]: node[this.secondaryAxisCoord] + bestOffset,
|
|
325
|
+
[this.primaryAxisCoord]: placeNextLayerAt
|
|
326
|
+
} as XYPosition)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { nodePositions: result, refLayer }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private placeInCells(
|
|
333
|
+
layer: Layer,
|
|
334
|
+
placeNextLayerAt: number,
|
|
335
|
+
refLayer: Layer
|
|
336
|
+
): Layout {
|
|
337
|
+
const result = new Map<string, XYPosition>()
|
|
338
|
+
|
|
339
|
+
const nodes = layer.nodes
|
|
340
|
+
const placementOptions = this.getNodePositions(refLayer)
|
|
341
|
+
|
|
342
|
+
let optionIndex = 0
|
|
343
|
+
for (let i = 0, node = nodes[i]!; i < nodes.length; i++, node = nodes[i]!) {
|
|
344
|
+
const nodeCenter = node[this.secondaryAxisCoord] + node[this.secondaryAxisDimension] / 2
|
|
345
|
+
|
|
346
|
+
let bestOffset = Infinity
|
|
347
|
+
|
|
348
|
+
while (optionIndex - i <= placementOptions.length - nodes.length) {
|
|
349
|
+
let position = placementOptions[optionIndex]!
|
|
350
|
+
const offset = position - nodeCenter
|
|
351
|
+
|
|
352
|
+
if (Math.abs(offset) < Math.abs(bestOffset)) {
|
|
353
|
+
bestOffset = offset
|
|
354
|
+
|
|
355
|
+
optionIndex++
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
result.set(node.id, {
|
|
363
|
+
[this.secondaryAxisCoord]: node[this.secondaryAxisCoord] + bestOffset,
|
|
364
|
+
[this.primaryAxisCoord]: placeNextLayerAt
|
|
365
|
+
} as XYPosition)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { nodePositions: result, refLayer }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private scoreLayout(
|
|
372
|
+
layout: Layout,
|
|
373
|
+
originalRects: Map<string, NodeRect>
|
|
374
|
+
): [number, Layout] {
|
|
375
|
+
return [
|
|
376
|
+
pipe(
|
|
377
|
+
Array.from(layout.nodePositions),
|
|
378
|
+
map(([id, position]) => {
|
|
379
|
+
const originalRect = originalRects.get(id)
|
|
380
|
+
invariant(originalRect, `Could not find original rect for node ${id}`)
|
|
381
|
+
return [pick(originalRect, ['x', 'y']), position]
|
|
382
|
+
}),
|
|
383
|
+
map(([original, suggested]) =>
|
|
384
|
+
Math.abs(original![this.secondaryAxisCoord] - suggested![this.secondaryAxisCoord])
|
|
385
|
+
),
|
|
386
|
+
reduce((a, b) => a + b, 0)
|
|
387
|
+
),
|
|
388
|
+
layout
|
|
389
|
+
]
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private getGapsPositions(layer: Layer): number[] {
|
|
393
|
+
const result = []
|
|
394
|
+
const { layout, nodes } = layer
|
|
395
|
+
|
|
396
|
+
invariant(layout, 'Layout of the layer must be computed before calling getGapsPositions')
|
|
397
|
+
|
|
398
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
399
|
+
const previousNode = nodes[i - 1]!
|
|
400
|
+
const currentNode = nodes[i]!
|
|
401
|
+
const previousNodePosition = layout.nodePositions.get(previousNode.id)!
|
|
402
|
+
const currentNodePosition = layout.nodePositions.get(currentNode.id)!
|
|
403
|
+
|
|
404
|
+
result.push(
|
|
405
|
+
(currentNodePosition[this.secondaryAxisCoord]
|
|
406
|
+
+ previousNodePosition[this.secondaryAxisCoord]
|
|
407
|
+
+ previousNode[this.secondaryAxisDimension]) / 2
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return result
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private getNodePositions(layer: Layer): number[] {
|
|
415
|
+
const result = []
|
|
416
|
+
const { layout, nodes } = layer
|
|
417
|
+
|
|
418
|
+
invariant(layout, 'Layout of the layer must be computed before calling getGapsPositions')
|
|
419
|
+
|
|
420
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
421
|
+
const node = nodes[i]!
|
|
422
|
+
const nodePosition = layout.nodePositions.get(node.id)!
|
|
423
|
+
|
|
424
|
+
result.push(
|
|
425
|
+
nodePosition[this.secondaryAxisCoord]
|
|
426
|
+
+ node[this.secondaryAxisDimension] / 2
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return result
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function getLinearAligner(mode: LinearAlignmentMode): Aligner {
|
|
435
|
+
switch (mode) {
|
|
436
|
+
case 'Left':
|
|
437
|
+
return new LinearAligner(
|
|
438
|
+
nodes => Math.min(...nodes.map(n => n.x)),
|
|
439
|
+
(alignTo, _) => Math.floor(alignTo),
|
|
440
|
+
'x'
|
|
441
|
+
)
|
|
442
|
+
case 'Top':
|
|
443
|
+
return new LinearAligner(
|
|
444
|
+
nodes => Math.min(...nodes.map(n => n.y)),
|
|
445
|
+
(alignTo, _) => Math.floor(alignTo),
|
|
446
|
+
'y'
|
|
447
|
+
)
|
|
448
|
+
case 'Right':
|
|
449
|
+
return new LinearAligner(
|
|
450
|
+
nodes => Math.max(...nodes.map(n => n.x + n.width)),
|
|
451
|
+
(alignTo, node) => Math.floor(alignTo - node.width!),
|
|
452
|
+
'x'
|
|
453
|
+
)
|
|
454
|
+
case 'Bottom':
|
|
455
|
+
return new LinearAligner(
|
|
456
|
+
nodes => Math.max(...nodes.map(n => n.y + n.height)),
|
|
457
|
+
(alignTo, node) => Math.floor(alignTo - node.height!),
|
|
458
|
+
'y'
|
|
459
|
+
)
|
|
460
|
+
case 'Center':
|
|
461
|
+
return new LinearAligner(
|
|
462
|
+
nodes => Math.min(...nodes.map(n => n.x + n.width / 2)),
|
|
463
|
+
(alignTo, node) => Math.floor(alignTo - node.width / 2),
|
|
464
|
+
'x'
|
|
465
|
+
)
|
|
466
|
+
case 'Middle':
|
|
467
|
+
return new LinearAligner(
|
|
468
|
+
nodes => Math.min(...nodes.map(n => n.y + n.height / 2)),
|
|
469
|
+
(alignTo, node) => Math.floor(alignTo - node.height / 2),
|
|
470
|
+
'y'
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { nonexhaustive } from '@likec4/core'
|
|
2
|
+
import type { InternalNode } from '@xyflow/react'
|
|
3
|
+
import { getNodeDimensions } from '@xyflow/system'
|
|
4
|
+
import { hasAtLeast } from 'remeda'
|
|
5
|
+
import type { DiagramState } from '../hooks'
|
|
6
|
+
import type { XYFlowNode } from '../xyflow/types'
|
|
7
|
+
import { createLayoutConstraints } from '../xyflow/useLayoutConstraints'
|
|
8
|
+
import { type Aligner, getLinearAligner, GridAligner, type GridAlignmentMode, type LinearAlignmentMode, type NodeRect } from './aligners'
|
|
9
|
+
|
|
10
|
+
export type AlignmentMode = LinearAlignmentMode | GridAlignmentMode
|
|
11
|
+
|
|
12
|
+
export function align(get: () => DiagramState) {
|
|
13
|
+
return (mode: AlignmentMode) => {
|
|
14
|
+
const { scheduleSaveManualLayout, xystore } = get()
|
|
15
|
+
const { nodeLookup, parentLookup } = xystore.getState()
|
|
16
|
+
|
|
17
|
+
const selectedNodes = new Set(nodeLookup.values().filter(n => n.selected).map(n => n.id))
|
|
18
|
+
const nodesToAlign = [...selectedNodes.difference(new Set(parentLookup.keys()))]
|
|
19
|
+
|
|
20
|
+
if (!hasAtLeast(nodesToAlign, 2)) {
|
|
21
|
+
console.warn('At least 2 nodes must be selected to align')
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
const constraints = createLayoutConstraints(xystore, nodesToAlign)
|
|
25
|
+
|
|
26
|
+
const aligner = getAligner(mode)
|
|
27
|
+
|
|
28
|
+
constraints.onMove(nodes => {
|
|
29
|
+
aligner.computeLayout(nodes.map(({ node }) => toNodeRect(node)))
|
|
30
|
+
|
|
31
|
+
nodes.forEach(({ rect, node }) => {
|
|
32
|
+
rect.positionAbsolute = {
|
|
33
|
+
...rect.positionAbsolute,
|
|
34
|
+
...aligner.applyPosition(toNodeRect(node))
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
scheduleSaveManualLayout()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toNodeRect(node: InternalNode<XYFlowNode>): NodeRect {
|
|
44
|
+
return {
|
|
45
|
+
...node.internals.positionAbsolute,
|
|
46
|
+
id: node.id,
|
|
47
|
+
width: getNodeDimensions(node).width,
|
|
48
|
+
height: getNodeDimensions(node).height
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getAligner(mode: AlignmentMode): Aligner {
|
|
53
|
+
switch (mode) {
|
|
54
|
+
case 'Left':
|
|
55
|
+
case 'Right':
|
|
56
|
+
case 'Top':
|
|
57
|
+
case 'Bottom':
|
|
58
|
+
case 'Center':
|
|
59
|
+
case 'Middle':
|
|
60
|
+
return getLinearAligner(mode)
|
|
61
|
+
case 'Column':
|
|
62
|
+
case 'Row':
|
|
63
|
+
return new GridAligner(mode)
|
|
64
|
+
default:
|
|
65
|
+
nonexhaustive(mode)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -49,6 +49,7 @@ import type { XYFlowEdge, XYFlowInstance, XYFlowNode } from '../xyflow/types'
|
|
|
49
49
|
import { createLayoutConstraints } from '../xyflow/useLayoutConstraints'
|
|
50
50
|
import { bezierControlPoints, isInside, isSamePoint, toDomPrecision } from '../xyflow/utils'
|
|
51
51
|
import { diagramViewToXYFlowData } from './diagram-to-xyflow'
|
|
52
|
+
import { align, type AlignmentMode } from './diagramStore.layout'
|
|
52
53
|
|
|
53
54
|
type RequiredOrNull<T> = {
|
|
54
55
|
[P in keyof T]-?: NonNullable<T[P]> | null
|
|
@@ -73,6 +74,7 @@ export type DiagramInitialState = {
|
|
|
73
74
|
enableElementDetails: boolean
|
|
74
75
|
enableRelationshipBrowser: boolean
|
|
75
76
|
enableRelationshipDetails: boolean
|
|
77
|
+
enableSearch: boolean
|
|
76
78
|
renderIcon: ElementIconRenderer | null
|
|
77
79
|
whereFilter: WhereOperator<string, string> | null
|
|
78
80
|
// If Dynamic View
|
|
@@ -85,8 +87,6 @@ export type DiagramInitialState = {
|
|
|
85
87
|
getContainer: () => HTMLDivElement | null
|
|
86
88
|
} & RequiredOrNull<LikeC4DiagramEventHandlers>
|
|
87
89
|
|
|
88
|
-
type AlignmentMode = 'Left' | 'Center' | 'Right' | 'Top' | 'Middle' | 'Bottom'
|
|
89
|
-
|
|
90
90
|
const StringSet = Set<string>
|
|
91
91
|
|
|
92
92
|
export type DiagramState = Simplify<
|
|
@@ -134,7 +134,7 @@ export type DiagramState = Simplify<
|
|
|
134
134
|
lastOnNavigate: null | {
|
|
135
135
|
fromView: ViewID
|
|
136
136
|
toView: ViewID
|
|
137
|
-
fromNode: NodeId
|
|
137
|
+
fromNode: NodeId | null
|
|
138
138
|
}
|
|
139
139
|
navigationHistory: Array<{
|
|
140
140
|
viewId: ViewID
|
|
@@ -1068,70 +1068,7 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
1068
1068
|
return vector(v).mul(scale).add(nodeCenter)
|
|
1069
1069
|
}
|
|
1070
1070
|
},
|
|
1071
|
-
align: (
|
|
1072
|
-
const { scheduleSaveManualLayout, xystore } = get()
|
|
1073
|
-
const { nodeLookup, parentLookup } = xystore.getState()
|
|
1074
|
-
|
|
1075
|
-
const selectedNodes = new Set(nodeLookup.values().filter(n => n.selected).map(n => n.id))
|
|
1076
|
-
const nodesToAlign = [...selectedNodes.difference(new Set(parentLookup.keys()))]
|
|
1077
|
-
|
|
1078
|
-
if (!hasAtLeast(nodesToAlign, 2)) {
|
|
1079
|
-
console.warn('At least 2 nodes must be selected to align')
|
|
1080
|
-
return
|
|
1081
|
-
}
|
|
1082
|
-
const constraints = createLayoutConstraints(xystore, nodesToAlign)
|
|
1083
|
-
|
|
1084
|
-
let getEdgePosition: (nodes: InternalNode<XYFlowNode>[]) => number
|
|
1085
|
-
let getPosition: (alignTo: number, node: InternalNode<XYFlowNode>) => number
|
|
1086
|
-
let propertyToEdit: keyof XYPosition
|
|
1087
|
-
|
|
1088
|
-
switch (mode) {
|
|
1089
|
-
case 'Left':
|
|
1090
|
-
getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.x))
|
|
1091
|
-
propertyToEdit = 'x'
|
|
1092
|
-
getPosition = (alignTo, _) => Math.floor(alignTo)
|
|
1093
|
-
break
|
|
1094
|
-
case 'Top':
|
|
1095
|
-
getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.y))
|
|
1096
|
-
propertyToEdit = 'y'
|
|
1097
|
-
getPosition = (alignTo, _) => Math.floor(alignTo)
|
|
1098
|
-
break
|
|
1099
|
-
case 'Right':
|
|
1100
|
-
getEdgePosition = nodes =>
|
|
1101
|
-
Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width))
|
|
1102
|
-
propertyToEdit = 'x'
|
|
1103
|
-
getPosition = (alignTo, node) => Math.floor(alignTo - node.width!)
|
|
1104
|
-
break
|
|
1105
|
-
case 'Bottom':
|
|
1106
|
-
getEdgePosition = nodes =>
|
|
1107
|
-
Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height))
|
|
1108
|
-
propertyToEdit = 'y'
|
|
1109
|
-
getPosition = (alignTo, node) => Math.floor(alignTo - node.height!)
|
|
1110
|
-
break
|
|
1111
|
-
case 'Center':
|
|
1112
|
-
getEdgePosition = nodes =>
|
|
1113
|
-
Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width / 2))
|
|
1114
|
-
propertyToEdit = 'x'
|
|
1115
|
-
getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).width / 2)
|
|
1116
|
-
break
|
|
1117
|
-
case 'Middle':
|
|
1118
|
-
getEdgePosition = nodes =>
|
|
1119
|
-
Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height / 2))
|
|
1120
|
-
propertyToEdit = 'y'
|
|
1121
|
-
getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).height / 2)
|
|
1122
|
-
break
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
constraints.onMove((nodes) => {
|
|
1126
|
-
const alignTo = getEdgePosition(nodes.map(({ node }) => node))
|
|
1127
|
-
|
|
1128
|
-
nodes.forEach(({ rect, node }) => {
|
|
1129
|
-
rect.positionAbsolute = { ...rect.positionAbsolute, [propertyToEdit]: getPosition(alignTo, node) }
|
|
1130
|
-
})
|
|
1131
|
-
})
|
|
1132
|
-
|
|
1133
|
-
scheduleSaveManualLayout()
|
|
1134
|
-
}
|
|
1071
|
+
align: align(get)
|
|
1135
1072
|
} satisfies DiagramState),
|
|
1136
1073
|
{
|
|
1137
1074
|
name: `${storeDevId} - ${props.view.id}`,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
IconLayoutAlignMiddle,
|
|
7
7
|
IconLayoutAlignRight,
|
|
8
8
|
IconLayoutAlignTop,
|
|
9
|
+
IconLayoutBoardSplit,
|
|
9
10
|
IconLayoutCollage,
|
|
10
11
|
IconRouteOff
|
|
11
12
|
} from '@tabler/icons-react'
|
|
@@ -53,6 +54,13 @@ export const ManualLayoutToolsButton = (props: PopoverProps) => {
|
|
|
53
54
|
<PopoverDropdown p={0}>
|
|
54
55
|
<Group gap={'xs'}>
|
|
55
56
|
<ActionIconGroup pos={'relative'}>
|
|
57
|
+
<Action
|
|
58
|
+
label="Align in columns"
|
|
59
|
+
icon={<IconLayoutBoardSplit />}
|
|
60
|
+
onClick={e => {
|
|
61
|
+
e.stopPropagation()
|
|
62
|
+
store.getState().align('Column')
|
|
63
|
+
}} />
|
|
56
64
|
<Action
|
|
57
65
|
label="Align left"
|
|
58
66
|
icon={<IconLayoutAlignLeft />}
|
|
@@ -74,6 +82,13 @@ export const ManualLayoutToolsButton = (props: PopoverProps) => {
|
|
|
74
82
|
e.stopPropagation()
|
|
75
83
|
store.getState().align('Right')
|
|
76
84
|
}} />
|
|
85
|
+
<Action
|
|
86
|
+
label="Align in rows"
|
|
87
|
+
icon={<IconLayoutBoardSplit style={{ transform: 'rotate(90deg)' }} />}
|
|
88
|
+
onClick={e => {
|
|
89
|
+
e.stopPropagation()
|
|
90
|
+
store.getState().align('Row')
|
|
91
|
+
}} />
|
|
77
92
|
<Action
|
|
78
93
|
label="Align top"
|
|
79
94
|
icon={<IconLayoutAlignTop />}
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
IconChevronRight,
|
|
19
19
|
IconFileSymlink,
|
|
20
20
|
IconFocusCentered,
|
|
21
|
-
IconMenu2
|
|
21
|
+
IconMenu2,
|
|
22
|
+
IconSearch
|
|
22
23
|
} from '@tabler/icons-react'
|
|
23
24
|
import clsx from 'clsx'
|
|
24
25
|
import { AnimatePresence, LayoutGroup, m } from 'framer-motion'
|
|
@@ -30,6 +31,7 @@ import { ActionIcon, Tooltip } from './_shared'
|
|
|
30
31
|
import { ChangeAutoLayoutButton } from './ChangeAutoLayoutButton'
|
|
31
32
|
import { ManualLayoutToolsButton } from './ManualLayoutToolsButton'
|
|
32
33
|
import * as css from './styles.css'
|
|
34
|
+
import { openSpotlight } from '@mantine/spotlight'
|
|
33
35
|
|
|
34
36
|
const historySelector = (s: DiagramState) => ({
|
|
35
37
|
showBurgerMenu: !!s.onBurgerMenuClick,
|
|
@@ -138,6 +140,7 @@ const LayoutDriftNotification = (props: PopoverProps) => (
|
|
|
138
140
|
export const TopLeftPanel = () => {
|
|
139
141
|
const store = useDiagramStoreApi()
|
|
140
142
|
const {
|
|
143
|
+
enableSearch,
|
|
141
144
|
showNavigationButtons,
|
|
142
145
|
showFitDiagram,
|
|
143
146
|
showLayoutDriftWarning,
|
|
@@ -150,6 +153,7 @@ export const TopLeftPanel = () => {
|
|
|
150
153
|
const isNotFocused = isNullish(s.focusedNodeId)
|
|
151
154
|
const isNotActive = isNotWalkthrough && isNotFocused
|
|
152
155
|
return ({
|
|
156
|
+
enableSearch: s.enableSearch,
|
|
153
157
|
showNavigationButtons: !!s.onBurgerMenuClick || (s.showNavigationButtons && !!s.onNavigateTo),
|
|
154
158
|
showFitDiagram: s.controls && s.fitViewEnabled && s.zoomable && isNotWalkthrough,
|
|
155
159
|
showLayoutDriftWarning: s.controls && !s.readonly && s.view.hasLayoutDrift === true && isNotActive,
|
|
@@ -174,6 +178,15 @@ export const TopLeftPanel = () => {
|
|
|
174
178
|
gap={'xs'}>
|
|
175
179
|
{showNavigationButtons && <BackwardForwardButtons />}
|
|
176
180
|
<ActionIconGroup className={css.actionIconGroup} orientation="vertical">
|
|
181
|
+
{enableSearch && (
|
|
182
|
+
<Tooltip label="Search">
|
|
183
|
+
<ActionIcon
|
|
184
|
+
onClick={() => openSpotlight()}
|
|
185
|
+
>
|
|
186
|
+
<IconSearch />
|
|
187
|
+
</ActionIcon>
|
|
188
|
+
</Tooltip>
|
|
189
|
+
)}
|
|
177
190
|
{showGoToSource && (
|
|
178
191
|
<Tooltip label="Open source" {...portalProps}>
|
|
179
192
|
<ActionIcon
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ThemeColor } from '@likec4/core'
|
|
2
|
-
import { ActionIcon, Box, Text as MantineText, Tooltip } from '@mantine/core'
|
|
2
|
+
import { ActionIcon, Box, Group, Text as MantineText, Tooltip } from '@mantine/core'
|
|
3
3
|
import { useDebouncedValue } from '@mantine/hooks'
|
|
4
|
-
import { IconId, IconZoomScan } from '@tabler/icons-react'
|
|
4
|
+
import { IconId, IconTransform, IconZoomScan } from '@tabler/icons-react'
|
|
5
5
|
import { Handle, type NodeProps, Position } from '@xyflow/react'
|
|
6
6
|
import clsx from 'clsx'
|
|
7
7
|
import { deepEqual as eq } from 'fast-equals'
|
|
@@ -54,21 +54,24 @@ const VariantsRoot = {
|
|
|
54
54
|
}
|
|
55
55
|
} satisfies Variants
|
|
56
56
|
|
|
57
|
-
const
|
|
57
|
+
const VariantsNavigateBtn = {
|
|
58
58
|
idle: {
|
|
59
59
|
'--ai-bg': 'var(--ai-bg-idle)',
|
|
60
60
|
scale: 1,
|
|
61
61
|
opacity: 0.5,
|
|
62
|
-
|
|
63
|
-
originY: 0.2
|
|
62
|
+
originY: 0.5
|
|
64
63
|
},
|
|
65
64
|
selected: {},
|
|
66
65
|
hovered: {
|
|
67
66
|
'--ai-bg': 'var(--ai-bg-hover)',
|
|
68
|
-
translateX: '-50%',
|
|
69
67
|
scale: 1.35,
|
|
70
68
|
opacity: 1
|
|
71
|
-
}
|
|
69
|
+
}
|
|
70
|
+
} satisfies Variants
|
|
71
|
+
VariantsNavigateBtn['selected'] = VariantsNavigateBtn['hovered']
|
|
72
|
+
|
|
73
|
+
const VariantsNavigate = {
|
|
74
|
+
...VariantsNavigateBtn,
|
|
72
75
|
'hovered:navigate': {
|
|
73
76
|
scale: 1.42
|
|
74
77
|
},
|
|
@@ -76,7 +79,16 @@ const VariantsNavigate = {
|
|
|
76
79
|
scale: 1.15
|
|
77
80
|
}
|
|
78
81
|
} satisfies Variants
|
|
79
|
-
|
|
82
|
+
|
|
83
|
+
const VariantRealationships = {
|
|
84
|
+
...VariantsNavigateBtn,
|
|
85
|
+
'hovered:relationships': {
|
|
86
|
+
scale: 1.42
|
|
87
|
+
},
|
|
88
|
+
'tap:relationships': {
|
|
89
|
+
scale: 1.15
|
|
90
|
+
}
|
|
91
|
+
} satisfies Variants
|
|
80
92
|
|
|
81
93
|
const VariantsDetailsBtn = {
|
|
82
94
|
idle: {
|
|
@@ -127,6 +139,7 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
|
|
|
127
139
|
isNavigable,
|
|
128
140
|
isInteractive,
|
|
129
141
|
enableElementDetails,
|
|
142
|
+
enableRelationshipBrowser,
|
|
130
143
|
triggerOnNavigateTo,
|
|
131
144
|
openOverlay,
|
|
132
145
|
isInActiveOverlay,
|
|
@@ -136,10 +149,11 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
|
|
|
136
149
|
isEditable: s.readonly !== true,
|
|
137
150
|
isHovered: s.hoveredNodeId === id,
|
|
138
151
|
isDimmed: s.dimmed.has(id),
|
|
139
|
-
isInteractive: s.nodesDraggable || s.nodesSelectable || s.enableElementDetails
|
|
152
|
+
isInteractive: s.nodesDraggable || s.nodesSelectable || s.enableElementDetails || s.enableRelationshipBrowser
|
|
140
153
|
|| (!!s.onNavigateTo && !!element.navigateTo),
|
|
141
154
|
isNavigable: (!!s.onNavigateTo && !!element.navigateTo),
|
|
142
155
|
enableElementDetails: s.enableElementDetails,
|
|
156
|
+
enableRelationshipBrowser: s.enableRelationshipBrowser,
|
|
143
157
|
triggerOnNavigateTo: s.triggerOnNavigateTo,
|
|
144
158
|
openOverlay: s.openOverlay,
|
|
145
159
|
isInActiveOverlay: (s.activeOverlay?.elementDetails ?? s.activeOverlay?.relationshipsOf) === id,
|
|
@@ -195,6 +209,11 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
|
|
|
195
209
|
openOverlay({ elementDetails: element.id })
|
|
196
210
|
}, [openOverlay, element.id])
|
|
197
211
|
|
|
212
|
+
const onOpenRelationships = useCallback((e: React.MouseEvent) => {
|
|
213
|
+
e.stopPropagation()
|
|
214
|
+
openOverlay({ relationshipsOf: element.id })
|
|
215
|
+
}, [openOverlay, element.id])
|
|
216
|
+
|
|
198
217
|
return (
|
|
199
218
|
<>
|
|
200
219
|
{isToolbarVisible && (
|
|
@@ -266,21 +285,40 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
|
|
|
266
285
|
</Box>
|
|
267
286
|
</Box>
|
|
268
287
|
{/* {isHovercards && element.links && <ElementLink element={element} />} */}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
288
|
+
|
|
289
|
+
{(enableRelationshipBrowser || isNavigable) && (
|
|
290
|
+
<Group className={css.navigateBtnBox} align="center">
|
|
291
|
+
{enableRelationshipBrowser && (
|
|
292
|
+
<ActionIcon
|
|
293
|
+
component={m.div}
|
|
294
|
+
variants={VariantRealationships}
|
|
295
|
+
data-animate-target="relationships"
|
|
296
|
+
className={clsx('nodrag nopan', css.navigateBtn)}
|
|
297
|
+
radius="md"
|
|
298
|
+
role="button"
|
|
299
|
+
onClick={onOpenRelationships}
|
|
300
|
+
onDoubleClick={stopPropagation}
|
|
301
|
+
{...isInteractive && animateHandlers}
|
|
302
|
+
>
|
|
303
|
+
<IconTransform style={{ width: '75%' }} />
|
|
304
|
+
</ActionIcon>
|
|
305
|
+
)}
|
|
306
|
+
{isNavigable && (
|
|
307
|
+
<ActionIcon
|
|
308
|
+
component={m.div}
|
|
309
|
+
variants={VariantsNavigate}
|
|
310
|
+
data-animate-target="navigate"
|
|
311
|
+
className={clsx('nodrag nopan', css.navigateBtn)}
|
|
312
|
+
radius="md"
|
|
313
|
+
role="button"
|
|
314
|
+
onClick={onNavigateTo}
|
|
315
|
+
onDoubleClick={stopPropagation}
|
|
316
|
+
{...isInteractive && animateHandlers}
|
|
317
|
+
>
|
|
318
|
+
<IconZoomScan style={{ width: '75%' }} />
|
|
319
|
+
</ActionIcon>
|
|
320
|
+
)}
|
|
321
|
+
</Group>
|
|
284
322
|
)}
|
|
285
323
|
{enableElementDetails && (
|
|
286
324
|
<Tooltip
|
|
@@ -354,16 +354,25 @@ const btn = style({
|
|
|
354
354
|
}
|
|
355
355
|
})
|
|
356
356
|
|
|
357
|
-
export const
|
|
357
|
+
export const navigateBtnBox = style({
|
|
358
|
+
zIndex: 100,
|
|
358
359
|
position: 'absolute',
|
|
359
360
|
left: '50%',
|
|
360
|
-
bottom:
|
|
361
|
+
bottom: 2,
|
|
362
|
+
transform: 'translate(-50%, 0%)',
|
|
363
|
+
gap: 0,
|
|
361
364
|
selectors: {
|
|
362
365
|
[`:where([data-likec4-shape='browser']) &`]: {
|
|
363
366
|
bottom: 4
|
|
364
367
|
}
|
|
365
368
|
}
|
|
366
|
-
}
|
|
369
|
+
})
|
|
370
|
+
globalStyle(`:where(.react-flow__node:hover) ${navigateBtnBox}`, {
|
|
371
|
+
transitionDelay: '20ms',
|
|
372
|
+
gap: 16
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
export const navigateBtn = style([btn, {}])
|
|
367
376
|
|
|
368
377
|
export const detailsBtn = style([btn, {
|
|
369
378
|
position: 'absolute',
|