@tldiagram/core-ui 1.95.1 → 2.0.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.
Files changed (100) hide show
  1. package/dist/api/client.d.ts +184 -3
  2. package/dist/components/ConnectorPanel.d.ts +5 -1
  3. package/dist/components/CrossBranchControls.d.ts +4 -3
  4. package/dist/components/ElementNode.d.ts +5 -0
  5. package/dist/components/ElementPanel.d.ts +6 -1
  6. package/dist/components/LayoutSection.d.ts +2 -1
  7. package/dist/components/MergeDialog.d.ts +16 -0
  8. package/dist/components/NodeContainer.d.ts +2 -0
  9. package/dist/components/ProxyConnectorPanel.d.ts +4 -1
  10. package/dist/components/ViewExplorer/index.d.ts +1 -1
  11. package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
  12. package/dist/components/ViewFloatingMenu.d.ts +8 -1
  13. package/dist/components/ViewGridNode.d.ts +3 -0
  14. package/dist/components/ViewPanel.d.ts +2 -1
  15. package/dist/components/WorkspacePanel.d.ts +2 -0
  16. package/dist/components/ZUI/ZUICanvas.d.ts +4 -0
  17. package/dist/components/ZUI/focus.d.ts +32 -0
  18. package/dist/components/ZUI/focus.test.d.ts +1 -0
  19. package/dist/components/ZUI/layout.d.ts +2 -2
  20. package/dist/components/ZUI/proxy.d.ts +20 -4
  21. package/dist/components/ZUI/renderer.d.ts +35 -1
  22. package/dist/components/ZUI/types.d.ts +6 -0
  23. package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
  24. package/dist/context/WorkspaceVersionContext.d.ts +49 -0
  25. package/dist/crossBranch/resolve.d.ts +39 -2
  26. package/dist/crossBranch/resolve.test.d.ts +1 -0
  27. package/dist/crossBranch/settings.d.ts +6 -1
  28. package/dist/crossBranch/types.d.ts +8 -0
  29. package/dist/hooks/useElementSearch.d.ts +8 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +16529 -14030
  32. package/dist/pages/InfiniteZoom.d.ts +1 -0
  33. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
  34. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
  35. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
  36. package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
  37. package/dist/pages/viewsJumpSearch.d.ts +22 -0
  38. package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
  39. package/dist/store/useStore.d.ts +3 -0
  40. package/dist/types/index.d.ts +9 -0
  41. package/dist/utils/elementIcon.d.ts +2 -0
  42. package/dist/utils/elementIcon.test.d.ts +1 -0
  43. package/dist/utils/sourceEditor.d.ts +7 -0
  44. package/dist/utils/watchDiffSummary.d.ts +34 -0
  45. package/package.json +2 -2
  46. package/src/App.tsx +12 -8
  47. package/src/api/client.ts +488 -26
  48. package/src/components/CodePreviewPanel.tsx +90 -16
  49. package/src/components/ConnectorPanel.tsx +34 -3
  50. package/src/components/ContextNeighborElement.tsx +2 -5
  51. package/src/components/CrossBranchControls.tsx +46 -17
  52. package/src/components/ElementNode.tsx +98 -47
  53. package/src/components/ElementPanel.tsx +62 -25
  54. package/src/components/InlineElementAdder.tsx +8 -3
  55. package/src/components/LayoutSection.tsx +4 -1
  56. package/src/components/MergeDialog.tsx +269 -0
  57. package/src/components/NodeContainer.tsx +55 -17
  58. package/src/components/ProxyConnectorPanel.tsx +58 -16
  59. package/src/components/ViewBezierConnector.tsx +116 -21
  60. package/src/components/ViewExplorer/index.tsx +1 -1
  61. package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
  62. package/src/components/ViewFloatingMenu.tsx +110 -1
  63. package/src/components/ViewGridNode.tsx +59 -8
  64. package/src/components/ViewPanel.tsx +3 -2
  65. package/src/components/WorkspacePanel.tsx +938 -0
  66. package/src/components/ZUI/ZUICanvas.tsx +216 -122
  67. package/src/components/ZUI/focus.test.ts +534 -0
  68. package/src/components/ZUI/focus.ts +293 -0
  69. package/src/components/ZUI/layout.ts +7 -11
  70. package/src/components/ZUI/proxy.ts +470 -114
  71. package/src/components/ZUI/renderer.ts +510 -134
  72. package/src/components/ZUI/types.ts +6 -0
  73. package/src/components/ZUI/useZUIInteraction.ts +66 -29
  74. package/src/context/WorkspaceVersionContext.tsx +126 -0
  75. package/src/crossBranch/resolve.test.ts +342 -0
  76. package/src/crossBranch/resolve.ts +368 -68
  77. package/src/crossBranch/settings.ts +49 -3
  78. package/src/crossBranch/types.ts +9 -0
  79. package/src/hooks/useElementSearch.ts +45 -0
  80. package/src/index.css +11 -0
  81. package/src/index.ts +7 -0
  82. package/src/pages/AppearanceSettings.tsx +24 -1
  83. package/src/pages/Dependencies.tsx +231 -65
  84. package/src/pages/InfiniteZoom.tsx +41 -19
  85. package/src/pages/Settings.tsx +1 -1
  86. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
  87. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
  88. package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
  89. package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
  90. package/src/pages/ViewEditor/index.tsx +549 -59
  91. package/src/pages/Views.tsx +112 -41
  92. package/src/pages/ViewsGrid.tsx +332 -113
  93. package/src/pages/viewsJumpSearch.test.ts +193 -0
  94. package/src/pages/viewsJumpSearch.ts +111 -0
  95. package/src/store/useStore.ts +58 -0
  96. package/src/types/index.ts +10 -0
  97. package/src/utils/elementIcon.test.ts +28 -0
  98. package/src/utils/elementIcon.ts +20 -0
  99. package/src/utils/sourceEditor.ts +46 -0
  100. package/src/utils/watchDiffSummary.ts +159 -0
