@tldiagram/core-ui 1.92.0 → 1.94.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.
Files changed (37) hide show
  1. package/dist/api/client.d.ts +13 -1
  2. package/dist/components/ElementNode.d.ts +14 -1
  3. package/dist/components/ZUI/ZUICanvas.d.ts +1 -0
  4. package/dist/config/runtime-vscode.d.ts +1 -0
  5. package/dist/config/runtime.d.ts +1 -0
  6. package/dist/index.js +10875 -9550
  7. package/dist/pages/InfiniteZoom.d.ts +5 -2
  8. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +10 -3
  9. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.test.d.ts +1 -0
  10. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +27 -24
  11. package/dist/pages/ViewsGrid.d.ts +9 -1
  12. package/dist/shims/empty-node-module.d.ts +2 -0
  13. package/dist/store/useStore.d.ts +80 -0
  14. package/dist/store/useStore.test.d.ts +1 -0
  15. package/package.json +10 -7
  16. package/src/api/client.ts +39 -1
  17. package/src/components/ElementNode.tsx +21 -59
  18. package/src/components/ElementPanel.tsx +2 -3
  19. package/src/components/LayoutSection.tsx +95 -104
  20. package/src/components/ViewGridNode.tsx +1 -4
  21. package/src/components/ZUI/ZUICanvas.tsx +138 -1
  22. package/src/components/ZUI/renderer.ts +166 -66
  23. package/src/components/ZUI/useZUIInteraction.ts +235 -81
  24. package/src/config/runtime-vscode.ts +6 -0
  25. package/src/config/runtime.ts +4 -0
  26. package/src/main.tsx +26 -14
  27. package/src/pages/InfiniteZoom.tsx +14 -5
  28. package/src/pages/ViewEditor/context.tsx +14 -3
  29. package/src/pages/ViewEditor/hooks/useCanvasInteractions.test.ts +30 -0
  30. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +294 -146
  31. package/src/pages/ViewEditor/hooks/useViewData.ts +459 -256
  32. package/src/pages/ViewEditor/index.tsx +67 -70
  33. package/src/pages/Views.tsx +552 -83
  34. package/src/pages/ViewsGrid.tsx +26 -337
  35. package/src/shims/empty-node-module.ts +1 -0
  36. package/src/store/useStore.test.ts +285 -0
  37. package/src/store/useStore.ts +327 -0
