@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.
@@ -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
- {!hideFocusView && (
207
+ {showFilters && (
195
208
  <>
196
209
  <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
197
- <Tooltip label={focusMode ? 'Show context' : 'Focus on this view'} placement="top" openDelay={200}>
198
- <Button
199
- variant="ghost"
200
- h="28px"
201
- px={2.5}
202
- color={focusMode ? 'var(--accent)' : 'gray.300'}
203
- bg={focusMode ? 'rgba(var(--accent-rgb), 0.12)' : 'transparent'}
204
- _hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
205
- onClick={() => onFocusModeChange(!focusMode)}
206
- >
207
- <HStack spacing={1.5}>
208
- <FocusSvg />
209
- <Text fontSize="11px" fontWeight="normal">Focus View</Text>
210
- <Box w="6px" h="6px" rounded="full" bg={focusMode ? 'var(--accent)' : 'gray.500'} />
211
- </HStack>
212
- </Button>
213
- </Tooltip>
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="Branches"
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) useStore.getState().setTreeData(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 Props extends CoreUISlots {
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
- setSelectedEdge(null)
502
- setSelectedProxyConnectorDetails(null)
503
- closeProxyConnectorPanelRef.current()
504
- closeConnectorPanelRef.current()
505
- setSelectedElement({
506
- id: obj.element_id, name: obj.name, description: obj.description, kind: obj.kind,
507
- technology: obj.technology, url: obj.url, logo_url: obj.logo_url,
508
- technology_connectors: obj.technology_connectors, tags: obj.tags, repo: obj.repo,
509
- branch: obj.branch, file_path: obj.file_path, language: obj.language,
510
- created_at: '', updated_at: '', has_view: false, view_label: null,
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: 100 })
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
- }, [canvas.stableOnZoomIn, canvas.stableOnZoomOut, canvas.stableOnNavigateToView, canvas.stableOnRemoveElement, canvas.stableOnConnectTo, canvas.stableOnInteractionStart, canvas.stableOnStartHandleReconnect])
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: 400 })
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
@@ -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 Diagram" to get started.</Text>
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
+ })
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Truncates a string to a specified length and appends an ellipsis.
3
+ */
4
+ export const truncate = (str: string, limit: number = 15): string => {
5
+ if (str.length <= limit) return str
6
+ return str.slice(0, limit) + '...'
7
+ }