@tldiagram/core-ui 1.92.0 → 1.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,6 @@ import ReactFlow, {
11
11
  PanOnScrollMode,
12
12
  ReactFlowProvider,
13
13
  useReactFlow,
14
- applyNodeChanges,
15
14
  } from 'reactflow'
16
15
  import type { Edge as RFEdge, EdgeMarker as RFEdgeMarker, Node as RFNode, NodeChange } from 'reactflow'
17
16
  import 'reactflow/dist/style.css'
@@ -74,8 +73,8 @@ import type { ExtensionToWebviewMessage } from '../../types/vscode-messages'
74
73
  import { ViewEditorContext } from './context'
75
74
  import { useViewData } from './hooks/useViewData'
76
75
  import { useDrawingEngine } from './hooks/useDrawingEngine'
77
- import { useCanvasInteractions } from './hooks/useCanvasInteractions'
78
- import { sanitizeExportFilename, triggerDownload } from './utils'
76
+ import { applyNodeChangesWithStructuralSharing, useCanvasInteractions } from './hooks/useCanvasInteractions'
77
+ import { connectorToConnector, findClosestHandles, sanitizeExportFilename, triggerDownload } from './utils'
79
78
  import { pickUnusedColor } from '../../components/ViewExplorer/utils'
80
79
 
81
80
  import { EmptyCanvasState } from './components/EmptyCanvasState'
@@ -86,6 +85,7 @@ import { useCrossBranchContextSettings } from '../../crossBranch/settings'
86
85
  import { removeConnectorGraphSnapshot, upsertConnectorGraphSnapshot, useWorkspaceGraphSnapshot } from '../../crossBranch/store'
87
86
  import type { ProxyConnectorDetails } from '../../crossBranch/types'
88
87
  import { useDemoRevealViewport, type ViewEditorDemoOptions } from '../../demo/viewEditor'
88
+ import { useStore } from '../../store/useStore'
89
89
 