@@ -0,0 +1,285 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import type { Connector, LibraryElement, PlacedElement, ViewTreeNode } from '../types'
3
+ import {
4
+ buildViewContentLinks,
5
+ buildElementLibraryItems,
6
+ canvasSelectors,
7
+ emptyViewEditorUiState,
8
+ findViewByOwner,
9
+ findViewPath,
10
+ mergeSavedElementIntoPlacements,
11
+ removeConnectorFromList,
12
+ removePlacedElement,
13
+ selectConnectorById,
14
+ selectElementById,
15
+ selectExistingElementIds,
16
+ updatePlacedElementPosition,
17
+ upsertConnectorInList,
18
+ useStore,
19
+ } from './useStore'
20
+
21
+ const tree: ViewTreeNode[] = [{
22
+ id: 1,
23
+ owner_element_id: null,
24
+ name: 'Root',
25
+ description: null,
26
+ level_label: null,
27
+ level: 0,
28
+ depth: 0,
29
+ created_at: '2024-01-01',
30
+ updated_at: '2024-01-01',
31
+ parent_view_id: null,
32
+ children: [{
33
+ id: 2,
34
+ owner_element_id: 10,
35
+ name: 'Child',
36
+ description: null,
37
+ level_label: null,
38
+ level: 1,
39
+ depth: 1,
40
+ created_at: '2024-01-01',
41
+ updated_at: '2024-01-01',
42
+ parent_view_id: 1,
43
+ children: [],
44
+ }],
45
+ }]
46
+
47
+ const element = (element_id: number, x = 0, y = 0): PlacedElement => ({
48
+ id: element_id + 100,
49
+ view_id: 1,
50
+ element_id,
51
+ position_x: x,
52
+ position_y: y,
53
+ name: `Element ${element_id}`,
54
+ description: null,
55
+ kind: null,
56
+ technology: null,
57
+ url: null,
58
+ logo_url: null,
59
+ technology_connectors: [],
60
+ tags: [],
61
+ has_view: false,
62
+ view_label: null,
63
+ })
64
+
65
+ const connector = (id: number): Connector => ({
66
+ id,
67
+ view_id: 1,
68
+ source_element_id: 10,
69
+ target_element_id: 20,
70
+ label: null,
71
+ description: null,
72
+ relationship: null,
73
+ direction: 'forward',
74
+ style: 'bezier',
75
+ url: null,
76
+ source_handle: 'right',
77
+ target_handle: 'left',
78
+ created_at: '2024-01-01',
79
+ updated_at: '2024-01-01',
80
+ })
81
+
82
+ const libraryElement = (id: number): LibraryElement => ({
83
+ id,
84
+ name: 'Saved',
85
+ kind: 'service',
86
+ description: 'Updated',
87
+ technology: 'TypeScript',
88
+ url: 'https://example.com',
89
+ logo_url: 'logo.svg',
90
+ technology_connectors: [{ type: 'custom', label: 'TS' }],
91
+ tags: ['api'],
92
+ repo: 'repo',
93
+ branch: 'main',
94
+ file_path: 'src/index.ts',
95
+ language: 'ts',
96
+ created_at: '2024-01-01',
97
+ updated_at: '2024-01-02',
98
+ has_view: true,
99
+ view_label: 'View',
100
+ })
101
+
102
+ beforeEach(() => {
103
+ useStore.setState({
104
+ ...emptyViewEditorUiState,
105
+ view: undefined,
106
+ viewElements: [],
107
+ connectors: [],
108
+ nodes: [],
109
+ edges: [],
110
+ linksMap: {},
111
+ parentLinksMap: {},
112
+ incomingLinks: [],
113
+ treeData: [],
114
+ allElements: [],
115
+ libraryRefresh: 0,
116
+ })
117
+ })
118
+
119
+ describe('pure view helpers', () => {
120
+ it('finds views by owner and path', () => {
121
+ expect(findViewByOwner(tree, 10)?.id).toBe(2)
122
+ expect(findViewByOwner(tree, 999)).toBeNull()
123
+ expect(findViewPath(tree, 2)?.map((view) => view.id)).toEqual([1, 2])
124
+ expect(findViewPath(tree, 999)).toBeNull()
125
+ })
126
+
127
+ it('builds child, parent, and incoming links for a view', () => {
128
+ const result = buildViewContentLinks(tree, 1, [element(10), element(20)])
129
+ expect(result.linksMap[10][0]).toMatchObject({ to_view_id: 2, relation_type: 'child' })
130
+ expect(result.parentLinksMap).toEqual({})
131
+ expect(result.incomingLinks).toEqual([])
132
+
133
+ const childResult = buildViewContentLinks(tree, 2, [element(20)])
134
+ expect(childResult.parentLinksMap[20][0]).toMatchObject({ from_view_id: 1, relation_type: 'parent' })
135
+ expect(childResult.incomingLinks[0]).toMatchObject({ element_id: 10, from_view_id: 1, to_view_id: 2 })
136
+ })
137
+
138
+ it('updates placement positions structurally', () => {
139
+ const elements = [element(10, 1, 2), element(20, 3, 4)]
140
+ expect(updatePlacedElementPosition(elements, 10, 1, 2)).toBe(elements)
141
+ const next = updatePlacedElementPosition(elements, 10, 9, 8)
142
+ expect(next).not.toBe(elements)
143
+ expect(next[0]).toMatchObject({ position_x: 9, position_y: 8 })
144
+ expect(next[1]).toBe(elements[1])
145
+ })
146
+
147
+ it('removes placements and merges saved element fields', () => {
148
+ const elements = [element(10), element(20)]
149
+ expect(removePlacedElement(elements, 10).map((item) => item.element_id)).toEqual([20])
150
+ const merged = mergeSavedElementIntoPlacements(elements, libraryElement(10))
151
+ expect(merged[0]).toMatchObject({ name: 'Saved', kind: 'service', repo: 'repo', tags: ['api'] })
152
+ expect(merged[1]).toBe(elements[1])
153
+ })
154
+
155
+ it('keeps library items available after removing their canvas placement', () => {
156
+ const onCanvas = element(10)
157
+ const libraryItems = buildElementLibraryItems([libraryElement(10), libraryElement(20)], [onCanvas])
158
+
159
+ expect(libraryItems.map((item) => item.id)).toEqual([10, 20])
160
+ expect(libraryItems[0]).toMatchObject({ id: 10, name: onCanvas.name, created_at: '2024-01-01' })
161
+
162
+ const afterRemoval = buildElementLibraryItems([libraryElement(10), libraryElement(20)], removePlacedElement([onCanvas], 10))
163
+ expect(afterRemoval.map((item) => item.id)).toEqual([10, 20])
164
+ expect(afterRemoval[0]).toMatchObject({ id: 10, name: 'Saved' })
165
+ })
166
+
167
+ it('upserts and removes connectors', () => {
168
+ const first = connector(1)
169
+ const second = { ...connector(2), label: 'two' }
170
+ expect(upsertConnectorInList([], first)).toEqual([first])
171
+ expect(upsertConnectorInList([first], { ...first, label: 'updated' })[0].label).toBe('updated')
172
+ expect(upsertConnectorInList([first], second)).toEqual([first, second])
173
+ expect(removeConnectorFromList([first, second], 1)).toEqual([second])
174
+ })
175
+
176
+ it('selects existing element ids and entities by id', () => {
177
+ const state = { viewElements: [element(10), element(20)], connectors: [connector(1)] }
178
+ expect(Array.from(selectExistingElementIds(state))).toEqual([10, 20])
179
+ expect(selectElementById(state, 20)?.name).toBe('Element 20')
180
+ expect(selectConnectorById(state, 1)?.source_element_id).toBe(10)
181
+ expect(canvasSelectors.existingElementIds(state).has(10)).toBe(true)
182
+ expect(canvasSelectors.elementById(state, 10)?.id).toBe(110)
183
+ expect(canvasSelectors.connectorById(state, 1)?.id).toBe(1)
184
+ })
185
+ })
186
+
187
+ describe('zustand canvas store', () => {
188
+ it('updates every scalar ui action', () => {
189
+ const selectedElement = libraryElement(10)
190
+ const selectedConnector = connector(1)
191
+ useStore.getState().setViewEditorUi({ viewId: 1, canEdit: true, isOwner: true, isFreePlan: true })
192
+ useStore.getState().setSnapToGrid(false)
193
+ useStore.getState().setSelectedElement(selectedElement)
194
+ useStore.getState().setSelectedConnector(selectedConnector)
195
+
196
+ expect(useStore.getState()).toMatchObject({
197
+ viewId: 1,
198
+ canEdit: true,
199
+ isOwner: true,
200
+ isFreePlan: true,
201
+ snapToGrid: false,
202
+ selectedElement,
203
+ selectedConnector,
204
+ })
205
+ })
206
+
207
+ it('sets every core collection with values and updater functions', () => {
208
+ useStore.getState().setView(tree[0])
209
+ useStore.getState().setViewElements([element(10)])
210
+ useStore.getState().setViewElements((previous) => [...previous, element(20)])
211
+ useStore.getState().setConnectors([connector(1)])
212
+ useStore.getState().setConnectors((previous) => [...previous, connector(2)])
213
+ useStore.getState().setNodes([{ id: '10', position: { x: 0, y: 0 }, data: {} }])
214
+ useStore.getState().setNodes((previous) => [...previous, { id: '20', position: { x: 1, y: 1 }, data: {} }])
215
+ useStore.getState().setEdges([{ id: '1', source: '10', target: '20' }])
216
+ useStore.getState().setEdges((previous) => [...previous, { id: '2', source: '20', target: '10' }])
217
+ useStore.getState().setLinksMap({ 10: [] })
218
+ useStore.getState().setLinksMap((previous) => ({ ...previous, 20: [] }))
219
+ useStore.getState().setParentLinksMap({ 10: [] })
220
+ useStore.getState().setParentLinksMap((previous) => ({ ...previous, 20: [] }))
221
+ useStore.getState().setIncomingLinks([{ id: 1, element_id: 10, element_name: 'E', from_view_id: 1, from_view_name: 'Root', to_view_id: 2 }])
222
+ useStore.getState().setTreeData(tree)
223
+ useStore.getState().setAllElements([libraryElement(10)])
224
+ useStore.getState().setLibraryRefresh((previous) => previous + 1)
225
+
226
+ const state = useStore.getState()
227
+ expect(state.view?.id).toBe(1)
228
+ expect(state.viewElements).toHaveLength(2)
229
+ expect(state.connectors).toHaveLength(2)
230
+ expect(state.nodes).toHaveLength(2)
231
+ expect(state.edges).toHaveLength(2)
232
+ expect(Object.keys(state.linksMap)).toEqual(['10', '20'])
233
+ expect(Object.keys(state.parentLinksMap)).toEqual(['10', '20'])
234
+ expect(state.incomingLinks).toHaveLength(1)
235
+ expect(state.treeData).toBe(tree)
236
+ expect(state.allElements).toHaveLength(1)
237
+ expect(state.libraryRefresh).toBe(1)
238
+ })
239
+
240
+ it('hydrates and resets canvas data', () => {
241
+ const links = buildViewContentLinks(tree, 1, [element(10)])
242
+ useStore.getState().setNodes([{ id: 'stale', position: { x: 0, y: 0 }, data: {} }])
243
+ useStore.getState().setEdges([{ id: 'stale', source: '1', target: '2' }])
244
+ useStore.getState().hydrateViewContent({
245
+ view: tree[0],
246
+ viewElements: [element(10)],
247
+ connectors: [connector(1)],
248
+ treeData: tree,
249
+ ...links,
250
+ })
251
+ expect(useStore.getState()).toMatchObject({
252
+ view: tree[0],
253
+ viewElements: [element(10)],
254
+ connectors: [connector(1)],
255
+ treeData: tree,
256
+ })
257
+ useStore.getState().resetCanvas()
258
+ expect(useStore.getState().nodes).toEqual([])
259
+ expect(useStore.getState().edges).toEqual([])
260
+ })
261
+
262
+ it('runs placement, connector, and deletion actions', () => {
263
+ useStore.getState().setViewElements([element(10), element(20)])
264
+ useStore.getState().setConnectors([connector(1)])
265
+ useStore.getState().updateElementPosition(10, 50, 60)
266
+ expect(useStore.getState().viewElements[0]).toMatchObject({ position_x: 50, position_y: 60 })
267
+
268
+ useStore.getState().removeElementPlacement(10)
269
+ expect(useStore.getState().viewElements.map((item) => item.element_id)).toEqual([20])
270
+
271
+ useStore.getState().setViewElements([element(10)])
272
+ useStore.getState().mergeSavedElement(libraryElement(10))
273
+ expect(useStore.getState().viewElements[0].name).toBe('Saved')
274
+ expect(useStore.getState().libraryRefresh).toBe(1)
275
+
276
+ useStore.getState().removeElementEverywhere(10)
277
+ expect(useStore.getState().viewElements).toEqual([])
278
+ expect(useStore.getState().libraryRefresh).toBe(2)
279
+
280
+ useStore.getState().upsertConnector(connector(2))
281
+ useStore.getState().replaceConnector({ ...connector(2), label: 'updated' })
282
+ useStore.getState().removeConnector(1)
283
+ expect(useStore.getState().connectors).toEqual([{ ...connector(2), label: 'updated' }])
284
+ })
285
+ })
@@ -0,0 +1,327 @@
1
+ import { create } from 'zustand'
2
+ import type { Edge as RFEdge, Node as RFNode } from 'reactflow'
3
+ import type {
4
+ Connector,
5
+ IncomingViewConnector,
6
+ LibraryElement,
7
+ PlacedElement,
8
+ ViewConnector,
9
+ ViewTreeNode,
10
+ } from '../types'
11
+
12
+ export type StoreSetter<T> = T | ((previous: T) => T)
13
+
14
+ export type ViewEditorUiState = {
15
+ viewId: number | null
16
+ canEdit: boolean
17
+ isOwner: boolean
18
+ isFreePlan: boolean
19
+ snapToGrid: boolean
20
+ selectedElement: LibraryElement | null
21
+ selectedConnector: Connector | null
22
+ }
23
+
24
+ export type ViewContentLinks = {
25
+ linksMap: Record<number, ViewConnector[]>
26
+ parentLinksMap: Record<number, ViewConnector[]>
27
+ incomingLinks: IncomingViewConnector[]
28
+ }
29
+
30
+ export type ViewContentPayload = ViewContentLinks & {
31
+ view: ViewTreeNode | null
32
+ viewElements: PlacedElement[]
33
+ connectors: Connector[]
34
+ treeData: ViewTreeNode[]
35
+ }
36
+
37
+ export type CanvasStoreState = ViewEditorUiState & {
38
+ view: ViewTreeNode | null | undefined
39
+ viewElements: PlacedElement[]
40
+ connectors: Connector[]
41
+ nodes: RFNode[]
42
+ edges: RFEdge[]
43
+ linksMap: Record<number, ViewConnector[]>
44
+ parentLinksMap: Record<number, ViewConnector[]>
45
+ incomingLinks: IncomingViewConnector[]
46
+ treeData: ViewTreeNode[]
47
+ allElements: LibraryElement[]
48
+ libraryRefresh: number
49
+
50
+ setViewEditorUi: (patch: Partial<ViewEditorUiState>) => void
51
+ setSnapToGrid: (snapToGrid: boolean) => void
52
+ setSelectedElement: (selectedElement: LibraryElement | null) => void
53
+ setSelectedConnector: (selectedConnector: Connector | null) => void
54
+ setView: (view: ViewTreeNode | null | undefined) => void
55
+ setViewElements: (next: StoreSetter<PlacedElement[]>) => void
56
+ setConnectors: (next: StoreSetter<Connector[]>) => void
57
+ setNodes: (next: StoreSetter<RFNode[]>) => void
58
+ setEdges: (next: StoreSetter<RFEdge[]>) => void
59
+ setLinksMap: (next: StoreSetter<Record<number, ViewConnector[]>>) => void
60
+ setParentLinksMap: (next: StoreSetter<Record<number, ViewConnector[]>>) => void
61
+ setIncomingLinks: (next: StoreSetter<IncomingViewConnector[]>) => void
62
+ setTreeData: (next: StoreSetter<ViewTreeNode[]>) => void
63
+ setAllElements: (next: StoreSetter<LibraryElement[]>) => void
64
+ setLibraryRefresh: (next: StoreSetter<number>) => void
65
+ resetCanvas: () => void
66
+ hydrateViewContent: (payload: ViewContentPayload) => void
67
+ updateElementPosition: (elementId: number, x: number, y: number) => void
68
+ removeElementPlacement: (elementId: number) => void
69
+ removeElementEverywhere: (elementId: number) => void
70
+ mergeSavedElement: (saved: LibraryElement) => void
71
+ upsertConnector: (connector: Connector) => void
72
+ replaceConnector: (connector: Connector) => void
73
+ removeConnector: (connectorId: number) => void
74
+ }
75
+
76
+ export const emptyViewEditorUiState: ViewEditorUiState = {
77
+ viewId: null,
78
+ canEdit: false,
79
+ isOwner: false,
80
+ isFreePlan: false,
81
+ snapToGrid: false,
82
+ selectedElement: null,
83
+ selectedConnector: null,
84
+ }
85
+
86
+ function resolveSetter<T>(next: StoreSetter<T>, previous: T): T {
87
+ return typeof next === 'function' ? (next as (previous: T) => T)(previous) : next
88
+ }
89
+
90
+ export function findViewByOwner(nodes: ViewTreeNode[], elementId: number): ViewTreeNode | null {
91
+ for (const node of nodes) {
92
+ if (node.owner_element_id !== null && Number(node.owner_element_id) === Number(elementId)) return node
93
+ const found = findViewByOwner(node.children, elementId)
94
+ if (found) return found
95
+ }
96
+ return null
97
+ }
98
+
99
+ export function findViewPath(nodes: ViewTreeNode[], targetId: number, path: ViewTreeNode[] = []): ViewTreeNode[] | null {
100
+ for (const node of nodes) {
101
+ if (node.id === targetId) return [...path, node]
102
+ const found = findViewPath(node.children, targetId, [...path, node])
103
+ if (found) return found
104
+ }
105
+ return null
106
+ }
107
+
108
+ export function buildViewContentLinks(tree: ViewTreeNode[], viewId: number, viewElements: PlacedElement[]): ViewContentLinks {
109
+ const linksMap: Record<number, ViewConnector[]> = {}
110
+ const parentLinksMap: Record<number, ViewConnector[]> = {}
111
+
112
+ const viewPath = findViewPath(tree, viewId)
113
+ const parentView = viewPath && viewPath.length > 1 ? viewPath[viewPath.length - 2] : null
114
+ const currentViewInTree = viewPath ? viewPath[viewPath.length - 1] : null
115
+
116
+ const incomingLinks: IncomingViewConnector[] = []
117
+ if (parentView && currentViewInTree?.owner_element_id) {
118
+ incomingLinks.push({
119
+ id: 0,
120
+ element_id: currentViewInTree.owner_element_id,
121
+ element_name: 'Parent',
122
+ from_view_id: parentView.id,
123
+ from_view_name: parentView.name,
124
+ to_view_id: viewId,
125
+ })
126
+ }
127
+
128
+ for (const element of viewElements) {
129
+ const childView = findViewByOwner(tree, element.element_id)
130
+ if (childView) {
131
+ linksMap[element.element_id] = [{
132
+ id: 0,
133
+ element_id: element.element_id,
134
+ from_view_id: viewId,
135
+ to_view_id: childView.id,
136
+ to_view_name: childView.name,
137
+ relation_type: 'child',
138
+ }]
139
+ }
140
+
141
+ if (parentView) {
142
+ parentLinksMap[element.element_id] = [{
143
+ id: 0,
144
+ element_id: element.element_id,
145
+ from_view_id: parentView.id,
146
+ to_view_id: parentView.id,
147
+ to_view_name: parentView.name,
148
+ relation_type: 'parent',
149
+ }]
150
+ }
151
+ }
152
+
153
+ return { linksMap, parentLinksMap, incomingLinks }
154
+ }
155
+
156
+ export function selectExistingElementIds(state: Pick<CanvasStoreState, 'viewElements'>): Set<number> {
157
+ return new Set(state.viewElements.map((element) => element.element_id))
158
+ }
159
+
160
+ export function selectElementById(state: Pick<CanvasStoreState, 'viewElements'>, elementId: number): PlacedElement | undefined {
161
+ return state.viewElements.find((element) => element.element_id === elementId)
162
+ }
163
+
164
+ export function selectConnectorById(state: Pick<CanvasStoreState, 'connectors'>, connectorId: number): Connector | undefined {
165
+ return state.connectors.find((connector) => connector.id === connectorId)
166
+ }
167
+
168
+ export function updatePlacedElementPosition(elements: PlacedElement[], elementId: number, x: number, y: number): PlacedElement[] {
169
+ let changed = false
170
+ const next = elements.map((element) => {
171
+ if (element.element_id !== elementId) return element
172
+ if (element.position_x === x && element.position_y === y) return element
173
+ changed = true
174
+ return { ...element, position_x: x, position_y: y }
175
+ })
176
+ return changed ? next : elements
177
+ }
178
+
179
+ export function removePlacedElement(elements: PlacedElement[], elementId: number): PlacedElement[] {
180
+ return elements.filter((element) => element.element_id !== elementId)
181
+ }
182
+
183
+ export function placedElementToLibraryElement(element: PlacedElement): LibraryElement {
184
+ return {
185
+ id: element.element_id,
186
+ name: element.name,
187
+ kind: element.kind,
188
+ description: element.description,
189
+ technology: element.technology,
190
+ url: element.url,
191
+ logo_url: element.logo_url,
192
+ technology_connectors: element.technology_connectors,
193
+ tags: element.tags,
194
+ repo: element.repo,
195
+ branch: element.branch,
196
+ file_path: element.file_path,
197
+ language: element.language,
198
+ created_at: '',
199
+ updated_at: '',
200
+ has_view: element.has_view,
201
+ view_label: element.view_label,
202
+ }
203
+ }
204
+
205
+ export function buildElementLibraryItems(allElements: LibraryElement[], viewElements: PlacedElement[]): LibraryElement[] {
206
+ const byId = new Map<number, LibraryElement>()
207
+ allElements.forEach((element) => byId.set(element.id, element))
208
+ viewElements.forEach((element) => {
209
+ const placed = placedElementToLibraryElement(element)
210
+ const existing = byId.get(placed.id)
211
+ byId.set(placed.id, existing
212
+ ? {
213
+ ...existing,
214
+ ...placed,
215
+ created_at: existing.created_at,
216
+ updated_at: existing.updated_at,
217
+ has_view: existing.has_view,
218
+ view_label: existing.view_label,
219
+ }
220
+ : placed)
221
+ })
222
+ return Array.from(byId.values())
223
+ }
224
+
225
+ export function mergeSavedElementIntoPlacements(elements: PlacedElement[], saved: LibraryElement): PlacedElement[] {
226
+ return elements.map((element) =>
227
+ element.element_id === saved.id
228
+ ? {
229
+ ...element,
230
+ name: saved.name,
231
+ description: saved.description,
232
+ kind: saved.kind,
233
+ technology: saved.technology,
234
+ url: saved.url,
235
+ logo_url: saved.logo_url,
236
+ technology_connectors: saved.technology_connectors,
237
+ tags: saved.tags,
238
+ repo: saved.repo,
239
+ branch: saved.branch,
240
+ file_path: saved.file_path,
241
+ language: saved.language,
242
+ }
243
+ : element,
244
+ )
245
+ }
246
+
247
+ export function upsertConnectorInList(connectors: Connector[], connector: Connector): Connector[] {
248
+ const index = connectors.findIndex((candidate) => candidate.id === connector.id)
249
+ if (index === -1) return [...connectors, connector]
250
+ const next = connectors.slice()
251
+ next[index] = connector
252
+ return next
253
+ }
254
+
255
+ export function removeConnectorFromList(connectors: Connector[], connectorId: number): Connector[] {
256
+ return connectors.filter((connector) => connector.id !== connectorId)
257
+ }
258
+
259
+ export const useStore = create<CanvasStoreState>((set) => ({
260
+ ...emptyViewEditorUiState,
261
+ view: undefined,
262
+ viewElements: [],
263
+ connectors: [],
264
+ nodes: [],
265
+ edges: [],
266
+ linksMap: {},
267
+ parentLinksMap: {},
268
+ incomingLinks: [],
269
+ treeData: [],
270
+ allElements: [],
271
+ libraryRefresh: 0,
272
+
273
+ setViewEditorUi: (patch) => set((state) => ({ ...state, ...patch })),
274
+ setSnapToGrid: (snapToGrid) => set({ snapToGrid }),
275
+ setSelectedElement: (selectedElement) => set({ selectedElement }),
276
+ setSelectedConnector: (selectedConnector) => set({ selectedConnector }),
277
+ setView: (view) => set({ view }),
278
+ setViewElements: (next) => set((state) => ({ viewElements: resolveSetter(next, state.viewElements) })),
279
+ setConnectors: (next) => set((state) => ({ connectors: resolveSetter(next, state.connectors) })),
280
+ setNodes: (next) => set((state) => ({ nodes: resolveSetter(next, state.nodes) })),
281
+ setEdges: (next) => set((state) => ({ edges: resolveSetter(next, state.edges) })),
282
+ setLinksMap: (next) => set((state) => ({ linksMap: resolveSetter(next, state.linksMap) })),
283
+ setParentLinksMap: (next) => set((state) => ({ parentLinksMap: resolveSetter(next, state.parentLinksMap) })),
284
+ setIncomingLinks: (next) => set((state) => ({ incomingLinks: resolveSetter(next, state.incomingLinks) })),
285
+ setTreeData: (next) => set((state) => ({ treeData: resolveSetter(next, state.treeData) })),
286
+ setAllElements: (next) => set((state) => ({ allElements: resolveSetter(next, state.allElements) })),
287
+ setLibraryRefresh: (next) => set((state) => ({ libraryRefresh: resolveSetter(next, state.libraryRefresh) })),
288
+ resetCanvas: () => set({ nodes: [], edges: [] }),
289
+ hydrateViewContent: (payload) => set({
290
+ view: payload.view,
291
+ viewElements: payload.viewElements,
292
+ connectors: payload.connectors,
293
+ linksMap: payload.linksMap,
294
+ parentLinksMap: payload.parentLinksMap,
295
+ incomingLinks: payload.incomingLinks,
296
+ treeData: payload.treeData,
297
+ }),
298
+ updateElementPosition: (elementId, x, y) => set((state) => ({
299
+ viewElements: updatePlacedElementPosition(state.viewElements, elementId, x, y),
300
+ })),
301
+ removeElementPlacement: (elementId) => set((state) => ({
302
+ viewElements: removePlacedElement(state.viewElements, elementId),
303
+ })),
304
+ removeElementEverywhere: (elementId) => set((state) => ({
305
+ viewElements: removePlacedElement(state.viewElements, elementId),
306
+ libraryRefresh: state.libraryRefresh + 1,
307
+ })),
308
+ mergeSavedElement: (saved) => set((state) => ({
309
+ viewElements: mergeSavedElementIntoPlacements(state.viewElements, saved),
310
+ libraryRefresh: state.libraryRefresh + 1,
311
+ })),
312
+ upsertConnector: (connector) => set((state) => ({
313
+ connectors: upsertConnectorInList(state.connectors, connector),
314
+ })),
315
+ replaceConnector: (connector) => set((state) => ({
316
+ connectors: upsertConnectorInList(state.connectors, connector),
317
+ })),
318
+ removeConnector: (connectorId) => set((state) => ({
319
+ connectors: removeConnectorFromList(state.connectors, connectorId),
320
+ })),
321
+ }))
322
+
323
+ export const canvasSelectors = {
324
+ existingElementIds: selectExistingElementIds,
325
+ elementById: selectElementById,
326
+ connectorById: selectConnectorById,
327
+ }