@tldiagram/core-ui 2.0.3 → 2.0.5
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/components/FloatingEdge.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8700 -8398
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +1 -0
- package/dist/pages/ViewEditor/index.d.ts +6 -1
- package/dist/utils/string.d.ts +4 -0
- package/dist/utils/string.test.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/CrossBranchControls.tsx +178 -61
- package/src/components/FloatingEdge.tsx +145 -39
- package/src/components/ProxyConnectorPanel.tsx +3 -2
- package/src/components/ViewFloatingMenu.tsx +158 -68
- package/src/index.ts +1 -1
- package/src/pages/InfiniteZoom.tsx +4 -4
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +38 -0
- package/src/pages/ViewEditor/hooks/useViewData.ts +12 -2
- package/src/pages/ViewEditor/index.tsx +51 -22
- package/src/pages/ViewsGrid.tsx +2 -2
- package/src/utils/string.test.ts +19 -0
- package/src/utils/string.ts +7 -0
|
@@ -2,7 +2,7 @@ import React, { memo } from 'react'
|
|
|
2
2
|
import type { ViewFloatingMenuSlots } from '../slots'
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
-
HStack, Tooltip, Button, Box, Text, Popover, PopoverTrigger, Portal, PopoverContent, PopoverBody, IconButton, Slider, SliderTrack, SliderFilledTrack, SliderThumb, useDisclosure
|
|
5
|
+
HStack, Tooltip, Button, Box, Text, Popover, PopoverTrigger, Portal, PopoverContent, PopoverBody, IconButton, Slider, SliderTrack, SliderFilledTrack, SliderThumb, Switch, VStack, useDisclosure
|
|
6
6
|
} from '@chakra-ui/react'
|
|
7
7
|
import { DownloadIcon } from '@chakra-ui/icons'
|
|
8
8
|
import {
|
|
@@ -15,12 +15,21 @@ import {
|
|
|
15
15
|
CollapseExtrasIcon as CollapseExtrasSvg,
|
|
16
16
|
FocusIcon as FocusSvg,
|
|
17
17
|
TagsIcon,
|
|
18
|
+
ChevronDownIcon,
|
|
18
19
|
} from './Icons'
|
|
19
20
|
import { KbdHint } from './PanelUI'
|
|
20
21
|
import { RedoSvg, UndoSvg } from './ViewDrawMenu'
|
|
21
22
|
import { useViewEditorContext } from '../pages/ViewEditor/context'
|
|
22
23
|
import type { Tag, ViewLayer } from '../types'
|
|
23
24
|
|
|
25
|
+
const DENSITY_STOPS = [
|
|
26
|
+
{ value: -2, label: 'Quiet' },
|
|
27
|
+
{ value: -1, label: 'Lean' },
|
|
28
|
+
{ value: 0, label: 'Normal' },
|
|
29
|
+
{ value: 1, label: 'Rich' },
|
|
30
|
+
{ value: 2, label: 'Full' },
|
|
31
|
+
] as const
|
|
32
|
+
|
|
24
33
|
export interface ViewFloatingMenuProps extends ViewFloatingMenuSlots {
|
|
25
34
|
handleAddElementAtCenter: () => void
|
|
26
35
|
drawingMode: boolean
|
|
@@ -105,7 +114,11 @@ function ViewFloatingMenu({
|
|
|
105
114
|
}: ViewFloatingMenuProps) {
|
|
106
115
|
const { canEdit } = useViewEditorContext()
|
|
107
116
|
const { isOpen: isTagsOpen, onClose: onTagsClose, onToggle: onTagsToggle } = useDisclosure()
|
|
117
|
+
const { isOpen: isFiltersOpen, onClose: onFiltersClose, onToggle: onFiltersToggle } = useDisclosure()
|
|
108
118
|
const [draftDensityLevel, setDraftDensityLevel] = React.useState(densityLevel)
|
|
119
|
+
const activeDensityLabel = DENSITY_STOPS.find((stop) => stop.value === draftDensityLevel)?.label ?? 'Normal'
|
|
120
|
+
const showFilters = !hideFocusView || !!onDensityLevelChange
|
|
121
|
+
const hasActiveFilters = (!hideFocusView && focusMode) || (!!onDensityLevelChange && densityLevel !== 0)
|
|
109
122
|
|
|
110
123
|
React.useEffect(() => {
|
|
111
124
|
setDraftDensityLevel(densityLevel)
|
|
@@ -191,26 +204,152 @@ function ViewFloatingMenu({
|
|
|
191
204
|
</>
|
|
192
205
|
)}
|
|
193
206
|
|
|
194
|
-
{
|
|
207
|
+
{showFilters && (
|
|
195
208
|
<>
|
|
196
209
|
<Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
|
|
197
|
-
<
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
<
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
210
|
+
<Popover isOpen={isFiltersOpen} onClose={onFiltersClose} placement="top" isLazy closeOnBlur>
|
|
211
|
+
<PopoverTrigger>
|
|
212
|
+
<Button
|
|
213
|
+
variant="ghost"
|
|
214
|
+
h="28px"
|
|
215
|
+
px={2.5}
|
|
216
|
+
color={isFiltersOpen || hasActiveFilters ? 'var(--accent)' : 'gray.300'}
|
|
217
|
+
bg={hasActiveFilters ? 'rgba(var(--accent-rgb), 0.12)' : 'transparent'}
|
|
218
|
+
_hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
|
|
219
|
+
onClick={onFiltersToggle}
|
|
220
|
+
aria-label="Open filters"
|
|
221
|
+
>
|
|
222
|
+
<HStack spacing={1.5}>
|
|
223
|
+
<FocusSvg />
|
|
224
|
+
<Text fontSize="11px" fontWeight={hasActiveFilters ? 'semibold' : 'normal'}>Filters</Text>
|
|
225
|
+
{hasActiveFilters && <Box w="6px" h="6px" rounded="full" bg="var(--accent)" />}
|
|
226
|
+
<ChevronDownIcon size={10} strokeWidth={3.5} />
|
|
227
|
+
</HStack>
|
|
228
|
+
</Button>
|
|
229
|
+
</PopoverTrigger>
|
|
230
|
+
<Portal>
|
|
231
|
+
<PopoverContent
|
|
232
|
+
bg="linear-gradient(180deg, rgba(var(--bg-main-rgb), 0.98) 0%, rgba(var(--bg-main-rgb), 0.92) 100%)"
|
|
233
|
+
backdropFilter="blur(22px)"
|
|
234
|
+
borderColor="whiteAlpha.100"
|
|
235
|
+
boxShadow="0 18px 48px rgba(0,0,0,0.46), inset 0 1px 0 rgba(255,255,255,0.04)"
|
|
236
|
+
borderRadius="lg"
|
|
237
|
+
width="280px"
|
|
238
|
+
_focus={{ boxShadow: 'none' }}
|
|
239
|
+
>
|
|
240
|
+
<PopoverBody p={3}>
|
|
241
|
+
<VStack align="stretch" spacing={3}>
|
|
242
|
+
{!hideFocusView && (
|
|
243
|
+
<HStack
|
|
244
|
+
justify="space-between"
|
|
245
|
+
spacing={3}
|
|
246
|
+
px={2.5}
|
|
247
|
+
py={2}
|
|
248
|
+
rounded="md"
|
|
249
|
+
bg={focusMode ? 'rgba(var(--accent-rgb), 0.10)' : 'whiteAlpha.50'}
|
|
250
|
+
border="1px solid"
|
|
251
|
+
borderColor={focusMode ? 'rgba(var(--accent-rgb), 0.22)' : 'whiteAlpha.100'}
|
|
252
|
+
>
|
|
253
|
+
<HStack spacing={2.5} minW={0}>
|
|
254
|
+
<Box color={focusMode ? 'var(--accent)' : 'gray.400'} flexShrink={0}>
|
|
255
|
+
<FocusSvg />
|
|
256
|
+
</Box>
|
|
257
|
+
<Box minW={0}>
|
|
258
|
+
<Text fontSize="xs" fontWeight="semibold" color="whiteAlpha.900">Hide External</Text>
|
|
259
|
+
</Box>
|
|
260
|
+
</HStack>
|
|
261
|
+
<Switch
|
|
262
|
+
size="sm"
|
|
263
|
+
isChecked={focusMode}
|
|
264
|
+
onChange={(event) => onFocusModeChange(event.target.checked)}
|
|
265
|
+
colorScheme="teal"
|
|
266
|
+
flexShrink={0}
|
|
267
|
+
aria-label="Toggle external view"
|
|
268
|
+
/>
|
|
269
|
+
</HStack>
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
{onDensityLevelChange && (
|
|
273
|
+
<Box
|
|
274
|
+
px={2.5}
|
|
275
|
+
py={2.5}
|
|
276
|
+
rounded="md"
|
|
277
|
+
bg="whiteAlpha.50"
|
|
278
|
+
border="1px solid"
|
|
279
|
+
borderColor="whiteAlpha.100"
|
|
280
|
+
>
|
|
281
|
+
<HStack justify="space-between" mb={2.5}>
|
|
282
|
+
<Box>
|
|
283
|
+
<Text fontSize="xs" fontWeight="semibold" color="whiteAlpha.900">Density</Text>
|
|
284
|
+
<Text fontSize="10px" color="whiteAlpha.600">Control how much detail is shown</Text>
|
|
285
|
+
</Box>
|
|
286
|
+
<Text
|
|
287
|
+
fontSize="10px"
|
|
288
|
+
fontWeight="bold"
|
|
289
|
+
color="var(--accent)"
|
|
290
|
+
bg="rgba(var(--accent-rgb), 0.10)"
|
|
291
|
+
border="1px solid"
|
|
292
|
+
borderColor="rgba(var(--accent-rgb), 0.18)"
|
|
293
|
+
rounded="full"
|
|
294
|
+
px={2}
|
|
295
|
+
py={0.5}
|
|
296
|
+
>
|
|
297
|
+
{activeDensityLabel}
|
|
298
|
+
</Text>
|
|
299
|
+
</HStack>
|
|
300
|
+
<Box px={1} pt={1} pb={0.5}>
|
|
301
|
+
<Slider
|
|
302
|
+
aria-label="Density"
|
|
303
|
+
min={-2}
|
|
304
|
+
max={2}
|
|
305
|
+
step={1}
|
|
306
|
+
value={draftDensityLevel}
|
|
307
|
+
onChange={setDraftDensityLevel}
|
|
308
|
+
onChangeEnd={(value) => {
|
|
309
|
+
setDraftDensityLevel(value)
|
|
310
|
+
onDensityLevelChange(value)
|
|
311
|
+
}}
|
|
312
|
+
focusThumbOnChange={false}
|
|
313
|
+
>
|
|
314
|
+
<SliderTrack h="4px" bg="whiteAlpha.200">
|
|
315
|
+
<SliderFilledTrack bg="var(--accent)" />
|
|
316
|
+
</SliderTrack>
|
|
317
|
+
{DENSITY_STOPS.map((stop) => (
|
|
318
|
+
<Box
|
|
319
|
+
key={stop.value}
|
|
320
|
+
position="absolute"
|
|
321
|
+
left={`${((stop.value + 2) / 4) * 100}%`}
|
|
322
|
+
top="50%"
|
|
323
|
+
transform="translate(-50%, -50%)"
|
|
324
|
+
w={stop.value === draftDensityLevel ? '6px' : '2px'}
|
|
325
|
+
h={stop.value === draftDensityLevel ? '6px' : '10px'}
|
|
326
|
+
rounded="full"
|
|
327
|
+
bg={draftDensityLevel >= stop.value ? 'var(--accent)' : 'whiteAlpha.500'}
|
|
328
|
+
pointerEvents="none"
|
|
329
|
+
/>
|
|
330
|
+
))}
|
|
331
|
+
<SliderThumb boxSize="14px" bg="white" border="2px solid" borderColor="var(--accent)" />
|
|
332
|
+
</Slider>
|
|
333
|
+
<HStack justify="space-between" mt={2} px={0.5}>
|
|
334
|
+
{DENSITY_STOPS.map((stop) => (
|
|
335
|
+
<Text
|
|
336
|
+
key={stop.value}
|
|
337
|
+
fontSize="9px"
|
|
338
|
+
fontWeight={stop.value === draftDensityLevel ? 'bold' : 'medium'}
|
|
339
|
+
color={stop.value === draftDensityLevel ? 'whiteAlpha.900' : 'whiteAlpha.500'}
|
|
340
|
+
>
|
|
341
|
+
{stop.label}
|
|
342
|
+
</Text>
|
|
343
|
+
))}
|
|
344
|
+
</HStack>
|
|
345
|
+
</Box>
|
|
346
|
+
</Box>
|
|
347
|
+
)}
|
|
348
|
+
</VStack>
|
|
349
|
+
</PopoverBody>
|
|
350
|
+
</PopoverContent>
|
|
351
|
+
</Portal>
|
|
352
|
+
</Popover>
|
|
214
353
|
</>
|
|
215
354
|
)}
|
|
216
355
|
<Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
|
|
@@ -325,55 +464,6 @@ function ViewFloatingMenu({
|
|
|
325
464
|
</>
|
|
326
465
|
)}
|
|
327
466
|
|
|
328
|
-
{onDensityLevelChange && (
|
|
329
|
-
<>
|
|
330
|
-
<Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
|
|
331
|
-
<Tooltip label={`Density ${draftDensityLevel}`} placement="top" openDelay={200}>
|
|
332
|
-
<Box
|
|
333
|
-
w="92px"
|
|
334
|
-
h="28px"
|
|
335
|
-
px={2.5}
|
|
336
|
-
display="flex"
|
|
337
|
-
alignItems="center"
|
|
338
|
-
bg="whiteAlpha.50"
|
|
339
|
-
rounded="md"
|
|
340
|
-
>
|
|
341
|
-
<Slider
|
|
342
|
-
aria-label="Density"
|
|
343
|
-
min={-2}
|
|
344
|
-
max={2}
|
|
345
|
-
step={1}
|
|
346
|
-
value={draftDensityLevel}
|
|
347
|
-
onChange={setDraftDensityLevel}
|
|
348
|
-
onChangeEnd={(value) => {
|
|
349
|
-
setDraftDensityLevel(value)
|
|
350
|
-
onDensityLevelChange(value)
|
|
351
|
-
}}
|
|
352
|
-
focusThumbOnChange={false}
|
|
353
|
-
>
|
|
354
|
-
<SliderTrack h="3px" bg="whiteAlpha.200">
|
|
355
|
-
<SliderFilledTrack bg="var(--accent)" />
|
|
356
|
-
</SliderTrack>
|
|
357
|
-
{[-2, -1, 0, 1, 2].map((value) => (
|
|
358
|
-
<Box
|
|
359
|
-
key={value}
|
|
360
|
-
position="absolute"
|
|
361
|
-
left={`${((value + 2) / 4) * 100}%`}
|
|
362
|
-
top="50%"
|
|
363
|
-
transform="translate(-50%, -50%)"
|
|
364
|
-
w="1px"
|
|
365
|
-
h="9px"
|
|
366
|
-
bg={draftDensityLevel >= value ? 'var(--accent)' : 'whiteAlpha.400'}
|
|
367
|
-
pointerEvents="none"
|
|
368
|
-
/>
|
|
369
|
-
))}
|
|
370
|
-
<SliderThumb boxSize="12px" bg="white" border="2px solid" borderColor="var(--accent)" />
|
|
371
|
-
</Slider>
|
|
372
|
-
</Box>
|
|
373
|
-
</Tooltip>
|
|
374
|
-
</>
|
|
375
|
-
)}
|
|
376
|
-
|
|
377
467
|
{/* Draw mode toggle */}
|
|
378
468
|
<Tooltip
|
|
379
469
|
label={drawingMode ? 'Exit drawing mode' : 'Draw on diagram'}
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
// ─── Pages ───────────────────────────────────────────────────────────────────
|
|
11
|
-
export { default as ViewEditor } from './pages/ViewEditor'
|
|
11
|
+
export { default as ViewEditor, type ViewEditorPermissions } from './pages/ViewEditor'
|
|
12
12
|
export { default as ViewsPage } from './pages/Views'
|
|
13
13
|
export { default as ViewsGrid } from './pages/ViewsGrid'
|
|
14
14
|
export { default as Dependencies } from './pages/Dependencies'
|
|
@@ -257,12 +257,12 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
|
|
|
257
257
|
{noDiagrams ? 'No diagrams to explore yet' : 'Your diagrams are empty'}
|
|
258
258
|
</Text>
|
|
259
259
|
<Text color="gray.500" fontSize="sm" maxW="400px">
|
|
260
|
-
{noDiagrams
|
|
261
|
-
? 'Start by creating your first diagram in the workspace.'
|
|
260
|
+
{noDiagrams
|
|
261
|
+
? 'Start by creating your first diagram in the workspace.'
|
|
262
262
|
: 'Add elements to your diagrams in the editor to see them rendered on this infinite canvas.'}
|
|
263
263
|
</Text>
|
|
264
264
|
</VStack>
|
|
265
|
-
|
|
265
|
+
|
|
266
266
|
{!sharedToken && (
|
|
267
267
|
<Button size="sm" colorScheme="blue" onClick={() => navigate('/views')} borderRadius="full" px={6}>
|
|
268
268
|
{noDiagrams ? 'Create First Diagram' : 'Go to Editor'}
|
|
@@ -349,7 +349,7 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
|
|
|
349
349
|
onEnabledChange={setCrossBranchEnabled}
|
|
350
350
|
onBudgetChange={setCrossBranchConnectorBudget}
|
|
351
351
|
onPriorityChange={setCrossBranchConnectorPriority}
|
|
352
|
-
label="
|
|
352
|
+
label="Filters"
|
|
353
353
|
/>
|
|
354
354
|
|
|
355
355
|
{(allTags.length > 0 || layers.length > 0) && (
|
|
@@ -1008,6 +1008,43 @@ export function useCanvasInteractions({
|
|
|
1008
1008
|
document.addEventListener('pointercancel', up)
|
|
1009
1009
|
}, [canEdit, clearHandleReconnectListeners, performReconnect, rfNodesRef, _rfEdgesRef, syncHandleReconnectDrag])
|
|
1010
1010
|
|
|
1011
|
+
const stableOnReconnectPick = useCallback(async (targetElementId: number) => {
|
|
1012
|
+
const picking = reconnectPickingRef.current
|
|
1013
|
+
if (!canEdit || !picking) return false
|
|
1014
|
+
|
|
1015
|
+
const oldConnector = _rfEdgesRef.current.find((candidate) => candidate.id === String(picking.edgeId))
|
|
1016
|
+
const pickedNode = rfNodesRef.current.find((node) => node.id === String(targetElementId))
|
|
1017
|
+
if (!oldConnector || !pickedNode) return false
|
|
1018
|
+
|
|
1019
|
+
const fixedNodeId = picking.endpoint === 'source' ? oldConnector.target : oldConnector.source
|
|
1020
|
+
if (fixedNodeId === pickedNode.id) return false
|
|
1021
|
+
const fixedNode = rfNodesRef.current.find((node) => node.id === fixedNodeId)
|
|
1022
|
+
if (!fixedNode) return false
|
|
1023
|
+
|
|
1024
|
+
const closest = picking.endpoint === 'source'
|
|
1025
|
+
? findClosestHandles(pickedNode, fixedNode)
|
|
1026
|
+
: findClosestHandles(fixedNode, pickedNode)
|
|
1027
|
+
const newConnection: Connection = picking.endpoint === 'source'
|
|
1028
|
+
? {
|
|
1029
|
+
source: pickedNode.id,
|
|
1030
|
+
sourceHandle: ensureVisualHandleId(closest.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? closest.sourceHandle,
|
|
1031
|
+
target: fixedNode.id,
|
|
1032
|
+
targetHandle: ensureVisualHandleId(closest.targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? closest.targetHandle,
|
|
1033
|
+
}
|
|
1034
|
+
: {
|
|
1035
|
+
source: fixedNode.id,
|
|
1036
|
+
sourceHandle: ensureVisualHandleId(closest.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? closest.sourceHandle,
|
|
1037
|
+
target: pickedNode.id,
|
|
1038
|
+
targetHandle: ensureVisualHandleId(closest.targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? closest.targetHandle,
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
reconnectPickingRef.current = null
|
|
1042
|
+
setReconnectPicking(null)
|
|
1043
|
+
setConnectorLongPressMenu(null)
|
|
1044
|
+
await performReconnect(oldConnector, newConnection)
|
|
1045
|
+
return true
|
|
1046
|
+
}, [canEdit, _rfEdgesRef, performReconnect, rfNodesRef])
|
|
1047
|
+
|
|
1011
1048
|
// ── Click-connect ghost cursor tracking ────────────────────────────────────
|
|
1012
1049
|
useEffect(() => {
|
|
1013
1050
|
if (!clickConnectMode) {
|
|
@@ -1528,6 +1565,7 @@ export function useCanvasInteractions({
|
|
|
1528
1565
|
stableOnConnectTo,
|
|
1529
1566
|
stableOnInteractionStart,
|
|
1530
1567
|
stableOnStartHandleReconnect,
|
|
1568
|
+
stableOnReconnectPick,
|
|
1531
1569
|
showAddingElementAt,
|
|
1532
1570
|
// RF event handlers
|
|
1533
1571
|
onNodesChange,
|
|
@@ -227,7 +227,13 @@ export function useViewData({
|
|
|
227
227
|
queryFn: () => api.workspace.views.treeAround(viewId, { ancestorLevels: 2, descendantLevels: 2 }),
|
|
228
228
|
staleTime: 0,
|
|
229
229
|
}).catch(() => null)
|
|
230
|
-
if (tree)
|
|
230
|
+
if (tree) {
|
|
231
|
+
const links = buildViewContentLinks(tree, viewId, viewElementsRef.current)
|
|
232
|
+
useStore.getState().setTreeData(tree)
|
|
233
|
+
useStore.getState().setLinksMap(links.linksMap)
|
|
234
|
+
useStore.getState().setParentLinksMap(links.parentLinksMap)
|
|
235
|
+
useStore.getState().setIncomingLinks(links.incomingLinks)
|
|
236
|
+
}
|
|
231
237
|
}, [queryClient, viewId])
|
|
232
238
|
|
|
233
239
|
// ── Fetch view content ──────────────────────────────────────────────────
|
|
@@ -281,8 +287,12 @@ export function useViewData({
|
|
|
281
287
|
if (fresh) {
|
|
282
288
|
setViewElements(fresh.placements)
|
|
283
289
|
setConnectors(fresh.connectors)
|
|
290
|
+
const links = buildViewContentLinks(treeDataRef.current, viewId, fresh.placements)
|
|
291
|
+
setLinksMap(links.linksMap)
|
|
292
|
+
setParentLinksMap(links.parentLinksMap)
|
|
293
|
+
useStore.getState().setIncomingLinks(links.incomingLinks)
|
|
284
294
|
}
|
|
285
|
-
}, [queryClient, setConnectors, setViewElements, viewId])
|
|
295
|
+
}, [queryClient, setConnectors, setLinksMap, setParentLinksMap, setViewElements, viewId])
|
|
286
296
|
|
|
287
297
|
// ── Element mutation helpers ───────────────────────────────────────────────
|
|
288
298
|
const handleElementDeleted = useCallback((deletedId: number) => {
|
|
@@ -100,6 +100,8 @@ const nodeTypes = {
|
|
|
100
100
|
const edgeTypes = { default: ViewBezierConnector, contextStraightConnector: ContextStraightConnector, proxyConnectorEdge: ProxyConnectorEdge }
|
|
101
101
|
const EMPTY_LINKS: ViewConnector[] = []
|
|
102
102
|
const VIEW_EDITOR_MIN_ZOOM_FLOOR = 0.12
|
|
103
|
+
const VIEW_EDITOR_INITIAL_FIT_PADDING = 0.25
|
|
104
|
+
const VIEW_EDITOR_FOCUS_FIT_PADDING = 0.35
|
|
103
105
|
const VIEW_EDITOR_EMPTY_EXTENT_RATIO = 0.75
|
|
104
106
|
const VIEW_EDITOR_PAN_MARGIN_RATIO = 0.25
|
|
105
107
|
const VIEW_EDITOR_PAN_MARGIN_MIN = 180
|
|
@@ -159,6 +161,19 @@ function viewSnapshotsEqual(left: ViewMetadataSnapshot, right: ViewMetadataSnaps
|
|
|
159
161
|
return left.id === right.id && left.name === right.name && (left.level_label ?? '') === (right.level_label ?? '')
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
function nodesMatchCurrentView(nodes: RFNode[], elements: PlacedElement[], viewId: number | null) {
|
|
165
|
+
if (viewId === null || elements.length === 0) return false
|
|
166
|
+
if (!elements.every((element) => element.view_id === viewId)) return false
|
|
167
|
+
|
|
168
|
+
const nodesById = new Map(nodes.map((node) => [node.id, node]))
|
|
169
|
+
return elements.every((element) => {
|
|
170
|
+
const node = nodesById.get(String(element.element_id))
|
|
171
|
+
return node !== undefined &&
|
|
172
|
+
Math.abs(node.position.x - (element.position_x ?? 0)) < 0.5 &&
|
|
173
|
+
Math.abs(node.position.y - (element.position_y ?? 0)) < 0.5
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
162
177
|
function alphaColor(color: string, opacity: number): string {
|
|
163
178
|
if (opacity >= 1) return color
|
|
164
179
|
return `color-mix(in srgb, ${color} ${Math.round(opacity * 100)}%, transparent)`
|
|
@@ -193,12 +208,21 @@ function canonicalNodePairKey(leftId: string, rightId: string) {
|
|
|
193
208
|
|
|
194
209
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
195
210
|
|
|
196
|
-
interface
|
|
211
|
+
export interface ViewEditorPermissions {
|
|
212
|
+
canEdit?: boolean
|
|
213
|
+
isOwner?: boolean
|
|
214
|
+
isFreePlan?: boolean
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface Props extends CoreUISlots, ViewEditorPermissions {
|
|
197
218
|
demoOptions?: ViewEditorDemoOptions
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
function ViewEditorInner({
|
|
201
222
|
demoOptions,
|
|
223
|
+
canEdit = true,
|
|
224
|
+
isOwner = true,
|
|
225
|
+
isFreePlan = false,
|
|
202
226
|
canvasOverlaySlot,
|
|
203
227
|
toolbarSlot,
|
|
204
228
|
shareSlot,
|
|
@@ -225,10 +249,6 @@ function ViewEditorInner({
|
|
|
225
249
|
undo: undoViewEdit,
|
|
226
250
|
redo: redoViewEdit,
|
|
227
251
|
} = useViewEditHistory()
|
|
228
|
-
const canEdit = true
|
|
229
|
-
const isOwner = true
|
|
230
|
-
const isFreePlan = false
|
|
231
|
-
|
|
232
252
|
const setHeader = useSetHeader()
|
|
233
253
|
const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
|
|
234
254
|
const [densityLevel, setDensityLevel] = useState(0)
|
|
@@ -465,6 +485,7 @@ function ViewEditorInner({
|
|
|
465
485
|
const stableOnConnectToRef = useRef<(targetElementId: number) => Promise<void>>(async () => { })
|
|
466
486
|
const stableOnInteractionStartRef = useRef<(elementId: number, options?: { sourceHandle?: string; clientX?: number; clientY?: number }) => void>(() => { })
|
|
467
487
|
const stableOnStartHandleReconnectRef = useRef<(args: { edgeId: string; endpoint: 'source' | 'target'; handleId: string; clientX: number; clientY: number }) => void>(() => { })
|
|
488
|
+
const stableOnReconnectPickRef = useRef<(targetElementId: number) => Promise<boolean>>(async () => false)
|
|
468
489
|
|
|
469
490
|
// ── Drawing engine ────────────────────────────────────────────────────────
|
|
470
491
|
const drawing = useDrawingEngine(viewId)
|
|
@@ -498,18 +519,21 @@ function ViewEditorInner({
|
|
|
498
519
|
stableOnZoomOut: useCallback(async (id: number) => { await stableOnZoomOutRef.current(id) }, []),
|
|
499
520
|
stableOnNavigateToView: useCallback((id: number) => { stableOnNavigateToViewRef.current(id) }, []),
|
|
500
521
|
stableOnSelect: useCallback((obj: PlacedElement) => {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
522
|
+
void stableOnReconnectPickRef.current(obj.element_id).then((handled) => {
|
|
523
|
+
if (handled) return
|
|
524
|
+
setSelectedEdge(null)
|
|
525
|
+
setSelectedProxyConnectorDetails(null)
|
|
526
|
+
closeProxyConnectorPanelRef.current()
|
|
527
|
+
closeConnectorPanelRef.current()
|
|
528
|
+
setSelectedElement({
|
|
529
|
+
id: obj.element_id, name: obj.name, description: obj.description, kind: obj.kind,
|
|
530
|
+
technology: obj.technology, url: obj.url, logo_url: obj.logo_url,
|
|
531
|
+
technology_connectors: obj.technology_connectors, tags: obj.tags, repo: obj.repo,
|
|
532
|
+
branch: obj.branch, file_path: obj.file_path, language: obj.language,
|
|
533
|
+
created_at: '', updated_at: '', has_view: false, view_label: null,
|
|
534
|
+
})
|
|
535
|
+
openElementPanelRef.current()
|
|
511
536
|
})
|
|
512
|
-
openElementPanelRef.current()
|
|
513
537
|
}, []),
|
|
514
538
|
stableOnOpenCodePreview: useCallback((elementId: number) => {
|
|
515
539
|
const obj = previewViewElementsRef.current.find((o) => o.element_id === elementId)
|
|
@@ -669,7 +693,7 @@ function ViewEditorInner({
|
|
|
669
693
|
useEffect(() => {
|
|
670
694
|
const unsub = vscodeBridge.onMessage(async (msg: ExtensionToWebviewMessage) => {
|
|
671
695
|
if (msg.type === 'focus-element') {
|
|
672
|
-
fitView({ nodes: [{ id: String(msg.elementId) }], duration: 800, padding:
|
|
696
|
+
fitView({ nodes: [{ id: String(msg.elementId) }], duration: 800, padding: VIEW_EDITOR_FOCUS_FIT_PADDING })
|
|
673
697
|
} else if (msg.type === 'element-placed') {
|
|
674
698
|
if (viewId === null) return
|
|
675
699
|
try {
|
|
@@ -1032,7 +1056,8 @@ function ViewEditorInner({
|
|
|
1032
1056
|
stableOnConnectToRef.current = canvas.stableOnConnectTo
|
|
1033
1057
|
stableOnInteractionStartRef.current = canvas.stableOnInteractionStart
|
|
1034
1058
|
stableOnStartHandleReconnectRef.current = canvas.stableOnStartHandleReconnect
|
|
1035
|
-
|
|
1059
|
+
stableOnReconnectPickRef.current = canvas.stableOnReconnectPick
|
|
1060
|
+
}, [canvas.stableOnZoomIn, canvas.stableOnZoomOut, canvas.stableOnNavigateToView, canvas.stableOnRemoveElement, canvas.stableOnConnectTo, canvas.stableOnInteractionStart, canvas.stableOnStartHandleReconnect, canvas.stableOnReconnectPick])
|
|
1036
1061
|
const viewName = view?.name ?? null
|
|
1037
1062
|
|
|
1038
1063
|
const [expandedAncestorGroups, setExpandedAncestorGroups] = useState<Set<string>>(new Set())
|
|
@@ -1274,6 +1299,7 @@ function ViewEditorInner({
|
|
|
1274
1299
|
if (!rfReadyRef.current || !needsFitView.current) return
|
|
1275
1300
|
const nodes = rfNodesRef.current
|
|
1276
1301
|
if (nodes.length === 0) return
|
|
1302
|
+
if (!nodesMatchCurrentView(nodes, viewElementsRef.current, viewIdRef.current)) return
|
|
1277
1303
|
if (!nodes.every((n) => typeof n.width === 'number' && n.width > 0 && typeof n.height === 'number' && n.height > 0)) return
|
|
1278
1304
|
|
|
1279
1305
|
if (clampedRevealProgress !== null) {
|
|
@@ -1283,13 +1309,17 @@ function ViewEditorInner({
|
|
|
1283
1309
|
return
|
|
1284
1310
|
}
|
|
1285
1311
|
|
|
1286
|
-
const ok = safeFitView({ duration: 0, padding:
|
|
1312
|
+
const ok = safeFitView({ duration: 0, padding: VIEW_EDITOR_INITIAL_FIT_PADDING, minZoom: computedMinZoom, maxZoom: 4 })
|
|
1287
1313
|
if (ok) needsFitView.current = false
|
|
1288
1314
|
else setTimeout(() => { if (needsFitView.current) maybeFitView() }, 50)
|
|
1289
|
-
}, [applyDemoRevealViewport, clampedRevealProgress, safeFitView, rfNodesRef])
|
|
1315
|
+
}, [applyDemoRevealViewport, clampedRevealProgress, computedMinZoom, safeFitView, rfNodesRef, viewElementsRef, viewIdRef])
|
|
1290
1316
|
|
|
1291
1317
|
const onRFInit = useCallback(() => { rfReadyRef.current = true; maybeFitView() }, [maybeFitView])
|
|
1292
1318
|
|
|
1319
|
+
useEffect(() => {
|
|
1320
|
+
needsFitView.current = true
|
|
1321
|
+
}, [viewId])
|
|
1322
|
+
|
|
1293
1323
|
useEffect(() => { maybeFitView() }, [rfNodes, maybeFitView])
|
|
1294
1324
|
|
|
1295
1325
|
useEffect(() => {
|
|
@@ -1310,7 +1340,6 @@ function ViewEditorInner({
|
|
|
1310
1340
|
closeElementPanelRef.current()
|
|
1311
1341
|
closeConnectorPanelRef.current()
|
|
1312
1342
|
closeProxyConnectorPanelRef.current()
|
|
1313
|
-
needsFitView.current = true
|
|
1314
1343
|
}, [clearEditHistory, viewId])
|
|
1315
1344
|
|
|
1316
1345
|
// ── Dynamic viewport bounds ────────────────────────────────────────────────
|
|
@@ -1756,7 +1785,7 @@ function ViewEditorInner({
|
|
|
1756
1785
|
menu={connectorLongPressMenu}
|
|
1757
1786
|
onEdit={(edgeId) => { const connector = connectors.find((e) => e.id === edgeId); if (connector) { setSelectedEdge(connector); connectorPanel.onOpen() }; setConnectorLongPressMenu(null) }}
|
|
1758
1787
|
onMoveSource={(edgeId) => { const picking = { edgeId, endpoint: 'source' as const }; reconnectPickingRef.current = picking; setReconnectPicking(picking); setConnectorLongPressMenu(null) }}
|
|
1759
|
-
onMoveTarget={(edgeId) => { const picking = { edgeId, endpoint: 'target' as const }; reconnectPickingRef.current = picking; setConnectorLongPressMenu(null) }}
|
|
1788
|
+
onMoveTarget={(edgeId) => { const picking = { edgeId, endpoint: 'target' as const }; reconnectPickingRef.current = picking; setReconnectPicking(picking); setConnectorLongPressMenu(null) }}
|
|
1760
1789
|
onDelete={async (edgeId) => {
|
|
1761
1790
|
setConnectorLongPressMenu(null)
|
|
1762
1791
|
if (!viewId) return
|
package/src/pages/ViewsGrid.tsx
CHANGED
|
@@ -832,7 +832,7 @@ function ViewGridInner({ onShare, treeData, loading, focusedId, onFocusChange, s
|
|
|
832
832
|
onStartRename: () => startEdit(n.id, n.name),
|
|
833
833
|
onDetails: () => handleDetailsOpen(n.id),
|
|
834
834
|
onDelete: () => { setDeleteTargetId(n.id); onDeleteOpen() },
|
|
835
|
-
onShare: onShare ? () => onShare(n.id) : () => {},
|
|
835
|
+
onShare: onShare ? () => onShare(n.id) : () => { },
|
|
836
836
|
onEditNameChange: setEditName,
|
|
837
837
|
onEditCommit: commitEdit,
|
|
838
838
|
onEditCancel: cancelEdit,
|
|
@@ -1088,7 +1088,7 @@ function ViewGridInner({ onShare, treeData, loading, focusedId, onFocusChange, s
|
|
|
1088
1088
|
<Text color="gray.600" fontSize="sm" mb={1}>No views yet.</Text>
|
|
1089
1089
|
{canEdit && (
|
|
1090
1090
|
<>
|
|
1091
|
-
<Text color="gray.700" fontSize="xs" mb={4}>Click "New
|
|
1091
|
+
<Text color="gray.700" fontSize="xs" mb={4}>Click "+ New" to get started.</Text>
|
|
1092
1092
|
|
|
1093
1093
|
</>
|
|
1094
1094
|
)}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { truncate } from './string'
|
|
3
|
+
|
|
4
|
+
describe('truncate', () => {
|
|
5
|
+
it('does not truncate strings shorter than or equal to the limit', () => {
|
|
6
|
+
expect(truncate('hello', 10)).toBe('hello')
|
|
7
|
+
expect(truncate('1234567890', 10)).toBe('1234567890')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('truncates strings longer than the limit and adds ellipsis', () => {
|
|
11
|
+
expect(truncate('hello world', 5)).toBe('hello...')
|
|
12
|
+
expect(truncate('12345678901', 10)).toBe('1234567890...')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('uses default limit of 15', () => {
|
|
16
|
+
expect(truncate('123456789012345')).toBe('123456789012345')
|
|
17
|
+
expect(truncate('1234567890123456')).toBe('123456789012345...')
|
|
18
|
+
})
|
|
19
|
+
})
|