@likec4/diagram 1.21.1 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/LikeC4Diagram.js +9 -13
- package/dist/base/index.d.ts +0 -1
- package/dist/base/primitives/element/styles.css.js +1 -1
- package/dist/{base → components}/Link.css.js +3 -3
- package/dist/components/SearchControl.css.d.ts +2 -0
- package/dist/components/SearchControl.css.js +7 -0
- package/dist/components/SearchControl.d.ts +5 -0
- package/dist/components/SearchControl.js +17 -0
- package/dist/context/IconRenderer.d.ts +10 -0
- package/dist/context/IconRenderer.js +34 -0
- package/dist/hooks/useCurrentViewId.js +8 -0
- package/dist/hooks/useDiagram.d.ts +4 -4
- package/dist/likec4diagram/DiagramUI.js +0 -2
- package/dist/likec4diagram/XYFlow.d.ts +6 -1
- package/dist/likec4diagram/XYFlow.js +28 -29
- package/dist/likec4diagram/custom/edges/RelationshipsDropdownMenu.js +2 -1
- package/dist/likec4diagram/state/actorContext.d.ts +16 -16
- package/dist/likec4diagram/state/assign.js +1 -1
- package/dist/likec4diagram/state/machine.d.ts +7 -7
- package/dist/likec4diagram/state/machine.js +10 -7
- package/dist/likec4diagram/ui/controls/Controls.js +63 -48
- package/dist/likec4diagram/ui/controls/NavigationButtons.js +44 -69
- package/dist/likec4diagram/ui/controls/_shared.js +1 -1
- package/dist/likec4diagram/ui/diagram-title/DiagramTitlePanel.js +1 -1
- package/dist/likec4diagram/ui/search/ElementsColumn.css.d.ts +15 -0
- package/dist/likec4diagram/ui/search/ElementsColumn.css.js +30 -0
- package/dist/likec4diagram/ui/search/ElementsColumn.d.ts +1 -0
- package/dist/likec4diagram/ui/search/ElementsColumn.js +243 -0
- package/dist/likec4diagram/ui/search/LikeC4Search.css.d.ts +8 -0
- package/dist/likec4diagram/ui/search/LikeC4Search.css.js +20 -0
- package/dist/likec4diagram/ui/search/LikeC4Search.d.ts +1 -0
- package/dist/likec4diagram/ui/search/LikeC4Search.js +265 -0
- package/dist/likec4diagram/ui/search/SearchInput.d.ts +1 -0
- package/dist/likec4diagram/ui/search/SearchInput.js +196 -0
- package/dist/likec4diagram/ui/search/ViewsColum.d.ts +9 -0
- package/dist/likec4diagram/ui/search/ViewsColum.js +130 -0
- package/dist/likec4diagram/ui/search/ViewsColumn.css.d.ts +5 -0
- package/dist/likec4diagram/ui/search/ViewsColumn.css.js +12 -0
- package/dist/likec4diagram/ui/search/_shared.css.d.ts +16 -0
- package/dist/likec4diagram/ui/search/_shared.css.js +5 -0
- package/dist/likec4diagram/ui/search/state.d.ts +19 -0
- package/dist/likec4diagram/ui/search/state.js +72 -0
- package/dist/likec4diagram/ui/search/utils.d.ts +3 -0
- package/dist/likec4diagram/ui/search/utils.js +23 -0
- package/dist/likec4diagram/useLayoutConstraints.js +4 -5
- package/dist/likec4model/useLikeC4ElementsTree.d.ts +1 -0
- package/dist/likec4model/useLikeC4ElementsTree.js +1 -0
- package/dist/overlays/Overlays.js +9 -9
- package/dist/overlays/element-details/ElementDetailsCard.js +20 -13
- package/dist/overlays/overlay/Overlay.d.ts +6 -2
- package/dist/overlays/overlay/Overlay.js +18 -29
- package/dist/overlays/relationship-details/SelectEdge.js +1 -3
- package/dist/overlays/relationships-browser/RelationshipsBrowser.js +6 -5
- package/dist/overlays/relationships-browser/SelectElement.js +10 -6
- package/dist/overlays/relationships-browser/actor.d.ts +2 -2
- package/dist/overlays/relationships-browser/actor.js +8 -8
- package/dist/overlays/relationships-browser/custom/nodeTypes.js +2 -2
- package/dist/style.css +1 -1
- package/package.json +13 -12
- package/src/LikeC4Diagram.tsx +0 -1
- package/src/base/index.ts +0 -2
- package/src/base/primitives/element/styles.css.ts +2 -2
- package/src/{base → components}/Link.css.ts +3 -3
- package/src/components/SearchControl.css.ts +69 -0
- package/src/components/SearchControl.tsx +33 -0
- package/src/context/IconRenderer.tsx +54 -1
- package/src/globals.css.ts +0 -2
- package/src/likec4diagram/DiagramUI.tsx +0 -4
- package/src/likec4diagram/XYFlow.tsx +30 -30
- package/src/likec4diagram/custom/edges/RelationshipsDropdownMenu.tsx +2 -1
- package/src/likec4diagram/state/assign.ts +1 -1
- package/src/likec4diagram/state/machine.ts +12 -10
- package/src/likec4diagram/ui/controls/Controls.tsx +35 -25
- package/src/likec4diagram/ui/controls/NavigationButtons.tsx +36 -58
- package/src/likec4diagram/ui/controls/styles.css.ts +3 -1
- package/src/likec4diagram/ui/diagram-title/DiagramTitlePanel.tsx +1 -1
- package/src/likec4diagram/ui/search/ElementsColumn.css.ts +119 -0
- package/src/likec4diagram/ui/search/ElementsColumn.tsx +291 -0
- package/src/likec4diagram/ui/search/LikeC4Search.css.ts +117 -0
- package/src/likec4diagram/ui/search/LikeC4Search.tsx +312 -0
- package/src/likec4diagram/ui/search/SearchInput.tsx +235 -0
- package/src/likec4diagram/ui/search/ViewsColum.tsx +158 -0
- package/src/likec4diagram/ui/search/ViewsColumn.css.ts +37 -0
- package/src/likec4diagram/ui/search/_shared.css.ts +104 -0
- package/src/likec4diagram/ui/search/state.ts +76 -0
- package/src/likec4diagram/ui/search/utils.ts +20 -0
- package/src/likec4diagram/useLayoutConstraints.ts +4 -5
- package/src/likec4model/useLikeC4ElementsTree.ts +4 -4
- package/src/overlays/Overlays.tsx +31 -25
- package/src/overlays/element-details/ElementDetailsCard.css.ts +4 -4
- package/src/overlays/element-details/ElementDetailsCard.tsx +245 -240
- package/src/overlays/overlay/Overlay.css.ts +10 -9
- package/src/overlays/overlay/Overlay.tsx +38 -26
- package/src/overlays/relationship-details/SelectEdge.css.ts +1 -0
- package/src/overlays/relationship-details/SelectEdge.tsx +2 -4
- package/src/overlays/relationships-browser/RelationshipsBrowser.tsx +10 -5
- package/src/overlays/relationships-browser/SelectElement.tsx +88 -82
- package/src/overlays/relationships-browser/actor.ts +10 -10
- package/src/overlays/relationships-browser/custom/nodeTypes.tsx +2 -2
- package/dist/LikeC4Search.css.d.ts +0 -1
- package/dist/LikeC4Search.css.js +0 -5
- package/dist/LikeC4Search.d.ts +0 -2
- package/dist/LikeC4Search.js +0 -88
- package/src/LikeC4Search.css.ts +0 -12
- package/src/LikeC4Search.tsx +0 -91
- /package/dist/{base → components}/Link.css.d.ts +0 -0
- /package/dist/{base → components}/Link.d.ts +0 -0
- /package/dist/{base → components}/Link.js +0 -0
- /package/src/{base → components}/Link.tsx +0 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { compareNatural } from '@likec4/core'
|
|
2
|
+
import {
|
|
3
|
+
Combobox,
|
|
4
|
+
ComboboxDropdown,
|
|
5
|
+
ComboboxEmpty,
|
|
6
|
+
ComboboxOption,
|
|
7
|
+
ComboboxOptions,
|
|
8
|
+
ComboboxTarget,
|
|
9
|
+
Input,
|
|
10
|
+
rem,
|
|
11
|
+
ScrollAreaAutosize,
|
|
12
|
+
Text,
|
|
13
|
+
useCombobox,
|
|
14
|
+
} from '@mantine/core'
|
|
15
|
+
import { useDebouncedValue, useFocusWithin, useIsFirstRender, useWindowEvent } from '@mantine/hooks'
|
|
16
|
+
import { usePreviousDistinct } from '@react-hookz/web'
|
|
17
|
+
import { IconSearch } from '@tabler/icons-react'
|
|
18
|
+
import { type ReactNode } from 'react'
|
|
19
|
+
import { isString, keys, only } from 'remeda'
|
|
20
|
+
import { useLikeC4Model } from '../../../likec4model/useLikeC4Model'
|
|
21
|
+
import * as css from './LikeC4Search.css'
|
|
22
|
+
import { setSearch, useIsPickViewActive, useSearch } from './state'
|
|
23
|
+
import { moveFocusToSearchInput, stopAndPrevent } from './utils'
|
|
24
|
+
|
|
25
|
+
function startingWithKind(search: string) {
|
|
26
|
+
return search.match(/^(k|ki|kin|kind|kind:)$/) != null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SEARCH_PREFIXES = ['#', 'kind:']
|
|
30
|
+
|
|
31
|
+
export function LikeC4SearchInput() {
|
|
32
|
+
const isPickViewActive = useIsPickViewActive()
|
|
33
|
+
const likec4model = useLikeC4Model(true)
|
|
34
|
+
const combobox = useCombobox({
|
|
35
|
+
scrollBehavior: 'smooth',
|
|
36
|
+
})
|
|
37
|
+
const { ref, focused } = useFocusWithin<HTMLInputElement>()
|
|
38
|
+
const search = useSearch()
|
|
39
|
+
const previous = usePreviousDistinct(search)
|
|
40
|
+
const isFirstRender = useIsFirstRender()
|
|
41
|
+
|
|
42
|
+
const [isEmptyForSomeTime] = useDebouncedValue(search === '' && !isFirstRender, isString(previous) ? 400 : 1000)
|
|
43
|
+
|
|
44
|
+
useWindowEvent(
|
|
45
|
+
'keydown',
|
|
46
|
+
(event) => {
|
|
47
|
+
try {
|
|
48
|
+
if (
|
|
49
|
+
!focused && !isPickViewActive && (
|
|
50
|
+
event.key === 'Backspace' ||
|
|
51
|
+
event.key.startsWith('Arrow') ||
|
|
52
|
+
event.key.match(/^\p{L}$/u)
|
|
53
|
+
)
|
|
54
|
+
) {
|
|
55
|
+
moveFocusToSearchInput()
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.warn(e)
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
let options = [] as ReactNode[]
|
|
64
|
+
let isExactMatch = false
|
|
65
|
+
|
|
66
|
+
switch (true) {
|
|
67
|
+
case search === '' && (isEmptyForSomeTime || SEARCH_PREFIXES.includes(previous ?? '')): {
|
|
68
|
+
options = SEARCH_PREFIXES.map((prefix) => (
|
|
69
|
+
<ComboboxOption value={prefix} key={prefix}>
|
|
70
|
+
<Text component="span" opacity={.5} mr={4} fz={'sm'}>Search by</Text>
|
|
71
|
+
{prefix}
|
|
72
|
+
</ComboboxOption>
|
|
73
|
+
))
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
case search.startsWith('#'): {
|
|
77
|
+
const searchTag = search.toLocaleLowerCase().slice(1)
|
|
78
|
+
const alloptions = likec4model.allTags().filter((tag) => tag.toLocaleLowerCase().includes(searchTag)).sort(
|
|
79
|
+
compareNatural,
|
|
80
|
+
)
|
|
81
|
+
isExactMatch = only(alloptions)?.toLocaleLowerCase() === searchTag
|
|
82
|
+
if (alloptions.length === 0) {
|
|
83
|
+
options = [
|
|
84
|
+
<ComboboxEmpty key="empty-tags">
|
|
85
|
+
No tags found
|
|
86
|
+
</ComboboxEmpty>,
|
|
87
|
+
]
|
|
88
|
+
} else {
|
|
89
|
+
options = alloptions.map((tag) => (
|
|
90
|
+
<ComboboxOption value={`#${tag}`} key={tag}>
|
|
91
|
+
<Text component="span" opacity={.5} mr={1} fz={'sm'}>#</Text>
|
|
92
|
+
{tag}
|
|
93
|
+
</ComboboxOption>
|
|
94
|
+
))
|
|
95
|
+
}
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
case search.startsWith('kind:'):
|
|
99
|
+
case startingWithKind(search): {
|
|
100
|
+
const term = search.length > 5 ? search.slice(5).toLocaleLowerCase() : ''
|
|
101
|
+
let alloptions = keys(likec4model.$model.specification.elements)
|
|
102
|
+
if (term) {
|
|
103
|
+
alloptions = alloptions.filter((kind) => kind.toLocaleLowerCase().includes(term))
|
|
104
|
+
isExactMatch = only(alloptions)?.toLocaleLowerCase() === term
|
|
105
|
+
}
|
|
106
|
+
if (alloptions.length === 0) {
|
|
107
|
+
options = [
|
|
108
|
+
<ComboboxEmpty key="empty-kinds">
|
|
109
|
+
No kinds found
|
|
110
|
+
</ComboboxEmpty>,
|
|
111
|
+
]
|
|
112
|
+
} else {
|
|
113
|
+
options = alloptions.map((kind) => (
|
|
114
|
+
<ComboboxOption value={`kind:${kind}`} key={kind}>
|
|
115
|
+
<Text component="span" opacity={.5} mr={1} fz={'sm'}>kind:</Text>
|
|
116
|
+
{kind}
|
|
117
|
+
</ComboboxOption>
|
|
118
|
+
))
|
|
119
|
+
}
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Combobox
|
|
126
|
+
onOptionSubmit={(optionValue) => {
|
|
127
|
+
setSearch(optionValue)
|
|
128
|
+
combobox.resetSelectedOption()
|
|
129
|
+
if (!SEARCH_PREFIXES.includes(optionValue)) {
|
|
130
|
+
combobox.closeDropdown()
|
|
131
|
+
// Let react to display filtered elements
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
document.querySelector<HTMLButtonElement>(`.${css.root} .${css.focusable}`)?.focus()
|
|
134
|
+
}, 50)
|
|
135
|
+
}
|
|
136
|
+
}}
|
|
137
|
+
width={'max-content'}
|
|
138
|
+
position="bottom-start"
|
|
139
|
+
shadow="md"
|
|
140
|
+
offset={{
|
|
141
|
+
mainAxis: 4,
|
|
142
|
+
crossAxis: 50,
|
|
143
|
+
}}
|
|
144
|
+
store={combobox}
|
|
145
|
+
withinPortal={false}
|
|
146
|
+
>
|
|
147
|
+
<ComboboxTarget>
|
|
148
|
+
<Input
|
|
149
|
+
ref={ref}
|
|
150
|
+
id="likec4searchinput"
|
|
151
|
+
placeholder="Search by title, description or start with # or kind:"
|
|
152
|
+
tabIndex={0}
|
|
153
|
+
classNames={{
|
|
154
|
+
input: css.input,
|
|
155
|
+
}}
|
|
156
|
+
size="xl"
|
|
157
|
+
value={search}
|
|
158
|
+
leftSection={<IconSearch style={{ width: rem(20) }} stroke={2} />}
|
|
159
|
+
onChange={(event) => {
|
|
160
|
+
setSearch(event.currentTarget.value)
|
|
161
|
+
combobox.openDropdown()
|
|
162
|
+
combobox.updateSelectedOptionIndex()
|
|
163
|
+
}}
|
|
164
|
+
onClick={() => combobox.openDropdown()}
|
|
165
|
+
onFocus={() => combobox.openDropdown()}
|
|
166
|
+
onBlur={() => combobox.closeDropdown()}
|
|
167
|
+
onKeyDownCapture={(e) => {
|
|
168
|
+
if (e.key === 'Tab') {
|
|
169
|
+
switch (true) {
|
|
170
|
+
case combobox.getSelectedOptionIndex() >= 0: {
|
|
171
|
+
combobox.clickSelectedOption()
|
|
172
|
+
return stopAndPrevent(e)
|
|
173
|
+
}
|
|
174
|
+
case options.length === 1: {
|
|
175
|
+
const firstOption = combobox.selectFirstOption()
|
|
176
|
+
if (firstOption) {
|
|
177
|
+
combobox.clickSelectedOption()
|
|
178
|
+
}
|
|
179
|
+
return stopAndPrevent(e)
|
|
180
|
+
}
|
|
181
|
+
case startingWithKind(search): {
|
|
182
|
+
setSearch('kind:')
|
|
183
|
+
return stopAndPrevent(e)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (e.key === 'Backspace' && combobox.dropdownOpened) {
|
|
189
|
+
if (search === 'kind:') {
|
|
190
|
+
setSearch('')
|
|
191
|
+
combobox.resetSelectedOption()
|
|
192
|
+
return stopAndPrevent(e)
|
|
193
|
+
}
|
|
194
|
+
if (search.startsWith('kind:') && isExactMatch) {
|
|
195
|
+
setSearch('kind:')
|
|
196
|
+
combobox.resetSelectedOption()
|
|
197
|
+
return stopAndPrevent(e)
|
|
198
|
+
}
|
|
199
|
+
if (search.startsWith('#') && isExactMatch) {
|
|
200
|
+
setSearch('#')
|
|
201
|
+
combobox.resetSelectedOption()
|
|
202
|
+
return stopAndPrevent(e)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (e.key === 'Escape' && combobox.dropdownOpened && options.length > 0) {
|
|
206
|
+
stopAndPrevent(e)
|
|
207
|
+
combobox.closeDropdown()
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
e.key === 'ArrowDown' && (
|
|
212
|
+
!combobox.dropdownOpened ||
|
|
213
|
+
options.length === 0 || isExactMatch ||
|
|
214
|
+
// reached the last option and the search is empty
|
|
215
|
+
(search === '' && combobox.getSelectedOptionIndex() === options.length - 1)
|
|
216
|
+
)
|
|
217
|
+
) {
|
|
218
|
+
combobox.closeDropdown()
|
|
219
|
+
stopAndPrevent(e)
|
|
220
|
+
document.querySelector<HTMLButtonElement>(`.${css.root} .${css.focusable}`)?.focus()
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
}} />
|
|
224
|
+
</ComboboxTarget>
|
|
225
|
+
|
|
226
|
+
<ComboboxDropdown hidden={options.length === 0} style={{ minWidth: 300 }}>
|
|
227
|
+
<ComboboxOptions>
|
|
228
|
+
<ScrollAreaAutosize mah={'min(322px, calc(100cqh - 50px))'} type="scroll">
|
|
229
|
+
{options}
|
|
230
|
+
</ScrollAreaAutosize>
|
|
231
|
+
</ComboboxOptions>
|
|
232
|
+
</ComboboxDropdown>
|
|
233
|
+
</Combobox>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type LikeC4Model,
|
|
3
|
+
type Tag,
|
|
4
|
+
isDeploymentView,
|
|
5
|
+
} from '@likec4/core'
|
|
6
|
+
import {
|
|
7
|
+
type ElementProps,
|
|
8
|
+
type UnstyledButtonProps,
|
|
9
|
+
Badge,
|
|
10
|
+
Box,
|
|
11
|
+
createScopedKeydownHandler,
|
|
12
|
+
Group,
|
|
13
|
+
Highlight,
|
|
14
|
+
Stack,
|
|
15
|
+
ThemeIcon,
|
|
16
|
+
UnstyledButton,
|
|
17
|
+
VisuallyHidden,
|
|
18
|
+
} from '@mantine/core'
|
|
19
|
+
import { IconStack2, IconZoomScan } from '@tabler/icons-react'
|
|
20
|
+
import clsx from 'clsx'
|
|
21
|
+
import { m } from 'framer-motion'
|
|
22
|
+
import { first } from 'remeda'
|
|
23
|
+
import { useCurrentViewId } from '../../../hooks'
|
|
24
|
+
import { useLikeC4Model } from '../../../likec4model/useLikeC4Model'
|
|
25
|
+
import { emptyBoX } from './_shared.css'
|
|
26
|
+
import { useCloseSearchAndNavigateTo, useNormalizedSearch } from './state'
|
|
27
|
+
import { centerY, moveFocusToSearchInput } from './utils'
|
|
28
|
+
import * as css from './ViewsColumn.css'
|
|
29
|
+
|
|
30
|
+
export const NothingFound = () => (
|
|
31
|
+
<Box className={emptyBoX}>
|
|
32
|
+
Nothing found
|
|
33
|
+
</Box>
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
export function ViewsColumn() {
|
|
37
|
+
const search = useNormalizedSearch()
|
|
38
|
+
let views = [...useLikeC4Model(true).views()]
|
|
39
|
+
if (search) {
|
|
40
|
+
if (search.startsWith('kind:')) {
|
|
41
|
+
views = []
|
|
42
|
+
} else {
|
|
43
|
+
views = views.filter(view => {
|
|
44
|
+
if (search.startsWith('#')) {
|
|
45
|
+
return view.tags.some((tag: Tag) => tag.toLocaleLowerCase().includes(search.slice(1)))
|
|
46
|
+
}
|
|
47
|
+
return (view.title ?? '' + view.$view.description ?? '').toLocaleLowerCase().includes(search)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Stack
|
|
54
|
+
renderRoot={props => <m.div layout {...props} />}
|
|
55
|
+
gap={8}
|
|
56
|
+
data-likec4-search-views
|
|
57
|
+
onKeyDown={(e) => {
|
|
58
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
59
|
+
const maxY = (e.target as HTMLElement).getBoundingClientRect().y
|
|
60
|
+
const elementButtons = [...document.querySelectorAll<HTMLButtonElement>(
|
|
61
|
+
`[data-likec4-search-elements] .likec4-element-button`,
|
|
62
|
+
)]
|
|
63
|
+
let elementButton = elementButtons.length > 1
|
|
64
|
+
? elementButtons.find((el, i, all) => centerY(el) > maxY || i === all.length - 1)
|
|
65
|
+
: null
|
|
66
|
+
elementButton ??= first(elementButtons)
|
|
67
|
+
|
|
68
|
+
if (elementButton) {
|
|
69
|
+
e.stopPropagation()
|
|
70
|
+
elementButton.focus()
|
|
71
|
+
}
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
}}>
|
|
75
|
+
{views.length === 0 && <NothingFound />}
|
|
76
|
+
{views.length > 0 && (
|
|
77
|
+
<VisuallyHidden>
|
|
78
|
+
<UnstyledButton
|
|
79
|
+
data-likec4-view
|
|
80
|
+
tabIndex={-1}
|
|
81
|
+
onFocus={e => {
|
|
82
|
+
e.stopPropagation()
|
|
83
|
+
moveFocusToSearchInput()
|
|
84
|
+
}} />
|
|
85
|
+
</VisuallyHidden>
|
|
86
|
+
)}
|
|
87
|
+
{views.map((view, i) => (
|
|
88
|
+
<m.div layoutId={`@view${view.id}`} key={view.id}>
|
|
89
|
+
<ViewButton
|
|
90
|
+
view={view}
|
|
91
|
+
search={search}
|
|
92
|
+
tabIndex={i === 0 ? 0 : -1}
|
|
93
|
+
/>
|
|
94
|
+
</m.div>
|
|
95
|
+
))}
|
|
96
|
+
</Stack>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function ViewButton(
|
|
101
|
+
{ className, view, loop = false, search, ...props }:
|
|
102
|
+
& {
|
|
103
|
+
view: LikeC4Model.View
|
|
104
|
+
search: string
|
|
105
|
+
loop?: boolean
|
|
106
|
+
}
|
|
107
|
+
& UnstyledButtonProps
|
|
108
|
+
& ElementProps<'button'>,
|
|
109
|
+
) {
|
|
110
|
+
const navigateTo = useCloseSearchAndNavigateTo()
|
|
111
|
+
const currentViewId = useCurrentViewId()
|
|
112
|
+
const isCurrentView = view.id === currentViewId
|
|
113
|
+
return (
|
|
114
|
+
<UnstyledButton
|
|
115
|
+
{...props}
|
|
116
|
+
className={clsx(css.focusable, css.viewButton, className)}
|
|
117
|
+
data-likec4-view={view.id}
|
|
118
|
+
{...isCurrentView && { 'data-disabled': true }}
|
|
119
|
+
onClick={(e) => {
|
|
120
|
+
e.stopPropagation()
|
|
121
|
+
navigateTo(view.id)
|
|
122
|
+
}}
|
|
123
|
+
onKeyDown={createScopedKeydownHandler({
|
|
124
|
+
siblingSelector: '[data-likec4-view]',
|
|
125
|
+
parentSelector: '[data-likec4-search-views]',
|
|
126
|
+
activateOnFocus: false,
|
|
127
|
+
loop,
|
|
128
|
+
orientation: 'vertical',
|
|
129
|
+
onKeyDown: (e) => {
|
|
130
|
+
if (e.nativeEvent.code === 'Space') {
|
|
131
|
+
e.stopPropagation()
|
|
132
|
+
navigateTo(view.id)
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
})}>
|
|
136
|
+
<ThemeIcon variant="transparent">
|
|
137
|
+
{isDeploymentView(view)
|
|
138
|
+
? <IconStack2 stroke={1.8} />
|
|
139
|
+
: <IconZoomScan stroke={1.8} />}
|
|
140
|
+
</ThemeIcon>
|
|
141
|
+
<Box style={{ flexGrow: 1 }}>
|
|
142
|
+
<Group gap={'xs'} wrap="nowrap" align="center">
|
|
143
|
+
<Highlight component="div" highlight={search} className={css.viewTitle}>
|
|
144
|
+
{view.title || 'untitled'}
|
|
145
|
+
</Highlight>
|
|
146
|
+
{isCurrentView && <Badge size="xs" fz={9} radius={'sm'}>current</Badge>}
|
|
147
|
+
</Group>
|
|
148
|
+
<Highlight
|
|
149
|
+
highlight={view.$view.description ? search : ''}
|
|
150
|
+
component="div"
|
|
151
|
+
className={css.viewDescription}
|
|
152
|
+
lineClamp={1}>
|
|
153
|
+
{view.$view.description || 'No description'}
|
|
154
|
+
</Highlight>
|
|
155
|
+
</Box>
|
|
156
|
+
</UnstyledButton>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { fallbackVar, globalStyle, style } from '@vanilla-extract/css'
|
|
2
|
+
import { mantine } from '../../../theme-vars'
|
|
3
|
+
import { button, description, descriptionColor, iconColor, title } from './_shared.css'
|
|
4
|
+
|
|
5
|
+
export { focusable } from './_shared.css'
|
|
6
|
+
|
|
7
|
+
export const viewButton = style([button, {
|
|
8
|
+
flexWrap: 'nowrap',
|
|
9
|
+
display: 'flex',
|
|
10
|
+
}])
|
|
11
|
+
|
|
12
|
+
globalStyle(`${viewButton} .mantine-ThemeIcon-root`, {
|
|
13
|
+
// transition: transitions.fast,
|
|
14
|
+
color: fallbackVar(iconColor, mantine.colors.dark[2]),
|
|
15
|
+
vars: {
|
|
16
|
+
'--ti-size': '24px',
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
globalStyle(`${viewButton}[data-disabled] .mantine-ThemeIcon-root`, {
|
|
20
|
+
opacity: 0.45,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const viewTitle = style([title])
|
|
24
|
+
export const viewDescription = style([description])
|
|
25
|
+
|
|
26
|
+
export const viewButtonDescription = style({
|
|
27
|
+
marginTop: 4,
|
|
28
|
+
// transition: transitions.fast,
|
|
29
|
+
color: fallbackVar(descriptionColor, mantine.colors.dimmed),
|
|
30
|
+
fontSize: 13,
|
|
31
|
+
lineHeight: 1.4,
|
|
32
|
+
selectors: {
|
|
33
|
+
[`:where(${viewButton}[data-disabled]) &`]: {
|
|
34
|
+
opacity: 0.85,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type StyleRule, createVar, fallbackVar, globalStyle, style } from '@vanilla-extract/css'
|
|
2
|
+
import { mantine } from '../../../theme-vars'
|
|
3
|
+
import { whereDark, whereLight } from '../../../theme-vars.css'
|
|
4
|
+
|
|
5
|
+
export const titleColor = createVar('title-color')
|
|
6
|
+
export const descriptionColor = createVar('description-color')
|
|
7
|
+
export const iconColor = createVar('icon-color')
|
|
8
|
+
|
|
9
|
+
export const buttonFocused = {
|
|
10
|
+
outline: 'none',
|
|
11
|
+
backgroundColor: mantine.colors.primaryColors[8],
|
|
12
|
+
borderColor: mantine.colors.primaryColors[9],
|
|
13
|
+
vars: {
|
|
14
|
+
[iconColor]: mantine.colors.primaryColors[2],
|
|
15
|
+
[titleColor]: mantine.colors.primaryColors[0],
|
|
16
|
+
[descriptionColor]: mantine.colors.primaryColors[1],
|
|
17
|
+
},
|
|
18
|
+
} satisfies StyleRule
|
|
19
|
+
|
|
20
|
+
export const button = style({
|
|
21
|
+
display: 'flex',
|
|
22
|
+
width: '100%',
|
|
23
|
+
background: mantine.colors.body,
|
|
24
|
+
borderRadius: mantine.radius.sm,
|
|
25
|
+
padding: `12px 8px 12px 14px`,
|
|
26
|
+
minHeight: 60,
|
|
27
|
+
gap: 10,
|
|
28
|
+
// alignItems: 'flex-start',
|
|
29
|
+
// transition: `all 50ms ${easings.inOut}`,
|
|
30
|
+
border: `1px solid ${mantine.colors.defaultBorder}`,
|
|
31
|
+
vars: {
|
|
32
|
+
[titleColor]: mantine.colors.dark[1],
|
|
33
|
+
[iconColor]: mantine.colors.dimmed,
|
|
34
|
+
[descriptionColor]: mantine.colors.dimmed,
|
|
35
|
+
},
|
|
36
|
+
':hover': {
|
|
37
|
+
...buttonFocused,
|
|
38
|
+
borderColor: mantine.colors.primaryColors[9],
|
|
39
|
+
backgroundColor: `color-mix(in srgb, ${buttonFocused.backgroundColor}, transparent 40%)`,
|
|
40
|
+
},
|
|
41
|
+
':focus': buttonFocused,
|
|
42
|
+
selectors: {
|
|
43
|
+
[`${whereDark} &`]: {
|
|
44
|
+
borderColor: 'transparent',
|
|
45
|
+
backgroundColor: `color-mix(in srgb, ${mantine.colors.dark[6]}, transparent 20%)`,
|
|
46
|
+
// background: mantine.colors.dark[6],
|
|
47
|
+
},
|
|
48
|
+
[`${whereLight} &`]: {
|
|
49
|
+
backgroundColor: `color-mix(in srgb, ${mantine.colors.white}, transparent 10%)`,
|
|
50
|
+
vars: {
|
|
51
|
+
[iconColor]: mantine.colors.gray[6],
|
|
52
|
+
[titleColor]: mantine.colors.gray[7],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
[`${whereLight} &:hover`]: {
|
|
56
|
+
borderColor: mantine.colors.primaryColors[6],
|
|
57
|
+
backgroundColor: mantine.colors.primaryColors[5],
|
|
58
|
+
vars: {
|
|
59
|
+
[iconColor]: mantine.colors.primaryColors[3],
|
|
60
|
+
[titleColor]: mantine.colors.primaryColors[0],
|
|
61
|
+
[descriptionColor]: mantine.colors.primaryColors[1],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
export const focusable = style({})
|
|
68
|
+
|
|
69
|
+
export const title = style({
|
|
70
|
+
color: fallbackVar(titleColor, mantine.colors.gray[7]),
|
|
71
|
+
fontSize: 16,
|
|
72
|
+
fontWeight: 500,
|
|
73
|
+
lineHeight: 1.1,
|
|
74
|
+
selectors: {
|
|
75
|
+
[`:where([data-disabled]) &`]: {
|
|
76
|
+
opacity: 0.4,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
export const description = style({
|
|
81
|
+
marginTop: 4,
|
|
82
|
+
color: fallbackVar(descriptionColor, mantine.colors.dimmed),
|
|
83
|
+
fontSize: 12,
|
|
84
|
+
lineHeight: 1.4,
|
|
85
|
+
selectors: {
|
|
86
|
+
[`:where([data-disabled]) &`]: {
|
|
87
|
+
opacity: 0.85,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
export const emptyBoX = style({
|
|
93
|
+
width: '100%',
|
|
94
|
+
height: '100%',
|
|
95
|
+
border: `2px dashed ${mantine.colors.defaultBorder}`,
|
|
96
|
+
borderRadius: mantine.radius.md,
|
|
97
|
+
display: 'flex',
|
|
98
|
+
justifyContent: 'center',
|
|
99
|
+
alignItems: 'center',
|
|
100
|
+
fontSize: mantine.fontSizes.md,
|
|
101
|
+
color: mantine.colors.dimmed,
|
|
102
|
+
padding: mantine.spacing.md,
|
|
103
|
+
paddingBlock: mantine.spacing.xl,
|
|
104
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type Fqn, type LikeC4Model, type ViewId, DiagramNode } from '@likec4/core'
|
|
2
|
+
import { createSafeContext } from '@mantine/core'
|
|
3
|
+
import { useCallbackRef } from '@mantine/hooks'
|
|
4
|
+
import { useStore } from '@nanostores/react'
|
|
5
|
+
import { atom, computed, onMount } from 'nanostores'
|
|
6
|
+
import { useDeferredValue } from 'react'
|
|
7
|
+
import { useDiagram } from '../../../hooks/useDiagram'
|
|
8
|
+
|
|
9
|
+
const $search = atom('')
|
|
10
|
+
|
|
11
|
+
export function setSearch(term: string) {
|
|
12
|
+
$search.set(term)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useSearch() {
|
|
16
|
+
return useStore($search)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const $normalizedSearch = computed($search, search => {
|
|
20
|
+
const v = search.trim().toLowerCase()
|
|
21
|
+
return v.length > 1 ? v : ''
|
|
22
|
+
})
|
|
23
|
+
export function useNormalizedSearch() {
|
|
24
|
+
return useDeferredValue(useStore($normalizedSearch))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const [LikeC4SearchContext, useCloseSearch] = createSafeContext<(cb?: () => void) => void>('LikeC4Search')
|
|
28
|
+
|
|
29
|
+
type PickView = {
|
|
30
|
+
elementFqn: Fqn
|
|
31
|
+
scoped: LikeC4Model.View[]
|
|
32
|
+
others: LikeC4Model.View[]
|
|
33
|
+
}
|
|
34
|
+
const $pickView = atom<PickView | null>(null)
|
|
35
|
+
onMount($search, () => {
|
|
36
|
+
// reset search on mount
|
|
37
|
+
$pickView.set(null)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export function usePickView() {
|
|
41
|
+
return useStore($pickView)
|
|
42
|
+
}
|
|
43
|
+
export function setPickView(data: PickView | null) {
|
|
44
|
+
$pickView.set(data)
|
|
45
|
+
}
|
|
46
|
+
export function wasResetPickView() {
|
|
47
|
+
if ($pickView.get() !== null) {
|
|
48
|
+
$pickView.set(null)
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const $pickViewActive = computed($pickView, pickView => pickView !== null)
|
|
55
|
+
export function useIsPickViewActive() {
|
|
56
|
+
return useStore($pickViewActive)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useCloseSearchAndNavigateTo() {
|
|
60
|
+
const diagram = useDiagram()
|
|
61
|
+
const close = useCloseSearch()
|
|
62
|
+
return useCallbackRef((viewId: ViewId, fromElementFqn?: Fqn) => {
|
|
63
|
+
close(() => {
|
|
64
|
+
fromElementFqn ??= $pickView.get()?.elementFqn
|
|
65
|
+
setPickView(null)
|
|
66
|
+
const fromNode = fromElementFqn
|
|
67
|
+
? diagram.getContext().view.nodes.find(n => DiagramNode.modelRef(n) === fromElementFqn)?.id
|
|
68
|
+
: undefined
|
|
69
|
+
if (diagram.currentView().id === viewId && fromNode) {
|
|
70
|
+
diagram.focusNode(fromNode)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
diagram.navigateTo(viewId, fromNode)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function stopAndPrevent(e: React.KeyboardEvent | KeyboardEvent) {
|
|
2
|
+
e.stopPropagation()
|
|
3
|
+
e.preventDefault()
|
|
4
|
+
return
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function centerY(element: HTMLElement) {
|
|
8
|
+
const rect = element.getBoundingClientRect()
|
|
9
|
+
const y = rect.y + Math.floor(rect.height / 2)
|
|
10
|
+
return y
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function moveFocusToSearchInput() {
|
|
14
|
+
const input = document.getElementById('likec4searchinput') as HTMLInputElement | null
|
|
15
|
+
if (input) {
|
|
16
|
+
const length = input.value.length
|
|
17
|
+
input.focus()
|
|
18
|
+
input.setSelectionRange(length, length)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -5,7 +5,6 @@ import { useMemo, useRef } from 'react'
|
|
|
5
5
|
import { filter, hasAtLeast, isNullish, map, pipe } from 'remeda'
|
|
6
6
|
import { type XYStoreApi, useXYStoreApi } from '../hooks'
|
|
7
7
|
// import { type XYStoreApi } from '../hooks/useXYFlow'
|
|
8
|
-
import { useSyncedRef } from '@react-hookz/web'
|
|
9
8
|
import { useDiagram } from '../hooks/useDiagram'
|
|
10
9
|
import type { Types } from './types'
|
|
11
10
|
|
|
@@ -255,11 +254,11 @@ type LayoutConstraints = {
|
|
|
255
254
|
*/
|
|
256
255
|
export function useLayoutConstraints(): LayoutConstraints {
|
|
257
256
|
const xystore = useXYStoreApi()
|
|
258
|
-
const diagram =
|
|
257
|
+
const diagram = useDiagram()
|
|
259
258
|
const solverRef = useRef<ReturnType<typeof createLayoutConstraints>>(undefined)
|
|
260
259
|
return useMemo((): LayoutConstraints => ({
|
|
261
260
|
onNodeDragStart: (_event, xynode) => {
|
|
262
|
-
diagram.
|
|
261
|
+
diagram.cancelSaveManualLayout()
|
|
263
262
|
const { nodeLookup } = xystore.getState()
|
|
264
263
|
const draggingNodes = pipe(
|
|
265
264
|
Array.from(nodeLookup.values()),
|
|
@@ -280,9 +279,9 @@ export function useLayoutConstraints(): LayoutConstraints {
|
|
|
280
279
|
},
|
|
281
280
|
onNodeDragStop: () => {
|
|
282
281
|
solverRef.current?.updateXYFlowNodes()
|
|
283
|
-
diagram.
|
|
282
|
+
diagram.scheduleSaveManualLayout()
|
|
284
283
|
// diagramApi.getState().scheduleSaveManualLayout()
|
|
285
284
|
solverRef.current = undefined
|
|
286
285
|
},
|
|
287
|
-
}), [xystore])
|
|
286
|
+
}), [xystore, diagram])
|
|
288
287
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type Fqn, type LikeC4Model, type ViewId, compareNatural } from '@likec4/core'
|
|
2
2
|
import { useMemo } from 'react'
|
|
3
3
|
import { useLikeC4Model } from './useLikeC4Model'
|
|
4
4
|
|
|
@@ -8,15 +8,15 @@ interface LikeC4ModelTreeNodeData {
|
|
|
8
8
|
children: LikeC4ModelTreeNodeData[]
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const sortByLabel = (a: LikeC4ModelTreeNodeData, b: LikeC4ModelTreeNodeData) => compareNatural(a.label, b.label)
|
|
11
|
+
export const sortByLabel = (a: LikeC4ModelTreeNodeData, b: LikeC4ModelTreeNodeData) => compareNatural(a.label, b.label)
|
|
12
12
|
|
|
13
13
|
function buildNode(
|
|
14
|
-
element: LikeC4Model.Node | LikeC4Model.Element
|
|
14
|
+
element: LikeC4Model.Node | LikeC4Model.Element,
|
|
15
15
|
): LikeC4ModelTreeNodeData {
|
|
16
16
|
return {
|
|
17
17
|
label: element.title,
|
|
18
18
|
value: element.id,
|
|
19
|
-
children: [...element.children()].map(buildNode).sort(sortByLabel)
|
|
19
|
+
children: [...element.children()].map(buildNode).sort(sortByLabel),
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|