@@ -1,15 +1,19 @@
1
- import { Box, Button, HStack, Icon, Text, VStack, Divider, Flex } from '@chakra-ui/react'
1
+ import { Box, Button, HStack, Icon, Text, VStack, Divider, Flex, IconButton } from '@chakra-ui/react'
2
2
  import { useNavigate } from 'react-router-dom'
3
3
  import type { ProxyConnectorDetails } from '../crossBranch/types'
4
4
  import SlidingPanel from './SlidingPanel'
5
5
  import PanelHeader from './PanelHeader'
6
- import { ChevronRightIcon, NavigationIcon } from './Icons'
6
+ import { ChevronRightIcon, NavigationIcon, TrashIcon, EditIcon } from './Icons'
7
+ import { useViewEditorContext } from '../pages/ViewEditor/context'
8
+ import type { Connector } from '../types'
7
9
 
8
10
  interface Props {
9
11
  isOpen: boolean
10
12
  onClose: () => void
11
13
  details: ProxyConnectorDetails | null
12
14
  hasBackdrop?: boolean
15
+ onEdit?: (connector: Connector) => void
16
+ onDelete?: (connectorId: number, ownerViewId: number) => void
13
17
  }
14
18
 
15
19
  export default function ProxyConnectorPanel({
@@ -17,8 +21,11 @@ export default function ProxyConnectorPanel({
17
21
  onClose,
18
22
  details,
19
23
  hasBackdrop = true,
24
+ onEdit,
25
+ onDelete,
20
26
  }: Props) {
21
27
  const navigate = useNavigate()
28
+ const { canEdit } = useViewEditorContext()
22
29
 
23
30
  return (
24
31
  <SlidingPanel
@@ -27,6 +34,7 @@ export default function ProxyConnectorPanel({
27
34
  panelKey="proxy-connector-panel"
28
35
  width={{ base: 'calc(100vw - 24px)', md: '300px' }}
29
36
  hasBackdrop={hasBackdrop}
37
+ zIndex={950}
30
38
  >
31
39
  <PanelHeader title="Relationships" onClose={onClose} />
32
40
 
@@ -71,21 +79,54 @@ export default function ProxyConnectorPanel({
71
79
  >
72
80
  <VStack align="stretch" spacing={3}>
73
81
  <Box>
74
- <HStack spacing={2} mb={1}>
75
- <Text color="white" fontSize="sm" fontWeight="semibold" isTruncated>
76
- {leaf.source.actualElementName}
77
- </Text>
78
- <Icon as={ChevronRightIcon} color="whiteAlpha.400" />
79
- <Text color="white" fontSize="sm" fontWeight="semibold" isTruncated>
80
- {leaf.target.actualElementName}
81
- </Text>
82
- </HStack>
82
+ <HStack justify="space-between" align="start">
83
+ <VStack align="start" spacing={1} flex={1}>
84
+ <HStack spacing={2}>
85
+ <Text color="white" fontSize="sm" fontWeight="semibold" isTruncated>
86
+ {leaf.source.actualElementName}
87
+ </Text>
88
+ <Icon as={ChevronRightIcon} color="whiteAlpha.400" />
89
+ <Text color="white" fontSize="sm" fontWeight="semibold" isTruncated>
90
+ {leaf.target.actualElementName}
91
+ </Text>
92
+ </HStack>
93
+
94
+ {(leaf.connector.label || leaf.connector.relationship) && (
95
+ <Text color="gray.400" fontSize="xs" fontStyle={!leaf.connector.label ? 'italic' : 'normal'}>
96
+ {leaf.connector.label || leaf.connector.relationship}
97
+ </Text>
98
+ )}
99
+ </VStack>
83
100
 
84
- {(leaf.connector.label || leaf.connector.relationship) && (
85
- <Text color="gray.400" fontSize="xs" fontStyle={!leaf.connector.label ? 'italic' : 'normal'}>
86
- {leaf.connector.label || leaf.connector.relationship}
87
- </Text>
88
- )}
101
+ {canEdit && (
102
+ <HStack spacing={1} mt={-1}>
103
+ <IconButton
104
+ aria-label="Edit connector"
105
+ icon={<EditIcon size={14} />}
106
+ size="xs"
107
+ variant="ghost"
108
+ color="blue.300"
109
+ _hover={{ bg: 'blue.900', color: 'blue.100' }}
110
+ onClick={(e) => {
111
+ e.stopPropagation()
112
+ onEdit?.(leaf.connector)
113
+ }}
114
+ />
115
+ <IconButton
116
+ aria-label="Delete connector"
117
+ icon={<TrashIcon size={14} />}
118
+ size="xs"
119
+ variant="ghost"
120
+ color="red.400"
121
+ _hover={{ bg: 'red.900', color: 'red.100' }}
122
+ onClick={(e) => {
123
+ e.stopPropagation()
124
+ onDelete?.(leaf.connector.id, leaf.ownerViewId)
125
+ }}
126
+ />
127
+ </HStack>
128
+ )}
129
+ </HStack>
89
130
  </Box>
90
131
 
91
132
  {leaf.connector.description && (
@@ -128,3 +169,4 @@ export default function ProxyConnectorPanel({
128
169
  </SlidingPanel>
129
170
  )
130
171
  }
172
+
@@ -1,6 +1,7 @@
1
- import { memo } from 'react'
1
+ import { memo, useCallback } from 'react'
2
2
  import { BaseEdge, EdgeLabelRenderer, Position, useStore, type EdgeProps } from 'reactflow'
3
3
  import { measureEdgeLabel, useEdgeLabelLayout } from './ViewEditorEdgeLabelLayout'
4
+ import type { ProxyConnectorDetails } from '../crossBranch/types'
4
5
 
5
6
  const CURVATURE = 0.5
6
7
 
@@ -35,6 +36,7 @@ function ViewBezierConnector({
35
36
  }: EdgeProps) {
36
37
  const sourceNode = useStore((s) => s.nodeInternals.get(source))
37
38
  const targetNode = useStore((s) => s.nodeInternals.get(target))
39
+ const edge = useStore((s) => s.edges.find((candidate) => candidate.id === id))
38
40
 
39
41
  const finalSourceX = sourceX
40
42
  const finalSourceY = sourceY
@@ -74,6 +76,37 @@ function ViewBezierConnector({
74
76
  const text = (!selected && fullText.length > 30) ? `${fullText.slice(0, 30)}...` : fullText
75
77
  const textWidth = text ? measureEdgeLabel(text, `${fontWeight} ${fontSize}px Inter, system-ui, sans-serif`) : 0
76
78
  const padding = Array.isArray(labelBgPadding) ? labelBgPadding : [2, 4]
79
+ const proxyBadgeCount = typeof (edge?.data as { proxyBadgeCount?: number } | undefined)?.proxyBadgeCount === 'number'
80
+ ? (edge?.data as { proxyBadgeCount: number }).proxyBadgeCount
81
+ : 0
82
+ const proxyBadgeDetails = ((edge?.data as { proxyBadgeDetails?: ProxyConnectorDetails | null } | undefined)?.proxyBadgeDetails) ?? null
83
+ const proxyBadgeText = proxyBadgeCount > 0 ? `+${proxyBadgeCount}` : ''
84
+ const versionChangeType = (edge?.data as { versionChangeType?: string } | undefined)?.versionChangeType
85
+ const versionBadgeText = versionChangeType === 'added'
86
+ ? '+ connector'
87
+ : versionChangeType === 'deleted'
88
+ ? '- connector'
89
+ : versionChangeType
90
+ ? '~ connector'
91
+ : ''
92
+ const badgeFontSize = 11
93
+ const badgeHorizontalPadding = 7
94
+ const badgeSize = 24
95
+ const labelWidth = textWidth + padding[1] * 2
96
+ const versionBadgeWidth = versionBadgeText
97
+ ? measureEdgeLabel(versionBadgeText, `700 ${badgeFontSize}px Inter, system-ui, sans-serif`) + badgeHorizontalPadding * 2
98
+ : 0
99
+ const badgeWidth = proxyBadgeText
100
+ ? Math.max(badgeSize, measureEdgeLabel(proxyBadgeText, `600 ${badgeFontSize}px Inter, system-ui, sans-serif`) + badgeHorizontalPadding * 2)
101
+ : 0
102
+ const labelHeight = text ? fontSize + padding[0] * 2 : 0
103
+ const badgeGap = (text && (proxyBadgeText || versionBadgeText)) || (proxyBadgeText && versionBadgeText) ? 8 : 0
104
+ const stackWidth = Math.max(labelWidth, badgeWidth, versionBadgeWidth)
105
+ const stackHeight = labelHeight +
106
+ (text && (proxyBadgeText || versionBadgeText) ? badgeGap : 0) +
107
+ (versionBadgeText ? badgeSize : 0) +
108
+ (versionBadgeText && proxyBadgeText ? badgeGap : 0) +
109
+ (proxyBadgeText ? badgeSize : 0)
77
110
 
78
111
  // Cubic bezier midpoint at t=0.5
79
112
  const labelX = 0.125 * finalSourceX + 0.375 * cp1x + 0.375 * cp2x + 0.125 * finalTargetX
@@ -82,16 +115,23 @@ function ViewBezierConnector({
82
115
  const labelLayout = useEdgeLabelLayout({
83
116
  id,
84
117
  preferredX: labelX,
85
- preferredY: labelY,
86
- width: textWidth + padding[1] * 2,
87
- height: fontSize + padding[0] * 2,
118
+ preferredY: labelY + (stackHeight > 0 ? (stackHeight - labelHeight) / 2 : 0),
119
+ width: stackWidth,
120
+ height: stackHeight || (fontSize + padding[0] * 2),
88
121
  dx: finalTargetX - finalSourceX,
89
122
  dy: finalTargetY - finalSourceY,
90
123
  })
91
124
 
92
- const labelWidth = textWidth + padding[1] * 2
93
- const labelPath = text ? ` M ${labelLayout.x - labelWidth / 2},${labelLayout.y} L ${labelLayout.x + labelWidth / 2},${labelLayout.y}` : ''
125
+ const labelCenterY = labelLayout.y - ((proxyBadgeText || versionBadgeText) ? (stackHeight - labelHeight) / 2 : 0)
126
+ const labelPath = text ? ` M ${labelLayout.x - labelWidth / 2},${labelCenterY} L ${labelLayout.x + labelWidth / 2},${labelCenterY}` : ''
94
127
  const combinedInteractionPath = `${interactionPath}${labelPath}`
128
+ const handleBadgeClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
129
+ event.preventDefault()
130
+ event.stopPropagation()
131
+ if (!proxyBadgeDetails) return
132
+ const onOpenProxyBadge = (edge?.data as { onOpenProxyBadge?: (details: ProxyConnectorDetails) => void } | undefined)?.onOpenProxyBadge
133
+ onOpenProxyBadge?.(proxyBadgeDetails)
134
+ }, [edge?.data, proxyBadgeDetails])
95
135
 
96
136
  return (
97
137
  <>
@@ -108,7 +148,7 @@ function ViewBezierConnector({
108
148
  interactionWidth={20}
109
149
  style={{ stroke: 'transparent' }}
110
150
  />
111
- {text && (
151
+ {(text || proxyBadgeText || versionBadgeText) && (
112
152
  <EdgeLabelRenderer>
113
153
  <div
114
154
  style={{
@@ -117,22 +157,77 @@ function ViewBezierConnector({
117
157
  pointerEvents: 'none',
118
158
  opacity: Number(labelStyle?.opacity ?? 1),
119
159
  zIndex: 2,
160
+ display: 'flex',
161
+ flexDirection: 'column',
162
+ alignItems: 'center',
163
+ gap: badgeGap,
120
164
  }}
121
165
  >
122
- <div
123
- style={{
124
- padding: `${padding[0]}px ${padding[1]}px`,
125
- borderRadius: Array.isArray(labelBgBorderRadius) ? labelBgBorderRadius[0] : Number(labelBgBorderRadius ?? 4),
126
- background: String(labelBgStyle?.fill ?? 'var(--chakra-colors-gray-900)'),
127
- color: String(labelStyle?.fill ?? 'var(--accent)'),
128
- fontSize,
129
- fontWeight,
130
- lineHeight: 1,
131
- whiteSpace: 'nowrap',
132
- }}
133
- >
134
- {text}
135
- </div>
166
+ {text && (
167
+ <div
168
+ style={{
169
+ padding: `${padding[0]}px ${padding[1]}px`,
170
+ borderRadius: Array.isArray(labelBgBorderRadius) ? labelBgBorderRadius[0] : Number(labelBgBorderRadius ?? 4),
171
+ background: String(labelBgStyle?.fill ?? 'var(--chakra-colors-gray-900)'),
172
+ color: String(labelStyle?.fill ?? 'var(--accent)'),
173
+ fontSize,
174
+ fontWeight,
175
+ lineHeight: 1,
176
+ whiteSpace: 'nowrap',
177
+ }}
178
+ >
179
+ {text}
180
+ </div>
181
+ )}
182
+ {proxyBadgeText && (
183
+ <button
184
+ type="button"
185
+ onClick={handleBadgeClick}
186
+ style={{
187
+ minWidth: badgeWidth,
188
+ height: badgeSize,
189
+ padding: `0 ${badgeHorizontalPadding}px`,
190
+ borderRadius: 999,
191
+ background: 'var(--bg-element)',
192
+ border: '1px dashed rgba(var(--accent-rgb), 0.8)',
193
+ color: 'white',
194
+ display: 'flex',
195
+ alignItems: 'center',
196
+ justifyContent: 'center',
197
+ fontSize: badgeFontSize,
198
+ fontWeight: 600,
199
+ lineHeight: 1,
200
+ boxShadow: selected ? '0 0 0 1px rgba(255,255,255,0.2)' : 'none',
201
+ cursor: proxyBadgeDetails ? 'pointer' : 'default',
202
+ pointerEvents: 'auto',
203
+ appearance: 'none',
204
+ }}
205
+ >
206
+ {proxyBadgeText}
207
+ </button>
208
+ )}
209
+ {versionBadgeText && (
210
+ <div
211
+ style={{
212
+ minWidth: versionBadgeWidth,
213
+ height: badgeSize,
214
+ padding: `0 ${badgeHorizontalPadding}px`,
215
+ borderRadius: 999,
216
+ background: 'rgba(17, 24, 39, 0.9)',
217
+ border: `1px solid ${versionChangeType === 'added' ? '#68d391' : versionChangeType === 'deleted' ? '#fc8181' : '#f6e05e'}`,
218
+ color: versionChangeType === 'added' ? '#68d391' : versionChangeType === 'deleted' ? '#fc8181' : '#f6e05e',
219
+ display: 'flex',
220
+ alignItems: 'center',
221
+ justifyContent: 'center',
222
+ fontSize: badgeFontSize,
223
+ fontWeight: 700,
224
+ lineHeight: 1,
225
+ boxShadow: '0 6px 18px rgba(0,0,0,0.28)',
226
+ }}
227
+ >
228
+ {versionBadgeText}
229
+ </div>
230
+ )}
136
231
  </div>
137
232
  </EdgeLabelRenderer>
138
233
  )}
@@ -43,7 +43,7 @@ interface Props {
43
43
  tagColors: Record<string, Tag>
44
44
  selectedElement?: LibraryElement | null
45
45
  onUpdateTags?: (elementId: number, tags: string[]) => Promise<void>
46
- onCreateTag: (tag: string, color?: string) => Promise<void>
46
+ onCreateTag: (tag: string, color?: string, description?: string) => Promise<void>
47
47
  layers: ViewLayer[]
48
48
  onHoverLayer: (tags: string[] | null, color?: string | null) => void
49
49
  onCreateLayer: (name: string, tags: string[], color: string) => Promise<void>
@@ -20,6 +20,11 @@ interface ViewFloatingMenuProps {
20
20
  onImport: () => void
21
21
  onExport: () => void
22
22
  onShare: () => void
23
+ canUndo?: boolean
24
+ canRedo?: boolean
25
+ undoRedoDisabled?: boolean
26
+ onUndo?: () => void
27
+ onRedo?: () => void
23
28
  isFreePlan: boolean
24
29
  canUpgrade?: boolean
25
30
  activeTags?: string[]
@@ -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, useDisclosure
5
+ HStack, Tooltip, Button, Box, Text, Popover, PopoverTrigger, Portal, PopoverContent, PopoverBody, IconButton, Slider, SliderTrack, SliderFilledTrack, SliderThumb, useDisclosure
6
6
  } from '@chakra-ui/react'
7
7
  import { DownloadIcon } from '@chakra-ui/icons'
8
8
  import {
@@ -17,6 +17,7 @@ import {
17
17
  TagsIcon,
18
18
  } from './Icons'
19
19
  import { KbdHint } from './PanelUI'
20
+ import { RedoSvg, UndoSvg } from './ViewDrawMenu'
20
21
  import { useViewEditorContext } from '../pages/ViewEditor/context'
21
22
  import type { Tag, ViewLayer } from '../types'
22
23
 
@@ -35,6 +36,13 @@ export interface ViewFloatingMenuProps extends ViewFloatingMenuSlots {
35
36
  onShare?: () => void
36
37
  focusMode: boolean
37
38
  onFocusModeChange: (enabled: boolean) => void
39
+ densityLevel?: number
40
+ onDensityLevelChange?: (level: number) => void
41
+ canUndo?: boolean
42
+ canRedo?: boolean
43
+ undoRedoDisabled?: boolean
44
+ onUndo?: () => void
45
+ onRedo?: () => void
38
46
 
39
47
  // Tag-related props
40
48
  allTags: string[]
@@ -73,6 +81,13 @@ function ViewFloatingMenu({
73
81
  onExport,
74
82
  focusMode,
75
83
  onFocusModeChange,
84
+ densityLevel = 0,
85
+ onDensityLevelChange,
86
+ canUndo = false,
87
+ canRedo = false,
88
+ undoRedoDisabled = false,
89
+ onUndo,
90
+ onRedo,
76
91
  allTags,
77
92
  layers,
78
93
  tagColors,
@@ -90,6 +105,11 @@ function ViewFloatingMenu({
90
105
  }: ViewFloatingMenuProps) {
91
106
  const { canEdit } = useViewEditorContext()
92
107
  const { isOpen: isTagsOpen, onClose: onTagsClose, onToggle: onTagsToggle } = useDisclosure()
108
+ const [draftDensityLevel, setDraftDensityLevel] = React.useState(densityLevel)
109
+
110
+ React.useEffect(() => {
111
+ setDraftDensityLevel(densityLevel)
112
+ }, [densityLevel])
93
113
 
94
114
  return (
95
115
  <HStack
@@ -131,6 +151,46 @@ function ViewFloatingMenu({
131
151
  </Button>
132
152
  </Tooltip>
133
153
 
154
+ {(canUndo || canRedo) && (
155
+ <>
156
+ <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
157
+ {canUndo && (
158
+ <Tooltip label="Undo" placement="top" openDelay={200}>
159
+ <IconButton
160
+ aria-label="Undo"
161
+ icon={<UndoSvg />}
162
+ variant="ghost"
163
+ h="28px"
164
+ minW="28px"
165
+ px={0}
166
+ color="gray.300"
167
+ isDisabled={undoRedoDisabled}
168
+ _disabled={{ opacity: 0.35, cursor: 'not-allowed' }}
169
+ _hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
170
+ onClick={onUndo}
171
+ />
172
+ </Tooltip>
173
+ )}
174
+ {canRedo && (
175
+ <Tooltip label="Redo" placement="top" openDelay={200}>
176
+ <IconButton
177
+ aria-label="Redo"
178
+ icon={<RedoSvg />}
179
+ variant="ghost"
180
+ h="28px"
181
+ minW="28px"
182
+ px={0}
183
+ color="gray.300"
184
+ isDisabled={undoRedoDisabled}
185
+ _disabled={{ opacity: 0.35, cursor: 'not-allowed' }}
186
+ _hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
187
+ onClick={onRedo}
188
+ />
189
+ </Tooltip>
190
+ )}
191
+ </>
192
+ )}
193
+
134
194
  {!hideFocusView && (
135
195
  <>
136
196
  <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
@@ -265,6 +325,55 @@ function ViewFloatingMenu({
265
325
  </>
266
326
  )}
267
327
 
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
+
268
377
  {/* Draw mode toggle */}
269
378
  <Tooltip
270
379
  label={drawingMode ? 'Exit drawing mode' : 'Draw on diagram'}
@@ -43,6 +43,9 @@ export interface ViewGridNodeData {
43
43
  name: string
44
44
  level_label: string | null
45
45
  counts?: { nodes: number; edges: number }
46
+ kind?: 'view' | 'cluster'
47
+ collapsedCount?: number
48
+ dimmed?: boolean
46
49
  focused: boolean
47
50
  canEdit: boolean
48
51
  isEditing: boolean
@@ -72,6 +75,7 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
72
75
 
73
76
  const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure()
74
77
  const [isTooltipOpen, setIsTooltipOpen] = useState(false)
78
+ const isCluster = data.kind === 'cluster'
75
79
 
76
80
  useEffect(() => {
77
81
  if (!isMenuOpen && !isTooltipOpen) return
@@ -114,6 +118,7 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
114
118
  }, [])
115
119
 
116
120
  useEffect(() => {
121
+ if (isCluster) return
117
122
  if (!hasRequested) return
118
123
 
119
124
  let active = true
@@ -141,13 +146,15 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
141
146
  URL.revokeObjectURL(url)
142
147
  }
143
148
  }
144
- }, [hasRequested, data.id])
149
+ }, [hasRequested, data.id, isCluster])
145
150
 
146
151
  const borderColor = data.focused ? accent : 'rgba(255,255,255,0.14)'
147
152
 
148
153
  const boxShadow = data.focused
149
154
  ? `0 0 24px ${hexToRgba(accent, 0.4)}`
150
- : '0 8px 24px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.05)'
155
+ : isCluster
156
+ ? '0 14px 34px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05)'
157
+ : '0 8px 24px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.05)'
151
158
 
152
159
  return (
153
160
  // Outer container: sizing + group context, overflow visible for the "New Child" hover button
@@ -160,7 +167,26 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
160
167
  h="150px"
161
168
  position="relative"
162
169
  userSelect="none"
170
+ opacity={data.dimmed ? 0.5 : 1}
163
171
  transition="opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1)"
172
+ _before={isCluster ? {
173
+ content: '""',
174
+ position: 'absolute',
175
+ inset: '8px -9px -8px 9px',
176
+ borderRadius: '12px',
177
+ border: '1px solid rgba(255,255,255,0.08)',
178
+ bg: 'rgba(var(--bg-element-rgb), 0.55)',
179
+ boxShadow: '0 8px 20px rgba(0,0,0,0.28)',
180
+ } : undefined}
181
+ _after={isCluster ? {
182
+ content: '""',
183
+ position: 'absolute',
184
+ inset: '16px -18px -16px 18px',
185
+ borderRadius: '12px',
186
+ border: '1px solid rgba(255,255,255,0.06)',
187
+ bg: 'rgba(var(--bg-element-rgb), 0.35)',
188
+ boxShadow: '0 8px 20px rgba(0,0,0,0.2)',
189
+ } : undefined}
164
190
  >
165
191
  <Handle
166
192
  type="target"
@@ -212,9 +238,32 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
212
238
  overflow="hidden"
213
239
  borderRadius="8px 8px 0 0"
214
240
  flexShrink={0}
215
- bg="var(--bg-card-solid)"
241
+ bg={isCluster ? 'rgba(var(--bg-element-rgb), 0.88)' : 'var(--bg-card-solid)'}
216
242
  >
217
- {thumbnailUrl ? (
243
+ {isCluster ? (
244
+ <Flex
245
+ position="absolute"
246
+ inset={0}
247
+ p={3}
248
+ gap={1.5}
249
+ align="flex-start"
250
+ justify="flex-start"
251
+ wrap="wrap"
252
+ bg="radial-gradient(circle at 80% 18%, rgba(var(--accent-rgb), 0.16), transparent 42px), linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01))"
253
+ >
254
+ {Array.from({ length: Math.min(18, Math.max(6, data.collapsedCount ?? 6)) }).map((_, i) => (
255
+ <Box
256
+ key={i}
257
+ w={`${18 + (i % 4) * 6}px`}
258
+ h="14px"
259
+ borderRadius="5px"
260
+ bg={i % 5 === 0 ? hexToRgba(accent, 0.2) : 'rgba(255,255,255,0.06)'}
261
+ border="1px solid"
262
+ borderColor={i % 5 === 0 ? hexToRgba(accent, 0.34) : 'rgba(255,255,255,0.07)'}
263
+ />
264
+ ))}
265
+ </Flex>
266
+ ) : thumbnailUrl ? (
218
267
  <Box
219
268
  as="img"
220
269
  src={thumbnailUrl}
@@ -294,7 +343,7 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
294
343
  </Text>
295
344
  )}
296
345
 
297
- {!data.isEditing && (
346
+ {!data.isEditing && !isCluster && (
298
347
  <Flex align="center" gap={1} onClick={(e) => e.stopPropagation()} mt="-2px">
299
348
  <Menu
300
349
  isLazy
@@ -404,9 +453,11 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
404
453
  flexShrink={0}
405
454
  textShadow="0 1px 2px rgba(0,0,0,0.5)"
406
455
  >
407
- {data.counts
408
- ? `${data.counts.nodes}n · ${data.counts.edges}e`
409
- : '-'}
456
+ {isCluster && data.collapsedCount
457
+ ? `${data.collapsedCount} views`
458
+ : data.counts
459
+ ? `${data.counts.nodes}n · ${data.counts.edges}e`
460
+ : '-'}
410
461
  </Text>
411
462
  </Flex>
412
463
  </Flex>
@@ -28,6 +28,7 @@ interface Props {
28
28
  view: ViewTreeNode | null
29
29
  canEdit?: boolean
30
30
  onSave: (updated: ViewTreeNode) => void
31
+ onUnsupportedMutation?: () => void
31
32
  hasBackdrop?: boolean
32
33
  }
33
34
 
@@ -37,7 +38,7 @@ interface Props {
37
38
  * Location: Right side of the screen on desktop. Overlays screen on mobile.
38
39
  * Aliases: View Properties, View Settings.
39
40
  */
40
- function ViewPanel({ isOpen, onClose, view, canEdit: canEditProp, onSave, hasBackdrop = true }: Props) {
41
+ function ViewPanel({ isOpen, onClose, view, canEdit: canEditProp, onSave, onUnsupportedMutation, hasBackdrop = true }: Props) {
41
42
  const ctx = useContext(ViewEditorContext)
42
43
  const canEdit = canEditProp ?? ctx?.canEdit ?? true
43
44
  const isReadOnly = !canEdit
@@ -118,7 +119,7 @@ function ViewPanel({ isOpen, onClose, view, canEdit: canEditProp, onSave, hasBac
118
119
  rows={4}
119
120
  />
120
121
  </FormControl>
121
- <LayoutSection view={view} canEdit={canEdit} />
122
+ <LayoutSection view={view} canEdit={canEdit} onUnsupportedMutation={onUnsupportedMutation} />
122
123
 
123
124
  {view && (
124
125
  <Box pt={2} borderTop="1px solid" borderColor="whiteAlpha.50">