@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
@@ -54,57 +54,59 @@ function flattenTree(roots: ViewTreeNode[]): ViewTreeNode[] {
54
54
  return result
55
55
  }
56
56
 
57
+ function filterTreeForGrid(nodes: ViewTreeNode[], allowedIds: Set<number> | null): ViewTreeNode[] {
58
+ if (!allowedIds) return nodes
59
+
60
+ const visit = (node: ViewTreeNode): ViewTreeNode | null => {
61
+ const children = node.children
62
+ .map(visit)
63
+ .filter((child): child is ViewTreeNode => child !== null)
64
+ const include = allowedIds.has(node.id) || (node.parent_view_id === null && children.length > 0)
65
+ if (!include) return null
66
+ return { ...node, children }
67
+ }
68
+
69
+ return nodes
70
+ .map(visit)
71
+ .filter((node): node is ViewTreeNode => node !== null)
72
+ }
73
+
57
74
  // ── Layout algorithm ──────────────────────────────────────────────────────────
58
75
 
59
76
  const CELL_W = 260
60
77
  const CELL_H = 150
61
78
  const GAP_H = 80
62
79
  const GAP_V = 120
63
-
64
- function subtreeWidth(node: ViewTreeNode): number {
65
- if (node.children.length === 0) return 1
66
- return node.children.reduce((sum, c) => sum + subtreeWidth(c), 0)
80
+ const COMPACT_WORKSPACE_THRESHOLD = 32
81
+ const LAYOUT_TRANSITION = 'transform 560ms cubic-bezier(0.16, 1, 0.3, 1), opacity 260ms ease, filter 260ms ease'
82
+
83
+ interface GridDisplayNode {
84
+ id: string
85
+ kind: 'view' | 'cluster'
86
+ view: ViewTreeNode
87
+ parentId: string | null
88
+ depth: number
89
+ children: GridDisplayNode[]
90
+ collapsedCount?: number
91
+ dimmed?: boolean
67
92
  }
68
93
 
69
- function buildDescendantSets(roots: ViewTreeNode[]): Map<number, Set<number>> {
70
- const map = new Map<number, Set<number>>()
71
-
72
- function visit(node: ViewTreeNode): Set<number> {
73
- const set = new Set([node.id])
74
- node.children.forEach((child) => {
75
- const childSet = visit(child)
76
- childSet.forEach((id) => set.add(id))
77
- })
78
- map.set(node.id, set)
79
- return set
80
- }
81
-
82
- roots.forEach(visit)
83
- return map
94
+ function displaySubtreeWidth(node: GridDisplayNode): number {
95
+ if (node.children.length === 0) return 1
96
+ return node.children.reduce((sum, child) => sum + displaySubtreeWidth(child), 0)
84
97
  }
85
98
 
86
99
  /**
87
- * Compute layout positions.
88
- *
89
- * Y-axis: node.depth (= node.level) - honours manual level overrides so a
90
- * diagram at L2 is always rendered in the L2 row even if its parent
91
- * is at L0.
92
- *
93
- * X-axis: column derived from the tree-walk (pre-order rank within each
94
- * level band), then a de-overlap pass shifts any colliding nodes and
95
- * their subtrees rightward so nothing overlaps on the same row.
100
+ * Compute layout positions for real cards plus collapsed cluster cards.
101
+ * Y follows view depth so manual level overrides still read as horizontal bands.
96
102
  */
97
- function computeLayout(roots: ViewTreeNode[]): Map<number, { x: number; y: number }> {
98
- const positions = new Map<number, { x: number; y: number }>()
99
- const flat: ViewTreeNode[] = []
100
- const visit = (n: ViewTreeNode) => { flat.push(n); n.children.forEach(visit) }
101
- roots.forEach(visit)
102
-
103
- if (flat.length === 0) return positions
103
+ function computeDisplayLayout(roots: GridDisplayNode[]): Map<string, { x: number; y: number }> {
104
+ const positions = new Map<string, { x: number; y: number }>()
105
+ if (roots.length === 0) return positions
106
+ const flat = flattenDisplayTree(roots)
104
107
 
105
- // ── Step 1: initial column assignment via tree walk ─────────────────────────
106
- function layoutNode(node: ViewTreeNode, startCol: number) {
107
- const w = subtreeWidth(node)
108
+ function layoutNode(node: GridDisplayNode, startCol: number) {
109
+ const w = displaySubtreeWidth(node)
108
110
  const centerCol = startCol + (w - 1) / 2
109
111
  positions.set(node.id, {
110
112
  x: centerCol * (CELL_W + GAP_H),
@@ -113,41 +115,44 @@ function computeLayout(roots: ViewTreeNode[]): Map<number, { x: number; y: numbe
113
115
  let childStart = startCol
114
116
  for (const child of node.children) {
115
117
  layoutNode(child, childStart)
116
- childStart += subtreeWidth(child)
118
+ childStart += displaySubtreeWidth(child)
117
119
  }
118
120
  }
121
+
119
122
  let col = 0
120
123
  for (const root of roots) {
121
124
  layoutNode(root, col)
122
- col += subtreeWidth(root)
125
+ col += displaySubtreeWidth(root)
126
+ }
127
+
128
+ const descendants = new Map<string, Set<string>>()
129
+ const collectDescendants = (node: GridDisplayNode): Set<string> => {
130
+ const set = new Set([node.id])
131
+ node.children.forEach((child) => {
132
+ collectDescendants(child).forEach((id) => set.add(id))
133
+ })
134
+ descendants.set(node.id, set)
135
+ return set
123
136
  }
124
- // ── Step 2: build descendant sets so we can shift whole subtrees ────────────
125
- const descendants = buildDescendantSets(roots)
126
-
127
- // ── Step 3: de-overlap pass - per Y row (top-down), fix X collisions ────────
128
- const STEP = CELL_W + GAP_H
129
- const byY = new Map<number, number[]>()
130
- flat.forEach((n) => {
131
- const y = n.depth * (CELL_H + GAP_V)
137
+ roots.forEach(collectDescendants)
138
+
139
+ const byY = new Map<number, string[]>()
140
+ flat.forEach((node) => {
141
+ const y = node.depth * (CELL_H + GAP_V)
132
142
  if (!byY.has(y)) byY.set(y, [])
133
- byY.get(y)!.push(n.id)
143
+ byY.get(y)!.push(node.id)
134
144
  })
135
145
 
136
- // Process rows top-down (ascending Y) so parent shifts propagate downward first
137
146
  const sortedYRows = Array.from(byY.entries()).sort(([ya], [yb]) => ya - yb)
138
-
147
+ const step = CELL_W + GAP_H
139
148
  for (const [rowY, ids] of sortedYRows) {
140
- // Snapshot original X values before any mutations in this row -
141
- // this prevents a just-shifted node's new position from cascading
142
- // into the next comparison and wrongly pushing correct neighbors right.
143
- const origX = new Map<number, number>(ids.map((id) => [id, positions.get(id)?.x ?? 0]))
149
+ const origX = new Map<string, number>(ids.map((id) => [id, positions.get(id)?.x ?? 0]))
144
150
  ids.sort((a, b) => (origX.get(a) ?? 0) - (origX.get(b) ?? 0))
145
-
146
151
  let rightmostX = origX.get(ids[0]) ?? 0
147
152
 
148
153
  for (let i = 1; i < ids.length; i++) {
149
154
  const originalX = origX.get(ids[i]) ?? 0
150
- const placedX = Math.max(originalX, rightmostX + STEP)
155
+ const placedX = Math.max(originalX, rightmostX + step)
151
156
 
152
157
  if (placedX > originalX) {
153
158
  const delta = placedX - originalX
@@ -167,6 +172,150 @@ function computeLayout(roots: ViewTreeNode[]): Map<number, { x: number; y: numbe
167
172
  return positions
168
173
  }
169
174
 
175
+ function countDescendantViews(node: ViewTreeNode): number {
176
+ return node.children.reduce((sum, child) => sum + 1 + countDescendantViews(child), 0)
177
+ }
178
+
179
+ function flattenDisplayTree(roots: GridDisplayNode[]): GridDisplayNode[] {
180
+ const result: GridDisplayNode[] = []
181
+ const visit = (node: GridDisplayNode) => {
182
+ result.push(node)
183
+ node.children.forEach(visit)
184
+ }
185
+ roots.forEach(visit)
186
+ return result
187
+ }
188
+
189
+ function sumContentCounts(
190
+ nodes: ViewTreeNode[],
191
+ countsByView: Record<number, { nodes: number; edges: number }>
192
+ ): { nodes: number; edges: number } {
193
+ let nodesCount = 0
194
+ let edgesCount = 0
195
+
196
+ const visit = (node: ViewTreeNode) => {
197
+ const counts = countsByView[node.id]
198
+ nodesCount += counts?.nodes ?? 0
199
+ edgesCount += counts?.edges ?? 0
200
+ node.children.forEach(visit)
201
+ }
202
+
203
+ nodes.forEach(visit)
204
+ return { nodes: nodesCount, edges: edgesCount }
205
+ }
206
+
207
+ function buildDisplayTree(roots: ViewTreeNode[], focusedId: number | null): GridDisplayNode[] {
208
+ const flat = flattenTree(roots)
209
+ if (flat.length <= COMPACT_WORKSPACE_THRESHOLD) {
210
+ const convert = (node: ViewTreeNode, parentId: string | null): GridDisplayNode => ({
211
+ id: String(node.id),
212
+ kind: 'view',
213
+ view: node,
214
+ parentId,
215
+ depth: node.depth,
216
+ children: node.children.map((child) => convert(child, String(node.id))),
217
+ })
218
+ return roots.map((root) => convert(root, null))
219
+ }
220
+
221
+ const byId = new Map(flat.map((node) => [node.id, node]))
222
+ const focusedNode = focusedId ? byId.get(focusedId) ?? null : null
223
+ const visible = new Set<number>()
224
+ const emphasis = new Set<number>()
225
+
226
+ if (!focusedNode) {
227
+ flat.forEach((node) => {
228
+ if (node.parent_view_id === null || node.depth <= 1) visible.add(node.id)
229
+ })
230
+ } else {
231
+ let cursor: ViewTreeNode | undefined = focusedNode
232
+ while (cursor) {
233
+ visible.add(cursor.id)
234
+ emphasis.add(cursor.id)
235
+ cursor = cursor.parent_view_id ? byId.get(cursor.parent_view_id) : undefined
236
+ }
237
+
238
+ roots.forEach((root) => visible.add(root.id))
239
+
240
+ const parent = focusedNode.parent_view_id ? byId.get(focusedNode.parent_view_id) : null
241
+ const siblings = flat.filter((node) => node.parent_view_id === focusedNode.parent_view_id)
242
+ siblings.forEach((node) => {
243
+ visible.add(node.id)
244
+ emphasis.add(node.id)
245
+ })
246
+
247
+ focusedNode.children.forEach((child) => {
248
+ visible.add(child.id)
249
+ emphasis.add(child.id)
250
+ })
251
+
252
+ parent?.children.forEach((child) => visible.add(child.id))
253
+ }
254
+
255
+ const makeNode = (node: ViewTreeNode, parentId: string | null): GridDisplayNode | null => {
256
+ if (!visible.has(node.id)) return null
257
+
258
+ const displayId = String(node.id)
259
+ const visibleChildren = node.children
260
+ .map((child) => makeNode(child, displayId))
261
+ .filter((child): child is GridDisplayNode => child !== null)
262
+
263
+ const hiddenChildren = node.children.filter((child) => !visible.has(child.id))
264
+ const hiddenCount = hiddenChildren.reduce((sum, child) => sum + 1 + countDescendantViews(child), 0)
265
+ const cluster: GridDisplayNode[] = hiddenCount > 0 ? [{
266
+ id: `${node.id}:cluster`,
267
+ kind: 'cluster',
268
+ view: node,
269
+ parentId: displayId,
270
+ depth: node.depth + 1,
271
+ children: [],
272
+ collapsedCount: hiddenCount,
273
+ dimmed: focusedNode ? !emphasis.has(node.id) : false,
274
+ }] : []
275
+
276
+ return {
277
+ id: displayId,
278
+ kind: 'view',
279
+ view: node,
280
+ parentId,
281
+ depth: node.depth,
282
+ children: [...visibleChildren, ...cluster],
283
+ dimmed: focusedNode ? !emphasis.has(node.id) : false,
284
+ }
285
+ }
286
+
287
+ return roots
288
+ .map((root) => makeNode(root, null))
289
+ .filter((node): node is GridDisplayNode => node !== null)
290
+ }
291
+
292
+ function buildStableLayoutIds(flat: ViewTreeNode[], focusedId: number | null): Set<string> {
293
+ const stable = new Set<string>()
294
+ const byId = new Map(flat.map((node) => [node.id, node]))
295
+
296
+ if (focusedId === null) {
297
+ flat.forEach((node) => {
298
+ if (node.parent_view_id === null || node.depth <= 1) stable.add(String(node.id))
299
+ })
300
+ return stable
301
+ }
302
+
303
+ const focused = byId.get(focusedId)
304
+ if (!focused) return stable
305
+
306
+ let cursor: ViewTreeNode | undefined = focused
307
+ while (cursor) {
308
+ stable.add(String(cursor.id))
309
+ cursor = cursor.parent_view_id ? byId.get(cursor.parent_view_id) : undefined
310
+ }
311
+
312
+ flat.forEach((node) => {
313
+ if (node.parent_view_id === focused.parent_view_id) stable.add(String(node.id))
314
+ })
315
+
316
+ return stable
317
+ }
318
+
170
319
 
171
320
 
172
321
 
@@ -387,7 +536,8 @@ function ViewGridInner({ onShare, treeData, loading, focusedId, onFocusChange, s
387
536
  }, [zoomIn, zoomOut])
388
537
 
389
538
  // ── Derived tree structures ─────────────────────────────────────────────────
390
- const roots = useMemo(() => treeData, [treeData])
539
+ const [gridViewIds, setGridViewIds] = useState<Set<number> | null>(null)
540
+ const roots = useMemo(() => filterTreeForGrid(treeData, gridViewIds), [treeData, gridViewIds])
391
541
  const flatTree = useMemo(() => flattenTree(roots), [roots])
392
542
 
393
543
  // Rename
@@ -422,28 +572,41 @@ function ViewGridInner({ onShare, treeData, loading, focusedId, onFocusChange, s
422
572
  }
423
573
  }, [loading, treeData.length])
424
574
 
425
- // Fetch node/edge counts
575
+ // Fetch visible grid cards and node/edge counts in one workspace roundtrip.
426
576
  useEffect(() => {
427
577
  let cancelled = false
428
- const ids = flatTree.map((n) => n.id)
429
- if (ids.length === 0) { setCountsByDiagram({}); return }
578
+ if (treeData.length === 0) {
579
+ setGridViewIds(new Set())
580
+ setCountsByDiagram({})
581
+ return
582
+ }
430
583
  ; (async () => {
431
- const next: Record<number, { nodes: number; edges: number }> = {}
432
- await Promise.all(
433
- ids.map(async (id) => {
434
- try {
435
- const [objs, edges] = await Promise.all([
436
- api.workspace.views.placements.list(id),
437
- api.workspace.connectors.list(id),
438
- ])
439
- next[id] = { nodes: objs.length, edges: edges.length }
440
- } catch { /* ignore per-diagram failure */ }
584
+ try {
585
+ const workspace = await api.workspace.views.gridData()
586
+ if (cancelled) return
587
+
588
+ const visibleIds = new Set(flattenTree(workspace.views).map((view) => view.id))
589
+ const next: Record<number, { nodes: number; edges: number }> = {}
590
+
591
+ visibleIds.forEach((id) => {
592
+ const content = workspace.content[id]
593
+ next[id] = {
594
+ nodes: content?.placements.length ?? 0,
595
+ edges: content?.connectors.length ?? 0,
596
+ }
441
597
  })
442
- )
443
- if (!cancelled) setCountsByDiagram((prev) => ({ ...prev, ...next }))
598
+
599
+ setGridViewIds(visibleIds)
600
+ setCountsByDiagram(next)
601
+ } catch {
602
+ if (!cancelled) {
603
+ setGridViewIds(null)
604
+ setCountsByDiagram({})
605
+ }
606
+ }
444
607
  })()
445
608
  return () => { cancelled = true }
446
- }, [flatTree])
609
+ }, [treeData])
447
610
 
448
611
  // ── Rename ──────────────────────────────────────────────────────────────────
449
612
  const startEdit = useCallback((id: number, name: string) => {
@@ -545,7 +708,49 @@ function ViewGridInner({ onShare, treeData, loading, focusedId, onFocusChange, s
545
708
  }
546
709
 
547
710
  // ── RF nodes - pure derivation, no useState/useEffect ───────────────────────
548
- const layoutPositions = useMemo(() => computeLayout(roots), [roots])
711
+ const displayTree = useMemo(
712
+ () => buildDisplayTree(roots, focusedId),
713
+ [roots, focusedId]
714
+ )
715
+ const displayFlat = useMemo(() => flattenDisplayTree(displayTree), [displayTree])
716
+ const rawLayoutPositions = useMemo(() => computeDisplayLayout(displayTree), [displayTree])
717
+ const stableLayoutIds = useMemo(() => buildStableLayoutIds(flatTree, focusedId), [flatTree, focusedId])
718
+ const previousLayoutRef = useRef<Map<string, { x: number; y: number }>>(new Map())
719
+
720
+ const layoutPositions = useMemo(() => {
721
+ const next = new Map(rawLayoutPositions)
722
+ const previousLayout = previousLayoutRef.current
723
+
724
+ if (focusedId !== null) {
725
+ const focusedKey = String(focusedId)
726
+ const previousFocusedPosition = previousLayout.get(focusedKey)
727
+ const nextFocusedPosition = next.get(focusedKey)
728
+
729
+ if (previousFocusedPosition && nextFocusedPosition) {
730
+ const dx = previousFocusedPosition.x - nextFocusedPosition.x
731
+ const dy = previousFocusedPosition.y - nextFocusedPosition.y
732
+
733
+ if (dx !== 0 || dy !== 0) {
734
+ next.forEach((position, id) => {
735
+ next.set(id, { x: position.x + dx, y: position.y + dy })
736
+ })
737
+ }
738
+ }
739
+ }
740
+
741
+ stableLayoutIds.forEach((id) => {
742
+ const previousPosition = previousLayout.get(id)
743
+ if (previousPosition && next.has(id)) {
744
+ next.set(id, previousPosition)
745
+ }
746
+ })
747
+
748
+ return next
749
+ }, [rawLayoutPositions, focusedId, stableLayoutIds])
750
+
751
+ useEffect(() => {
752
+ previousLayoutRef.current = layoutPositions
753
+ }, [layoutPositions])
549
754
 
550
755
  // Stable during drag (layoutPositions only changes after treeData refresh, never on mouse moves)
551
756
  const computedMinZoom = useMemo(() => {
@@ -599,35 +804,50 @@ function ViewGridInner({ onShare, treeData, loading, focusedId, onFocusChange, s
599
804
  }, [focusedId, flatTree])
600
805
 
601
806
  const rfNodes = useMemo((): RFNode[] =>
602
- flatTree.map((n): RFNode => ({
603
- id: String(n.id),
604
- type: 'diagramGrid',
605
- position: layoutPositions.get(n.id) ?? { x: 0, y: 0 },
606
- data: {
607
- id: n.id,
608
- name: n.name,
609
- level_label: n.level_label,
610
- counts: countsByView[n.id],
611
- focused: focusedId === n.id,
612
- canEdit,
613
- isEditing: editingId === n.id,
614
- editName,
615
- onFocus: () => onFocusChange(n.id),
616
- onOpen: () => navigate(`/views/${n.id}`),
617
- onStartRename: () => startEdit(n.id, n.name),
618
- onDetails: () => handleDetailsOpen(n.id),
619
- onDelete: () => { setDeleteTargetId(n.id); onDeleteOpen() },
620
- onShare: onShare ? () => onShare(n.id) : () => {},
621
- onEditNameChange: setEditName,
622
- onEditCommit: commitEdit,
623
- onEditCancel: cancelEdit,
624
- isMobile: isMobileLayout,
625
- wasdKey: wasdTargets[n.id],
626
- } satisfies ViewGridNodeData,
627
- draggable: false,
628
- })),
807
+ displayFlat.map((displayNode): RFNode => {
808
+ const n = displayNode.view
809
+ const isCluster = displayNode.kind === 'cluster'
810
+ const hiddenChildren = isCluster
811
+ ? n.children.filter((child) => !displayFlat.some((visibleNode) => visibleNode.kind === 'view' && visibleNode.view.id === child.id))
812
+ : []
813
+
814
+ return {
815
+ id: displayNode.id,
816
+ type: 'diagramGrid',
817
+ position: layoutPositions.get(displayNode.id) ?? { x: 0, y: 0 },
818
+ data: {
819
+ id: n.id,
820
+ name: isCluster ? `${n.name} descendants` : n.name,
821
+ level_label: isCluster ? 'Collapsed stack' : n.level_label,
822
+ counts: isCluster ? sumContentCounts(hiddenChildren, countsByView) : countsByView[n.id],
823
+ kind: displayNode.kind,
824
+ collapsedCount: displayNode.collapsedCount,
825
+ dimmed: displayNode.dimmed,
826
+ focused: !isCluster && focusedId === n.id,
827
+ canEdit: !isCluster && canEdit,
828
+ isEditing: !isCluster && editingId === n.id,
829
+ editName,
830
+ onFocus: () => onFocusChange(n.id),
831
+ onOpen: () => isCluster ? onFocusChange(n.id) : navigate(`/views/${n.id}`),
832
+ onStartRename: () => startEdit(n.id, n.name),
833
+ onDetails: () => handleDetailsOpen(n.id),
834
+ onDelete: () => { setDeleteTargetId(n.id); onDeleteOpen() },
835
+ onShare: onShare ? () => onShare(n.id) : () => {},
836
+ onEditNameChange: setEditName,
837
+ onEditCommit: commitEdit,
838
+ onEditCancel: cancelEdit,
839
+ isMobile: isMobileLayout,
840
+ wasdKey: isCluster ? undefined : wasdTargets[n.id],
841
+ } satisfies ViewGridNodeData,
842
+ draggable: false,
843
+ style: {
844
+ transition: LAYOUT_TRANSITION,
845
+ zIndex: displayNode.kind === 'cluster' ? 1 : focusedId === n.id ? 3 : 2,
846
+ },
847
+ }
848
+ }),
629
849
  // eslint-disable-next-line react-hooks/exhaustive-deps
630
- [flatTree, layoutPositions, focusedId, countsByView,
850
+ [displayFlat, layoutPositions, focusedId, countsByView,
631
851
  editingId, editName, canEdit, navigate, startEdit, handleDetailsOpen,
632
852
  commitEdit, cancelEdit, onDeleteOpen,
633
853
  wasdTargets, levelEditingNodeId]
@@ -674,17 +894,17 @@ function ViewGridInner({ onShare, treeData, loading, focusedId, onFocusChange, s
674
894
 
675
895
  // ── RF edges ────────────────────────────────────────────────────────────────
676
896
  const rfEdges = useMemo((): RFEdge[] =>
677
- flatTree
678
- .filter((n) => n.parent_view_id)
897
+ displayFlat
898
+ .filter((n) => n.parentId)
679
899
  .map((n) => ({
680
- id: `${n.parent_view_id}-${n.id}`,
681
- source: String(n.parent_view_id!),
682
- target: String(n.id),
900
+ id: `${n.parentId}-${n.id}`,
901
+ source: n.parentId!,
902
+ target: n.id,
683
903
  type: 'floating',
684
904
  animated: false,
685
- data: { color: HIERARCHY_EDGE_COLOR, dashed: false },
905
+ data: { color: n.kind === 'cluster' ? hexToRgba(accent, 0.28) : HIERARCHY_EDGE_COLOR, dashed: n.kind === 'cluster' },
686
906
  })),
687
- [flatTree]
907
+ [displayFlat, accent]
688
908
  )
689
909
 
690
910
  const allRfEdges = rfEdges
@@ -743,7 +963,7 @@ function ViewGridInner({ onShare, treeData, loading, focusedId, onFocusChange, s
743
963
  // ── Camera: pan to focused node only when it's out of view ──────────────────
744
964
  useEffect(() => {
745
965
  if (!focusedId) return
746
- const pos = layoutPositions.get(focusedId)
966
+ const pos = layoutPositions.get(String(focusedId))
747
967
  if (!pos) return
748
968
  const t = setTimeout(() => {
749
969
  const { x: vpX, y: vpY, zoom } = getViewport()
@@ -846,8 +1066,7 @@ function ViewGridInner({ onShare, treeData, loading, focusedId, onFocusChange, s
846
1066
  onFocusChange(null)
847
1067
  }}
848
1068
  style={{
849
- background: 'var(--bg-canvas)',
850
- boxShadow: 'inset 0 0 100px rgba(0,0,0,0.6)'
1069
+ background: 'var(--bg-canvas)'
851
1070
  }}
852
1071
  >
853
1072
  {/* Micro dots for high precision technical feel */}