90
90
  const nodeTypes = {
91
91
  elementNode: ElementNode,
@@ -99,6 +99,7 @@ const VIEW_EDITOR_EMPTY_EXTENT_RATIO = 0.75
99
99
  const VIEW_EDITOR_PAN_MARGIN_RATIO = 0.25
100
100
  const VIEW_EDITOR_PAN_MARGIN_MIN = 180
101
101
  const VIEW_EDITOR_PAN_MARGIN_MAX = 720
102
+ const SNAP_GRID: [number, number] = [30, 30]
102
103
 
103
104
  function alphaColor(color: string, opacity: number): string {
104
105
  if (opacity >= 1) return color
@@ -222,11 +223,33 @@ function ViewEditorInner({
222
223
  const [extrasOpen, setExtrasOpen] = useState(false)
223
224
  const [isImporting, setIsImporting] = useState(false)
224
225
  const [isExporting, setIsExporting] = useState(false)
225
- const [snapToGrid, setSnapToGrid] = useState(() => {
226
- if (typeof window === 'undefined') return false
227
- const stored = localStorage.getItem('diag:snapToGrid')
228
- return stored === 'true'
229
- })
226
+ const setViewEditorUi = useStore((state) => state.setViewEditorUi)
227
+ const snapToGrid = useStore((state) => state.snapToGrid)
228
+ const setStoreSnapToGrid = useStore((state) => state.setSnapToGrid)
229
+ const upsertStoreConnector = useStore((state) => state.upsertConnector)
230
+ const removeStoreConnector = useStore((state) => state.removeConnector)
231
+ const setSnapToGrid = useCallback((snap: boolean) => {
232
+ setStoreSnapToGrid(snap)
233
+ if (typeof window !== 'undefined') localStorage.setItem('diag:snapToGrid', String(snap))
234
+ }, [setStoreSnapToGrid])
235
+
236
+ useEffect(() => {
237
+ if (typeof window === 'undefined') return
238
+ setStoreSnapToGrid(localStorage.getItem('diag:snapToGrid') === 'true')
239
+ }, [setStoreSnapToGrid])
240
+
241
+ useEffect(() => {
242
+ setViewEditorUi({
243
+ viewId,
244
+ canEdit,
245
+ isOwner,
246
+ isFreePlan,
247
+ snapToGrid,
248
+ selectedElement,
249
+ selectedConnector: selectedEdge,
250
+ })
251
+ }, [canEdit, isFreePlan, isOwner, selectedEdge, selectedElement, setViewEditorUi, snapToGrid, viewId])
252
+
230
253
  useEffect(() => { localStorage.setItem('diag:snapToGrid', String(snapToGrid)) }, [snapToGrid])
231
254
  const [, setHoveredZoom] = useState<{ elementId: number | null; type: 'in' | 'out' | null } | null>(null)
232
255
  const hoveredZoomRef = useRef<{ elementId: number | null; type: 'in' | 'out' | null } | null>(null)
@@ -613,7 +636,7 @@ function ViewEditorInner({
613
636
  const targetNode = rfNodesRef.current.find((n) => n.id === String(targetElementId))
614
637
  let finalSourceHandle = 'right'; let finalTargetHandle = 'left'
615
638
  if (sourceNode && targetNode) {
616
- const h = (await import('./utils')).findClosestHandles(sourceNode, targetNode)
639
+ const h = findClosestHandles(sourceNode, targetNode)
617
640
  finalSourceHandle = h.sourceHandle; finalTargetHandle = h.targetHandle
618
641
  }
619
642
  try {
@@ -621,9 +644,9 @@ function ViewEditorInner({
621
644
  source_element_id: sourceId, target_element_id: targetElementId,
622
645
  source_handle: finalSourceHandle, target_handle: finalTargetHandle, direction: 'forward',
623
646
  })
624
- const connector = (await import('./utils')).connectorToConnector(newConnector)
647
+ const connector = connectorToConnector(newConnector)
625
648
  upsertConnectorGraphSnapshot(connector)
626
- setConnectors((prev) => [...prev, connector])
649
+ upsertStoreConnector(connector)
627
650
  } catch { /* intentionally empty */ }
628
651
  },
629
652
  existingElementIds, linksMapRef, parentLinksMapRef,
@@ -641,23 +664,11 @@ function ViewEditorInner({
641
664
  handleElementDeleted, handleElementPermanentlyDeleted,
642
665
  handleConnectorDeleted: useCallback((edgeId: number) => {
643
666
  if (viewId != null) removeConnectorGraphSnapshot(viewId, edgeId)
644
- setConnectors((prev) => prev.filter((connector) => connector.id !== edgeId))
645
- }, [setConnectors, viewId]),
667
+ removeStoreConnector(edgeId)
668
+ }, [removeStoreConnector, viewId]),
646
669
  handleUpdateTags,
647
670
  drawingCanvasRef,
648
671
  snapToGrid,
649
- onMoveStateChange: useCallback((moving: boolean) => {
650
- setLiveContextNodes((nds) => {
651
- let changed = false
652
- const nextNodes = nds.map((node) => {
653
- const currentMoving = Boolean((node.data as { isCanvasMoving?: boolean }).isCanvasMoving)
654
- if (currentMoving === moving) return node
655
- changed = true
656
- return { ...node, data: { ...node.data, isCanvasMoving: moving } }
657
- })
658
- return changed ? nextNodes : nds
659
- })
660
- }, []),
661
672
  })
662
673
 
663
674
  // Wire stable placeholders to the real implementations from canvas hook
@@ -826,7 +837,7 @@ function ViewEditorInner({
826
837
  const ctxChanges = changes.filter((c) => 'id' in c && contextNodeIdsRef.current.has((c as { id: string }).id))
827
838
  const mainChanges = changes.filter((c) => !('id' in c) || !contextNodeIdsRef.current.has((c as { id: string }).id))
828
839
  if (ctxChanges.length > 0) {
829
- setLiveContextNodes((nds) => applyNodeChanges(ctxChanges, nds))
840
+ setLiveContextNodes((nds) => applyNodeChangesWithStructuralSharing(ctxChanges, nds))
830
841
  }
831
842
  if (mainChanges.length > 0) {
832
843
  canvasOnNodesChange(mainChanges)
@@ -1010,12 +1021,12 @@ function ViewEditorInner({
1010
1021
  const handleOpenExport = useCallback(() => exportModal.onOpen(), [exportModal])
1011
1022
  const handleConnectorSave = useCallback((updated: Connector) => {
1012
1023
  upsertConnectorGraphSnapshot(updated)
1013
- setConnectors((prev) => prev.map((c) => (c.id === updated.id ? updated : c)))
1014
- }, [setConnectors])
1024
+ upsertStoreConnector(updated)
1025
+ }, [upsertStoreConnector])
1015
1026
  const handleConnectorDeleteInPanel = useCallback((edgeId: number) => {
1016
1027
  if (viewId != null) removeConnectorGraphSnapshot(viewId, edgeId)
1017
- setConnectors((prev) => prev.filter((c) => c.id !== edgeId))
1018
- }, [setConnectors, viewId])
1028
+ removeStoreConnector(edgeId)
1029
+ }, [removeStoreConnector, viewId])
1019
1030
  const handleViewSave = useCallback((updated: ViewTreeNode) => setView(updated), [setView])
1020
1031
 
1021
1032
  // ── Library helpers ────────────────────────────────────────────────────────
@@ -1224,8 +1235,10 @@ function ViewEditorInner({
1224
1235
  nodesDraggable={canEdit} connectionMode={ConnectionMode.Loose} connectionRadius={25}
1225
1236
  edgesUpdatable={canEdit} reconnectRadius={0}
1226
1237
  snapToGrid={snapToGrid}
1227
- snapGrid={[30, 30]}
1238
+ snapGrid={SNAP_GRID}
1228
1239
  deleteKeyCode={null}
1240
+ onlyRenderVisibleElements
1241
+ autoPanOnNodeDrag={false}
1229
1242
  panOnDrag={!drawingMode}
1230
1243
  panOnScroll={!isMobileLayout} panOnScrollSpeed={1.2} panOnScrollMode={PanOnScrollMode.Free}
1231
1244
  zoomOnScroll={false} zoomOnPinch
@@ -1308,7 +1321,7 @@ function ViewEditorInner({
1308
1321
  try {
1309
1322
  await api.workspace.connectors.delete('', edgeId)
1310
1323
  removeConnectorGraphSnapshot(viewId, edgeId)
1311
- setConnectors((prev) => prev.filter((connector) => connector.id !== edgeId))
1324
+ removeStoreConnector(edgeId)
1312
1325
  } catch { /* intentionally empty */ }
1313
1326
  }}
1314
1327
  />
@@ -0,0 +1 @@
1
+ export default {}
@@ -0,0 +1,272 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import type { Connector, LibraryElement, PlacedElement, ViewTreeNode } from '../types'
3
+ import {
4
+ buildViewContentLinks,
5
+ canvasSelectors,
6
+ emptyViewEditorUiState,
7
+ findViewByOwner,
8
+ findViewPath,
9
+ mergeSavedElementIntoPlacements,
10
+ removeConnectorFromList,
11
+ removePlacedElement,
12
+ selectConnectorById,
13
+ selectElementById,
14
+ selectExistingElementIds,
15
+ updatePlacedElementPosition,
16
+ upsertConnectorInList,
17
+ useStore,
18
+ } from './useStore'
19
+
20
+ const tree: ViewTreeNode[] = [{
21
+ id: 1,
22
+ owner_element_id: null,
23
+ name: 'Root',
24
+ description: null,
25
+ level_label: null,
26
+ level: 0,
27
+ depth: 0,
28
+ created_at: '2024-01-01',
29
+ updated_at: '2024-01-01',
30
+ parent_view_id: null,
31
+ children: [{
32
+ id: 2,
33
+ owner_element_id: 10,
34
+ name: 'Child',
35
+ description: null,
36
+ level_label: null,
37
+ level: 1,
38
+ depth: 1,
39
+ created_at: '2024-01-01',
40
+ updated_at: '2024-01-01',
41
+ parent_view_id: 1,
42
+ children: [],
43
+ }],
44
+ }]
45
+
46
+ const element = (element_id: number, x = 0, y = 0): PlacedElement => ({
47
+ id: element_id + 100,
48
+ view_id: 1,
49
+ element_id,
50
+ position_x: x,
51
+ position_y: y,
52
+ name: `Element ${element_id}`,
53
+ description: null,
54
+ kind: null,
55
+ technology: null,
56
+ url: null,
57
+ logo_url: null,
58
+ technology_connectors: [],
59
+ tags: [],
60
+ has_view: false,
61
+ view_label: null,
62
+ })
63
+
64
+ const connector = (id: number): Connector => ({
65
+ id,
66
+ view_id: 1,
67
+ source_element_id: 10,
68
+ target_element_id: 20,
69
+ label: null,
70
+ description: null,
71
+ relationship: null,
72
+ direction: 'forward',
73
+ style: 'bezier',
74
+ url: null,
75
+ source_handle: 'right',
76
+ target_handle: 'left',
77
+ created_at: '2024-01-01',
78
+ updated_at: '2024-01-01',
79
+ })
80
+
81
+ const libraryElement = (id: number): LibraryElement => ({
82
+ id,
83
+ name: 'Saved',
84
+ kind: 'service',
85
+ description: 'Updated',
86
+ technology: 'TypeScript',
87
+ url: 'https://example.com',
88
+ logo_url: 'logo.svg',
89
+ technology_connectors: [{ type: 'custom', label: 'TS' }],
90
+ tags: ['api'],
91
+ repo: 'repo',
92
+ branch: 'main',
93
+ file_path: 'src/index.ts',
94
+ language: 'ts',
95
+ created_at: '2024-01-01',
96
+ updated_at: '2024-01-02',
97
+ has_view: true,
98
+ view_label: 'View',
99
+ })
100
+
101
+ beforeEach(() => {
102
+ useStore.setState({
103
+ ...emptyViewEditorUiState,
104
+ view: undefined,
105
+ viewElements: [],
106
+ connectors: [],
107
+ nodes: [],
108
+ edges: [],
109
+ linksMap: {},
110
+ parentLinksMap: {},
111
+ incomingLinks: [],
112
+ treeData: [],
113
+ allElements: [],
114
+ libraryRefresh: 0,
115
+ })
116
+ })
117
+
118
+ describe('pure view helpers', () => {
119
+ it('finds views by owner and path', () => {
120
+ expect(findViewByOwner(tree, 10)?.id).toBe(2)
121
+ expect(findViewByOwner(tree, 999)).toBeNull()
122
+ expect(findViewPath(tree, 2)?.map((view) => view.id)).toEqual([1, 2])
123
+ expect(findViewPath(tree, 999)).toBeNull()
124
+ })
125
+
126
+ it('builds child, parent, and incoming links for a view', () => {
127
+ const result = buildViewContentLinks(tree, 1, [element(10), element(20)])
128
+ expect(result.linksMap[10][0]).toMatchObject({ to_view_id: 2, relation_type: 'child' })
129
+ expect(result.parentLinksMap).toEqual({})
130
+ expect(result.incomingLinks).toEqual([])
131
+
132
+ const childResult = buildViewContentLinks(tree, 2, [element(20)])
133
+ expect(childResult.parentLinksMap[20][0]).toMatchObject({ from_view_id: 1, relation_type: 'parent' })
134
+ expect(childResult.incomingLinks[0]).toMatchObject({ element_id: 10, from_view_id: 1, to_view_id: 2 })
135
+ })
136
+
137
+ it('updates placement positions structurally', () => {
138
+ const elements = [element(10, 1, 2), element(20, 3, 4)]
139
+ expect(updatePlacedElementPosition(elements, 10, 1, 2)).toBe(elements)
140
+ const next = updatePlacedElementPosition(elements, 10, 9, 8)
141
+ expect(next).not.toBe(elements)
142
+ expect(next[0]).toMatchObject({ position_x: 9, position_y: 8 })
143
+ expect(next[1]).toBe(elements[1])
144
+ })
145
+
146
+ it('removes placements and merges saved element fields', () => {
147
+ const elements = [element(10), element(20)]
148
+ expect(removePlacedElement(elements, 10).map((item) => item.element_id)).toEqual([20])
149
+ const merged = mergeSavedElementIntoPlacements(elements, libraryElement(10))
150
+ expect(merged[0]).toMatchObject({ name: 'Saved', kind: 'service', repo: 'repo', tags: ['api'] })
151
+ expect(merged[1]).toBe(elements[1])
152
+ })
153
+
154
+ it('upserts and removes connectors', () => {
155
+ const first = connector(1)
156
+ const second = { ...connector(2), label: 'two' }
157
+ expect(upsertConnectorInList([], first)).toEqual([first])
158
+ expect(upsertConnectorInList([first], { ...first, label: 'updated' })[0].label).toBe('updated')
159
+ expect(upsertConnectorInList([first], second)).toEqual([first, second])
160
+ expect(removeConnectorFromList([first, second], 1)).toEqual([second])
161
+ })
162
+
163
+ it('selects existing element ids and entities by id', () => {
164
+ const state = { viewElements: [element(10), element(20)], connectors: [connector(1)] }
165
+ expect(Array.from(selectExistingElementIds(state))).toEqual([10, 20])
166
+ expect(selectElementById(state, 20)?.name).toBe('Element 20')
167
+ expect(selectConnectorById(state, 1)?.source_element_id).toBe(10)
168
+ expect(canvasSelectors.existingElementIds(state).has(10)).toBe(true)
169
+ expect(canvasSelectors.elementById(state, 10)?.id).toBe(110)
170
+ expect(canvasSelectors.connectorById(state, 1)?.id).toBe(1)
171
+ })
172
+ })
173
+
174
+ describe('zustand canvas store', () => {
175
+ it('updates every scalar ui action', () => {
176
+ const selectedElement = libraryElement(10)
177
+ const selectedConnector = connector(1)
178
+ useStore.getState().setViewEditorUi({ viewId: 1, canEdit: true, isOwner: true, isFreePlan: true })
179
+ useStore.getState().setSnapToGrid(false)
180
+ useStore.getState().setSelectedElement(selectedElement)
181
+ useStore.getState().setSelectedConnector(selectedConnector)
182
+
183
+ expect(useStore.getState()).toMatchObject({
184
+ viewId: 1,
185
+ canEdit: true,
186
+ isOwner: true,
187
+ isFreePlan: true,
188
+ snapToGrid: false,
189
+ selectedElement,
190
+ selectedConnector,
191
+ })
192
+ })
193
+
194
+ it('sets every core collection with values and updater functions', () => {
195
+ useStore.getState().setView(tree[0])
196
+ useStore.getState().setViewElements([element(10)])
197
+ useStore.getState().setViewElements((previous) => [...previous, element(20)])
198
+ useStore.getState().setConnectors([connector(1)])
199
+ useStore.getState().setConnectors((previous) => [...previous, connector(2)])
200
+ useStore.getState().setNodes([{ id: '10', position: { x: 0, y: 0 }, data: {} }])
201
+ useStore.getState().setNodes((previous) => [...previous, { id: '20', position: { x: 1, y: 1 }, data: {} }])
202
+ useStore.getState().setEdges([{ id: '1', source: '10', target: '20' }])
203
+ useStore.getState().setEdges((previous) => [...previous, { id: '2', source: '20', target: '10' }])
204
+ useStore.getState().setLinksMap({ 10: [] })
205
+ useStore.getState().setLinksMap((previous) => ({ ...previous, 20: [] }))
206
+ useStore.getState().setParentLinksMap({ 10: [] })
207
+ useStore.getState().setParentLinksMap((previous) => ({ ...previous, 20: [] }))
208
+ useStore.getState().setIncomingLinks([{ id: 1, element_id: 10, element_name: 'E', from_view_id: 1, from_view_name: 'Root', to_view_id: 2 }])
209
+ useStore.getState().setTreeData(tree)
210
+ useStore.getState().setAllElements([libraryElement(10)])
211
+ useStore.getState().setLibraryRefresh((previous) => previous + 1)
212
+
213
+ const state = useStore.getState()
214
+ expect(state.view?.id).toBe(1)
215
+ expect(state.viewElements).toHaveLength(2)
216
+ expect(state.connectors).toHaveLength(2)
217
+ expect(state.nodes).toHaveLength(2)
218
+ expect(state.edges).toHaveLength(2)
219
+ expect(Object.keys(state.linksMap)).toEqual(['10', '20'])
220
+ expect(Object.keys(state.parentLinksMap)).toEqual(['10', '20'])
221
+ expect(state.incomingLinks).toHaveLength(1)
222
+ expect(state.treeData).toBe(tree)
223
+ expect(state.allElements).toHaveLength(1)
224
+ expect(state.libraryRefresh).toBe(1)
225
+ })
226
+
227
+ it('hydrates and resets canvas data', () => {
228
+ const links = buildViewContentLinks(tree, 1, [element(10)])
229
+ useStore.getState().setNodes([{ id: 'stale', position: { x: 0, y: 0 }, data: {} }])
230
+ useStore.getState().setEdges([{ id: 'stale', source: '1', target: '2' }])
231
+ useStore.getState().hydrateViewContent({
232
+ view: tree[0],
233
+ viewElements: [element(10)],
234
+ connectors: [connector(1)],
235
+ treeData: tree,
236
+ ...links,
237
+ })
238
+ expect(useStore.getState()).toMatchObject({
239
+ view: tree[0],
240
+ viewElements: [element(10)],
241
+ connectors: [connector(1)],
242
+ treeData: tree,
243
+ })
244
+ useStore.getState().resetCanvas()
245
+ expect(useStore.getState().nodes).toEqual([])
246
+ expect(useStore.getState().edges).toEqual([])
247
+ })
248
+
249
+ it('runs placement, connector, and deletion actions', () => {
250
+ useStore.getState().setViewElements([element(10), element(20)])
251
+ useStore.getState().setConnectors([connector(1)])
252
+ useStore.getState().updateElementPosition(10, 50, 60)
253
+ expect(useStore.getState().viewElements[0]).toMatchObject({ position_x: 50, position_y: 60 })
254
+
255
+ useStore.getState().removeElementPlacement(10)
256
+ expect(useStore.getState().viewElements.map((item) => item.element_id)).toEqual([20])
257
+
258
+ useStore.getState().setViewElements([element(10)])
259
+ useStore.getState().mergeSavedElement(libraryElement(10))
260
+ expect(useStore.getState().viewElements[0].name).toBe('Saved')
261
+ expect(useStore.getState().libraryRefresh).toBe(1)
262
+
263
+ useStore.getState().removeElementEverywhere(10)
264
+ expect(useStore.getState().viewElements).toEqual([])
265
+ expect(useStore.getState().libraryRefresh).toBe(2)
266
+
267
+ useStore.getState().upsertConnector(connector(2))
268
+ useStore.getState().replaceConnector({ ...connector(2), label: 'updated' })
269
+ useStore.getState().removeConnector(1)
270
+ expect(useStore.getState().connectors).toEqual([{ ...connector(2), label: 'updated' }])
271
+ })
272
+ })