@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.
@@ -1,6 +1,7 @@
1
1
  import type { DrawingCanvasHandle } from '../../../components/DrawingCanvas';
2
2
  import { type Connection, type Edge as RFEdge, type EdgeChange, type Node as RFNode, type NodeChange, type NodeDragHandler, type OnConnect, type OnConnectStartParams } from 'reactflow';
3
3
  import type { Connector, PlacedElement, ViewTreeNode, LibraryElement, ViewLayer, ViewConnector, IncomingViewConnector } from '../../../types';
4
+ export declare function applyNodeChangesWithStructuralSharing(changes: NodeChange[], nodes: RFNode[]): RFNode[];
4
5
  interface CanvasInteractionOptions {
5
6
  viewId: number | null;
6
7
  canEdit: boolean;
@@ -79,7 +80,7 @@ type HandleReconnectDragState = {
79
80
  hoveredNodeId?: string;
80
81
  hoveredHandleId?: string;
81
82
  };
82
- export declare function useCanvasInteractions({ viewId, canEdit, drawingMode: _drawingMode, isMobileLayout: _isMobileLayout, rfNodesRef, rfEdgesRef: _rfEdgesRef, viewElementsRef, viewIdRef, incomingLinksRef, treeDataRef, navigateRef, containerRef, interactionSourceIdRef, hoveredZoomRef, hoverPanLockedUntilRef, setViewElements, setConnectors, setRfNodes, setRfEdges, setLinksMap, setParentLinksMap: _setParentLinksMap, setHoveredZoom, refreshGrid, refreshElements, stableOnConnectTo, existingElementIds, linksMapRef, parentLinksMapRef, openElementPanel: _openElementPanel, closeElementPanel: closeElementPanel, openConnectorPanel: openConnectorPanel, closeConnectorPanel: closeConnectorPanel, selectedElement, selectedEdgeId, connectors, layers, setSelectedElement, setSelectedEdge, setSelectedEdgeId, setSelectedProxyConnectorDetails, openProxyConnectorPanel, closeProxyConnectorPanel, handleElementDeleted, handleElementPermanentlyDeleted, handleConnectorDeleted, handleUpdateTags, drawingCanvasRef, snapToGrid, onMoveStateChange, }: CanvasInteractionOptions): {
83
+ export declare function useCanvasInteractions({ viewId, canEdit, drawingMode: _drawingMode, isMobileLayout: _isMobileLayout, rfNodesRef, rfEdgesRef: _rfEdgesRef, viewElementsRef, viewIdRef, incomingLinksRef, treeDataRef, navigateRef, containerRef, interactionSourceIdRef, hoveredZoomRef, hoverPanLockedUntilRef, setViewElements: _setViewElements, setConnectors: _setConnectors, setRfNodes, setRfEdges, setLinksMap, setParentLinksMap: _setParentLinksMap, setHoveredZoom, refreshGrid, refreshElements, stableOnConnectTo, existingElementIds, linksMapRef, parentLinksMapRef, openElementPanel: _openElementPanel, closeElementPanel: closeElementPanel, openConnectorPanel: openConnectorPanel, closeConnectorPanel: closeConnectorPanel, selectedElement, selectedEdgeId, connectors, layers, setSelectedElement, setSelectedEdge, setSelectedEdgeId, setSelectedProxyConnectorDetails, openProxyConnectorPanel, closeProxyConnectorPanel, handleElementDeleted, handleElementPermanentlyDeleted, handleConnectorDeleted, handleUpdateTags, drawingCanvasRef, snapToGrid, onMoveStateChange, }: CanvasInteractionOptions): {
83
84
  canvasMenu: {
84
85
  x: number;
85
86
  y: number;
@@ -1,5 +1,4 @@
1
- import { type Edge as RFEdge, type Node as RFNode } from 'reactflow';
2
- import type { ViewTreeNode, PlacedElement, LibraryElement, Connector, IncomingViewConnector, ViewConnector, Tag } from '../../../types';
1
+ import type { ViewTreeNode, PlacedElement, LibraryElement, Connector, Tag } from '../../../types';
3
2
  interface ViewDataOptions {
4
3
  viewId: number | null;
5
4
  interactionSourceId: number | null;
@@ -37,38 +36,38 @@ interface ViewDataOptions {
37
36
  }
38
37
  export declare function useViewData({ viewId, interactionSourceId, clickConnectMode, selectedEdgeId, activeTags, hiddenLayerTags, hoveredLayerTags, hoveredLayerColor, tagColors, stableOnZoomIn, stableOnZoomOut, stableOnNavigateToView, stableOnSelect, stableOnOpenCodePreview, stableOnInteractionStart, stableOnConnectTo, stableOnStartHandleReconnect, stableOnRemoveElement, stableOnHoverZoom, hoveredZoomRef, }: ViewDataOptions): {
39
38
  view: ViewTreeNode | null | undefined;
40
- setView: import("react").Dispatch<import("react").SetStateAction<ViewTreeNode | null | undefined>>;
39
+ setView: (view: ViewTreeNode | null | undefined) => void;
41
40
  viewElements: PlacedElement[];
42
- setViewElements: import("react").Dispatch<import("react").SetStateAction<PlacedElement[]>>;
41
+ setViewElements: (next: import("../../../store/useStore").StoreSetter<PlacedElement[]>) => void;
43
42
  connectors: Connector[];
44
- setConnectors: import("react").Dispatch<import("react").SetStateAction<Connector[]>>;
45
- rfNodes: RFNode[];
46
- setRfNodes: import("react").Dispatch<import("react").SetStateAction<RFNode[]>>;
47
- rfEdges: RFEdge[];
48
- setRfEdges: import("react").Dispatch<import("react").SetStateAction<RFEdge[]>>;
49
- linksMap: Record<number, ViewConnector[]>;
50
- setLinksMap: import("react").Dispatch<import("react").SetStateAction<Record<number, ViewConnector[]>>>;
51
- parentLinksMap: Record<number, ViewConnector[]>;
52
- setParentLinksMap: import("react").Dispatch<import("react").SetStateAction<Record<number, ViewConnector[]>>>;
53
- incomingLinks: IncomingViewConnector[];
43
+ setConnectors: (next: import("../../../store/useStore").StoreSetter<Connector[]>) => void;
44
+ rfNodes: import("reactflow").Node[];
45
+ setRfNodes: (next: import("../../../store/useStore").StoreSetter<import("reactflow").Node[]>) => void;
46
+ rfEdges: import("reactflow").Edge[];
47
+ setRfEdges: (next: import("../../../store/useStore").StoreSetter<import("reactflow").Edge[]>) => void;
48
+ linksMap: Record<number, import("../../..").ViewConnector[]>;
49
+ setLinksMap: (next: import("../../../store/useStore").StoreSetter<Record<number, import("../../..").ViewConnector[]>>) => void;
50
+ parentLinksMap: Record<number, import("../../..").ViewConnector[]>;
51
+ setParentLinksMap: (next: import("../../../store/useStore").StoreSetter<Record<number, import("../../..").ViewConnector[]>>) => void;
52
+ incomingLinks: import("../../..").IncomingViewConnector[];
54
53
  treeData: ViewTreeNode[];
55
54
  allElements: LibraryElement[];
56
55
  libraryRefresh: number;
57
- setLibraryRefresh: import("react").Dispatch<import("react").SetStateAction<number>>;
56
+ setLibraryRefresh: (next: import("../../../store/useStore").StoreSetter<number>) => void;
58
57
  existingElementIds: Set<number>;
59
58
  viewElementsRef: import("react").MutableRefObject<PlacedElement[]>;
60
- linksMapRef: import("react").MutableRefObject<Record<number, ViewConnector[]>>;
61
- parentLinksMapRef: import("react").MutableRefObject<Record<number, ViewConnector[]>>;
62
- incomingLinksRef: import("react").MutableRefObject<IncomingViewConnector[]>;
59
+ linksMapRef: import("react").MutableRefObject<Record<number, import("../../..").ViewConnector[]>>;
60
+ parentLinksMapRef: import("react").MutableRefObject<Record<number, import("../../..").ViewConnector[]>>;
61
+ incomingLinksRef: import("react").MutableRefObject<import("../../..").IncomingViewConnector[]>;
63
62
  treeDataRef: import("react").MutableRefObject<ViewTreeNode[]>;
64
- rfNodesRef: import("react").MutableRefObject<RFNode[]>;
65
- rfEdgesRef: import("react").MutableRefObject<RFEdge[]>;
63
+ rfNodesRef: import("react").MutableRefObject<import("reactflow").Node[]>;
64
+ rfEdgesRef: import("react").MutableRefObject<import("reactflow").Edge[]>;
66
65
  viewIdRef: import("react").MutableRefObject<number | null>;
67
66
  refreshGrid: () => Promise<void>;
68
67
  refreshElements: () => Promise<void>;
69
68
  handleElementDeleted: (deletedId: number) => void;
70
69
  handleElementPermanentlyDeleted: (deletedId: number) => void;
71
70
  handleElementSaved: (saved: LibraryElement) => void;
72
- setAllElements: import("react").Dispatch<import("react").SetStateAction<LibraryElement[]>>;
71
+ setAllElements: (next: import("../../../store/useStore").StoreSetter<LibraryElement[]>) => void;
73
72
  };
74
73
  export {};
@@ -0,0 +1,2 @@
1
+ declare const _default: {};
2
+ export default _default;
@@ -0,0 +1,78 @@
1
+ import type { Edge as RFEdge, Node as RFNode } from 'reactflow';
2
+ import type { Connector, IncomingViewConnector, LibraryElement, PlacedElement, ViewConnector, ViewTreeNode } from '../types';
3
+ export type StoreSetter<T> = T | ((previous: T) => T);
4
+ export type ViewEditorUiState = {
5
+ viewId: number | null;
6
+ canEdit: boolean;
7
+ isOwner: boolean;
8
+ isFreePlan: boolean;
9
+ snapToGrid: boolean;
10
+ selectedElement: LibraryElement | null;
11
+ selectedConnector: Connector | null;
12
+ };
13
+ export type ViewContentLinks = {
14
+ linksMap: Record<number, ViewConnector[]>;
15
+ parentLinksMap: Record<number, ViewConnector[]>;
16
+ incomingLinks: IncomingViewConnector[];
17
+ };
18
+ export type ViewContentPayload = ViewContentLinks & {
19
+ view: ViewTreeNode | null;
20
+ viewElements: PlacedElement[];
21
+ connectors: Connector[];
22
+ treeData: ViewTreeNode[];
23
+ };
24
+ export type CanvasStoreState = ViewEditorUiState & {
25
+ view: ViewTreeNode | null | undefined;
26
+ viewElements: PlacedElement[];
27
+ connectors: Connector[];
28
+ nodes: RFNode[];
29
+ edges: RFEdge[];
30
+ linksMap: Record<number, ViewConnector[]>;
31
+ parentLinksMap: Record<number, ViewConnector[]>;
32
+ incomingLinks: IncomingViewConnector[];
33
+ treeData: ViewTreeNode[];
34
+ allElements: LibraryElement[];
35
+ libraryRefresh: number;
36
+ setViewEditorUi: (patch: Partial<ViewEditorUiState>) => void;
37
+ setSnapToGrid: (snapToGrid: boolean) => void;
38
+ setSelectedElement: (selectedElement: LibraryElement | null) => void;
39
+ setSelectedConnector: (selectedConnector: Connector | null) => void;
40
+ setView: (view: ViewTreeNode | null | undefined) => void;
41
+ setViewElements: (next: StoreSetter<PlacedElement[]>) => void;
42
+ setConnectors: (next: StoreSetter<Connector[]>) => void;
43
+ setNodes: (next: StoreSetter<RFNode[]>) => void;
44
+ setEdges: (next: StoreSetter<RFEdge[]>) => void;
45
+ setLinksMap: (next: StoreSetter<Record<number, ViewConnector[]>>) => void;
46
+ setParentLinksMap: (next: StoreSetter<Record<number, ViewConnector[]>>) => void;
47
+ setIncomingLinks: (next: StoreSetter<IncomingViewConnector[]>) => void;
48
+ setTreeData: (next: StoreSetter<ViewTreeNode[]>) => void;
49
+ setAllElements: (next: StoreSetter<LibraryElement[]>) => void;
50
+ setLibraryRefresh: (next: StoreSetter<number>) => void;
51
+ resetCanvas: () => void;
52
+ hydrateViewContent: (payload: ViewContentPayload) => void;
53
+ updateElementPosition: (elementId: number, x: number, y: number) => void;
54
+ removeElementPlacement: (elementId: number) => void;
55
+ removeElementEverywhere: (elementId: number) => void;
56
+ mergeSavedElement: (saved: LibraryElement) => void;
57
+ upsertConnector: (connector: Connector) => void;
58
+ replaceConnector: (connector: Connector) => void;
59
+ removeConnector: (connectorId: number) => void;
60
+ };
61
+ export declare const emptyViewEditorUiState: ViewEditorUiState;
62
+ export declare function findViewByOwner(nodes: ViewTreeNode[], elementId: number): ViewTreeNode | null;
63
+ export declare function findViewPath(nodes: ViewTreeNode[], targetId: number, path?: ViewTreeNode[]): ViewTreeNode[] | null;
64
+ export declare function buildViewContentLinks(tree: ViewTreeNode[], viewId: number, viewElements: PlacedElement[]): ViewContentLinks;
65
+ export declare function selectExistingElementIds(state: Pick<CanvasStoreState, 'viewElements'>): Set<number>;
66
+ export declare function selectElementById(state: Pick<CanvasStoreState, 'viewElements'>, elementId: number): PlacedElement | undefined;
67
+ export declare function selectConnectorById(state: Pick<CanvasStoreState, 'connectors'>, connectorId: number): Connector | undefined;
68
+ export declare function updatePlacedElementPosition(elements: PlacedElement[], elementId: number, x: number, y: number): PlacedElement[];
69
+ export declare function removePlacedElement(elements: PlacedElement[], elementId: number): PlacedElement[];
70
+ export declare function mergeSavedElementIntoPlacements(elements: PlacedElement[], saved: LibraryElement): PlacedElement[];
71
+ export declare function upsertConnectorInList(connectors: Connector[], connector: Connector): Connector[];
72
+ export declare function removeConnectorFromList(connectors: Connector[], connectorId: number): Connector[];
73
+ export declare const useStore: import("zustand").UseBoundStore<import("zustand").StoreApi<CanvasStoreState>>;
74
+ export declare const canvasSelectors: {
75
+ existingElementIds: typeof selectExistingElementIds;
76
+ elementById: typeof selectElementById;
77
+ connectorById: typeof selectConnectorById;
78
+ };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tldiagram/core-ui",
3
- "version": "1.92.0",
3
+ "version": "1.93.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,9 @@
38
38
  "dependencies": {
39
39
  "@buf/tldiagramcom_diagram.bufbuild_es": "^2.11.0-20260419172603-8192c519478e.1",
40
40
  "@bufbuild/protobuf": "^2.11.0",
41
- "esbuild": "^0.25.12"
41
+ "@tanstack/react-query": "^5.100.1",
42
+ "esbuild": "^0.25.12",
43
+ "zustand": "^5.0.12"
42
44
  },
43
45
  "peerDependencies": {
44
46
  "@chakra-ui/icons": "^2.2.4",
@@ -59,7 +61,7 @@
59
61
  "@emotion/styled": "^11.14.0",
60
62
  "@uiw/react-codemirror": "^4.25.8",
61
63
  "d3-force": "^3.0.0",
62
- "elkjs": "^0.11.1",
64
+ "dagre": "^0.8.5",
63
65
  "framer-motion": "^11.18.2",
64
66
  "html-to-image": "^1.11.13",
65
67
  "mermaid-ast": "^0.8.2",
@@ -125,12 +127,13 @@
125
127
  "@emotion/react": "^11.14.0",
126
128
  "@emotion/styled": "^11.14.0",
127
129
  "@eslint/js": "^9.0.0",
130
+ "@types/dagre": "^0.7.54",
128
131
  "@types/react": "^18.3.12",
129
132
  "@types/react-dom": "^18.3.1",
130
133
  "@uiw/react-codemirror": "^4.25.8",
131
134
  "@vitejs/plugin-react": "^4.3.4",
132
135
  "d3-force": "^3.0.0",
133
- "elkjs": "^0.11.1",
136
+ "dagre": "^0.8.5",
134
137
  "eslint": "^9.0.0",
135
138
  "eslint-plugin-react-hooks": "^5.0.0",
136
139
  "eslint-plugin-react-refresh": "^0.4.0",
package/src/api/client.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  ImportService,
47
47
  } from '@buf/tldiagramcom_diagram.bufbuild_es/diag/v1/import_service_pb'
48
48
  import { transport } from './transport'
49
+ import { apiUrl, fetchApiAsset } from '../config/runtime'
49
50
 
50
51
  export interface DependenciesResponse {
51
52
  elements: DependencyElement[]
@@ -383,6 +384,36 @@ export const api = {
383
384
  return (json.views ?? []).map(mapDiagram)
384
385
  }),
385
386
 
387
+ // Lazy: root-level views only. Use for sidebar first render on huge workspaces.
388
+ treeRoots: (opts: { limit?: number; offset?: number; search?: string } = {}): Promise<{ views: ViewTreeNode[]; totalCount: number }> =>
389
+ rpc(async () => {
390
+ const res = await workspaceClient.getWorkspace({
391
+ includeContent: false,
392
+ level: 0,
393
+ limit: opts.limit ?? 0,
394
+ offset: opts.offset ?? 0,
395
+ search: opts.search ?? '',
396
+ })
397
+ const json = j<{ views: ProtoDiagram[]; total_count?: number }>(GetWorkspaceResponseSchema, res)
398
+ return {
399
+ views: (json.views ?? []).map(mapDiagram),
400
+ totalCount: Number(json.total_count ?? 0),
401
+ }
402
+ }),
403
+
404
+ // Lazy: direct children of a parent view. Used on tree node expand.
405
+ treeChildren: (parentId: number, opts: { limit?: number; offset?: number } = {}): Promise<ViewTreeNode[]> =>
406
+ rpc(async () => {
407
+ const res = await workspaceClient.getWorkspace({
408
+ includeContent: false,
409
+ parentId,
410
+ limit: opts.limit ?? 0,
411
+ offset: opts.offset ?? 0,
412
+ })
413
+ const json = j<{ views: ProtoDiagram[] }>(GetWorkspaceResponseSchema, res)
414
+ return (json.views ?? []).map(mapDiagram)
415
+ }),
416
+
386
417
  get: (id: number): Promise<ViewTreeNode> =>
387
418
  rpc(async () => {
388
419
  const res = await workspaceClient.getView({ viewId: id })
@@ -422,7 +453,14 @@ export const api = {
422
453
  delete: (_orgId: string, id: number): Promise<void> =>
423
454
  rpc(async () => { await workspaceClient.deleteView({ orgId: '', viewId: id }) }),
424
455
 
425
- thumbnail: async (_id: number): Promise<string | null> => null,
456
+ thumbnail: async (id: number): Promise<string | null> => {
457
+ const res = await fetchApiAsset(apiUrl(`/views/${id}/thumbnail.svg`), {
458
+ headers: { Accept: 'image/svg+xml' },
459
+ })
460
+ if (!res.ok) return null
461
+ const svg = await res.text()
462
+ return URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }))
463
+ },
426
464
 
427
465
  placements: {
428
466
  list: (diagramId: number): Promise<ElementPlacement[]> =>
@@ -12,9 +12,6 @@ import { ZoomInIcon, ZoomOutIcon, TrashIcon as TrashSvg, EditIcon as EditSvg } f
12
12
  import { vscodeBridge } from '../lib/vscodeBridge'
13
13
  import type { ExtensionToWebviewMessage } from '../types/vscode-messages'
14
14
  import {
15
- DEFAULT_SOURCE_HANDLE_SIDE,
16
- DEFAULT_TARGET_HANDLE_SIDE,
17
- ensureVisualHandleId,
18
15
  getVisualHandleId,
19
16
  getVisualHandleStyle,
20
17
  HANDLE_SLOT_CENTER_INDEX,
@@ -154,6 +151,10 @@ interface NodeData extends PlacedElement {
154
151
  layerHighlightColor?: string
155
152
  forceShowTagPopup?: boolean
156
153
  isCanvasMoving?: boolean
154
+ connectedHandleIds?: readonly string[]
155
+ selectedHandleIds?: readonly string[]
156
+ reconnectCandidates?: readonly { handleId: string; edgeId: string; endpoint: 'source' | 'target'; selected: boolean }[]
157
+ isConnectorHighlighted?: boolean
157
158
  }
158
159
 
159
160
  interface Props {
@@ -285,61 +286,13 @@ function ElementNode({ data, selected }: Props) {
285
286
  const zoom = useStore(zoomSelector)
286
287
  useAccentColor()
287
288
 
288
- const nodeId = String(data.element_id)
289
- const isConnectorHighlighted = useStore((s) =>
290
- s.edges.some(e => e.selected && (e.source === nodeId || e.target === nodeId))
291
- )
292
- const { connectedHandleKey, selectedHandleKey } = useStore((s) => {
293
- const connectedHandles = new Set<string>()
294
- const selectedHandles = new Set<string>()
295
- for (const connector of s.edges) {
296
- if (connector.source === nodeId) {
297
- const targetNode = s.nodeInternals.get(connector.target)
298
- if (targetNode && targetNode.type !== 'ContextBoundaryElement' && targetNode.type !== 'contextNeighborNode') {
299
- const handleId = ensureVisualHandleId(connector.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE)
300
- if (handleId) {
301
- connectedHandles.add(handleId)
302
- if (connector.selected) selectedHandles.add(handleId)
303
- }
304
- }
305
- }
306
- if (connector.target === nodeId) {
307
- const sourceNode = s.nodeInternals.get(connector.source)
308
- if (sourceNode && sourceNode.type !== 'ContextBoundaryElement' && sourceNode.type !== 'contextNeighborNode') {
309
- const handleId = ensureVisualHandleId(connector.targetHandle, DEFAULT_TARGET_HANDLE_SIDE)
310
- if (handleId) {
311
- connectedHandles.add(handleId)
312
- if (connector.selected) selectedHandles.add(handleId)
313
- }
314
- }
315
- }
316
- }
317
- return {
318
- connectedHandleKey: Array.from(connectedHandles).sort().join('|'),
319
- selectedHandleKey: Array.from(selectedHandles).sort().join('|'),
320
- }
321
- })
322
- const handleReconnectCandidates = useStore((s) => {
323
- const candidates: Array<{ handleId: string; edgeId: string; endpoint: 'source' | 'target'; selected: boolean }> = []
324
- for (const connector of s.edges) {
325
- if (connector.source === nodeId) {
326
- const handleId = ensureVisualHandleId(connector.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE)
327
- if (handleId) candidates.push({ handleId, edgeId: connector.id, endpoint: 'source', selected: !!connector.selected })
328
- }
329
- if (connector.target === nodeId) {
330
- const handleId = ensureVisualHandleId(connector.targetHandle, DEFAULT_TARGET_HANDLE_SIDE)
331
- if (handleId) candidates.push({ handleId, edgeId: connector.id, endpoint: 'target', selected: !!connector.selected })
332
- }
333
- }
334
- return candidates
335
- })
336
289
  const connectedHandleIds = useMemo(
337
- () => new Set(connectedHandleKey ? connectedHandleKey.split('|') : []),
338
- [connectedHandleKey],
290
+ () => new Set(data.connectedHandleIds ?? []),
291
+ [data.connectedHandleIds],
339
292
  )
340
293
  const selectedHandleIds = useMemo(
341
- () => new Set(selectedHandleKey ? selectedHandleKey.split('|') : []),
342
- [selectedHandleKey],
294
+ () => new Set(data.selectedHandleIds ?? []),
295
+ [data.selectedHandleIds],
343
296
  )
344
297
  const activeSides = useMemo(() => {
345
298
  const sides = new Set<string>()
@@ -351,7 +304,7 @@ function ElementNode({ data, selected }: Props) {
351
304
  }, [connectedHandleIds])
352
305
  const reconnectCandidateByHandle = useMemo(() => {
353
306
  const next = new Map<string, { edgeId: string; endpoint: 'source' | 'target' }>()
354
- const sortedCandidates = [...handleReconnectCandidates].sort((left, right) => {
307
+ const sortedCandidates = [...(data.reconnectCandidates ?? [])].sort((left, right) => {
355
308
  if (left.selected !== right.selected) return left.selected ? -1 : 1
356
309
  return left.edgeId.localeCompare(right.edgeId)
357
310
  })
@@ -361,7 +314,7 @@ function ElementNode({ data, selected }: Props) {
361
314
  }
362
315
  }
363
316
  return next
364
- }, [handleReconnectCandidates])
317
+ }, [data.reconnectCandidates])
365
318
 
366
319
  const derivedPrimaryIconPath = (() => {
367
320
  const selected = data.technology_connectors?.find((link) => link.type === 'catalog' && !!link.is_primary_icon && !!link.slug)
@@ -509,7 +462,7 @@ function ElementNode({ data, selected }: Props) {
509
462
  isSelected={selected}
510
463
  isSource={isSource}
511
464
  isTarget={isTarget}
512
- isConnectorHighlighted={isConnectorHighlighted}
465
+ isConnectorHighlighted={!!data.isConnectorHighlighted}
513
466
  minW="180px"
514
467
  maxW="230px"
515
468
  cursor={bodyCursor}
@@ -480,7 +480,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
480
480
  try {
481
481
  if (viewId != null) {
482
482
  await api.workspace.views.placements.remove(viewId, element.id)
483
- } else if (orgId) {
483
+ } else if (orgId !== undefined) {
484
484
  await api.elements.delete(orgId, element.id)
485
485
  }
486
486
  onDelete?.(element.id)
@@ -491,7 +491,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
491
491
  const handlePermanentDelete = async () => {
492
492
  if (isReadOnly || !element) return
493
493
  try {
494
- if (!orgId) return
494
+ if (orgId === undefined) return
495
495
  await api.elements.delete(orgId, element.id)
496
496
  onPermanentDelete?.(element.id)
497
497
  confirmPermanentDelete.onClose()
@@ -21,11 +21,10 @@ import { ChevronDownIcon, ChevronRightIcon } from './Icons'
21
21
  import { api } from '../api/client'
22
22
  import type { ViewTreeNode } from '../types'
23
23
 
24
- type Algorithm = 'elk' | 'force'
24
+ type Algorithm = 'dagre' | 'force'
25
25
 
26
- interface ElkConfig {
27
- algorithm: 'layered' | 'force' | 'mrtree' | 'box'
28
- direction: 'RIGHT' | 'LEFT' | 'DOWN' | 'UP'
26
+ interface DagreConfig {
27
+ direction: 'TB' | 'BT' | 'LR' | 'RL'
29
28
  nodeSpacing: number
30
29
  layerSpacing: number
31
30
  }
@@ -41,7 +40,7 @@ const NODE_W = 200
41
40
  const NODE_H = 120
42
41
 
43
42
  const ALGO_META: Record<Algorithm, { label: string }> = {
44
- elk: { label: 'Layered' },
43
+ dagre: { label: 'Layered' },
45
44
  force: { label: 'Organic' },
46
45
  }
47
46
 
@@ -52,13 +51,12 @@ interface Props {
52
51
 
53
52
  export default function LayoutSection({ view, canEdit }: Props) {
54
53
  const [open, setOpen] = useState(false)
55
- const [algo, setAlgo] = useState<Algorithm>('elk')
54
+ const [algo, setAlgo] = useState<Algorithm>('dagre')
56
55
  const [running, setRunning] = useState(false)
57
56
  const [collisionRunning, setCollisionRunning] = useState(false)
58
57
 
59
- const [elkConfig, setElkConfig] = useState<ElkConfig>({
60
- algorithm: 'layered',
61
- direction: 'DOWN',
58
+ const [dagreConfig, setDagreConfig] = useState<DagreConfig>({
59
+ direction: 'TB',
62
60
  nodeSpacing: 75,
63
61
  layerSpacing: 75,
64
62
  })
@@ -199,8 +197,8 @@ export default function LayoutSection({ view, canEdit }: Props) {
199
197
  ])
200
198
 
201
199
  let positions: Map<number, { x: number; y: number }>
202
- if (algo === 'elk') {
203
- positions = await runElk(objs, edgeList)
200
+ if (algo === 'dagre') {
201
+ positions = await runDagre(objs, edgeList)
204
202
  } else {
205
203
  positions = await runForce(objs, edgeList)
206
204
  }
@@ -271,46 +269,45 @@ export default function LayoutSection({ view, canEdit }: Props) {
271
269
  }
272
270
 
273
271
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
274
- const runElk = async (objs: any[], edgeList: any[]) => {
272
+ const runDagre = async (objs: any[], edgeList: any[]) => {
275
273
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
- const ELKModule = await import('elkjs/lib/elk.bundled.js') as any
277
- const elk = new ELKModule.default()
278
-
279
- const layoutOptions: Record<string, string> = {
280
- 'elk.algorithm': elkConfig.algorithm,
281
- 'elk.spacing.nodeNode': String(elkConfig.nodeSpacing),
282
- }
283
- if (elkConfig.algorithm === 'layered') {
284
- layoutOptions['elk.direction'] = elkConfig.direction
285
- layoutOptions['elk.layered.spacing.nodeNodeBetweenLayers'] = String(elkConfig.layerSpacing)
286
- }
274
+ const dagreModule = await import('dagre') as any
275
+ const dagre = dagreModule.default ?? dagreModule
287
276
 
288
277
  const objSet = new Set<number>(objs.map((o: { element_id?: number }) => Number(o.element_id)))
289
- const graph = {
290
- id: 'root',
291
- layoutOptions,
292
- children: objs.map((obj: { element_id: number }) => ({
293
- id: String(obj.element_id),
294
- width: NODE_W,
295
- height: NODE_H,
296
- })),
297
- edges: edgeList
298
- .filter((e: { source_element_id: number; target_element_id: number }) =>
299
- objSet.has(e.source_element_id) && objSet.has(e.target_element_id)
300
- )
301
- .map((e: { id: number; source_element_id: number; target_element_id: number }) => ({
302
- id: String(e.id),
303
- sources: [String(e.source_element_id)],
304
- targets: [String(e.target_element_id)],
305
- })),
306
- }
278
+ const graph = new dagre.graphlib.Graph({ multigraph: true })
279
+ graph.setGraph({
280
+ rankdir: dagreConfig.direction,
281
+ nodesep: dagreConfig.nodeSpacing,
282
+ ranksep: dagreConfig.layerSpacing,
283
+ marginx: 0,
284
+ marginy: 0,
285
+ })
286
+ graph.setDefaultEdgeLabel(() => ({}))
287
+
288
+ objs.forEach((obj: { element_id: number }) => {
289
+ graph.setNode(String(obj.element_id), { width: NODE_W, height: NODE_H })
290
+ })
291
+
292
+ edgeList
293
+ .filter((e: { source_element_id: number; target_element_id: number }) =>
294
+ objSet.has(e.source_element_id) && objSet.has(e.target_element_id)
295
+ )
296
+ .forEach((e: { id: number; source_element_id: number; target_element_id: number }) => {
297
+ graph.setEdge(String(e.source_element_id), String(e.target_element_id), {}, String(e.id))
298
+ })
299
+
300
+ dagre.layout(graph)
307
301
 
308
- const result = await elk.layout(graph)
309
302
  const positions = new Map<number, { x: number; y: number }>()
310
- result.children?.forEach((child: { id: string; x?: number; y?: number }) => {
311
- const id = Number(child.id)
303
+ graph.nodes().forEach((nodeId: string) => {
304
+ const id = Number(nodeId)
312
305
  if (!Number.isFinite(id)) return
313
- positions.set(id, { x: child.x ?? 0, y: child.y ?? 0 })
306
+ const node = graph.node(nodeId) as { x?: number; y?: number }
307
+ positions.set(id, {
308
+ x: (node.x ?? 0) - NODE_W / 2,
309
+ y: (node.y ?? 0) - NODE_H / 2,
310
+ })
314
311
  })
315
312
  return positions
316
313
  }
@@ -428,54 +425,34 @@ export default function LayoutSection({ view, canEdit }: Props) {
428
425
  border="1px solid"
429
426
  borderColor="whiteAlpha.100"
430
427
  >
431
- {algo === 'elk' ? (
428
+ {algo === 'dagre' ? (
432
429
  <Grid templateColumns="1fr 1fr" gap={4}>
433
430
  <FormControl gridColumn="span 2">
434
- <FormLabel {...LabelStyle}>Algorithm</FormLabel>
431
+ <FormLabel {...LabelStyle}>Direction</FormLabel>
435
432
  <Select
436
433
  size="xs"
437
434
  variant="filled"
438
435
  bg="whiteAlpha.100"
439
436
  border="none"
440
437
  _hover={{ bg: 'whiteAlpha.200' }}
441
- value={elkConfig.algorithm}
442
- onChange={e => setElkConfig(c => ({ ...c, algorithm: e.target.value as ElkConfig['algorithm'] }))}
438
+ value={dagreConfig.direction}
439
+ onChange={e => setDagreConfig(c => ({ ...c, direction: e.target.value as DagreConfig['direction'] }))}
443
440
  >
444
- <option value="layered">Layered</option>
445
- <option value="force">Force</option>
446
- <option value="mrtree">Mr. Tree</option>
447
- <option value="box">Box</option>
441
+ <option value="TB">Top → Bottom</option>
442
+ <option value="BT">Bottom → Top</option>
443
+ <option value="LR">Left → Right</option>
444
+ <option value="RL">Right → Left</option>
448
445
  </Select>
449
446
  </FormControl>
450
447
 
451
- {elkConfig.algorithm === 'layered' && (
452
- <FormControl gridColumn="span 2">
453
- <FormLabel {...LabelStyle}>Direction</FormLabel>
454
- <Select
455
- size="xs"
456
- variant="filled"
457
- bg="whiteAlpha.100"
458
- border="none"
459
- _hover={{ bg: 'whiteAlpha.200' }}
460
- value={elkConfig.direction}
461
- onChange={e => setElkConfig(c => ({ ...c, direction: e.target.value as ElkConfig['direction'] }))}
462
- >
463
- <option value="DOWN">Top → Bottom</option>
464
- <option value="UP">Bottom → Top</option>
465
- <option value="RIGHT">Left → Right</option>
466
- <option value="LEFT">Right → Left</option>
467
- </Select>
468
- </FormControl>
469
- )}
470
-
471
448
  <FormControl>
472
449
  <FormLabel {...LabelStyle}>Element Gap</FormLabel>
473
450
  <NumberInput
474
451
  size="xs"
475
452
  variant="filled"
476
- value={elkConfig.nodeSpacing}
453
+ value={dagreConfig.nodeSpacing}
477
454
  min={10} max={400} step={10}
478
- onChange={(_, v) => !isNaN(v) && setElkConfig(c => ({ ...c, nodeSpacing: v }))}
455
+ onChange={(_, v) => !isNaN(v) && setDagreConfig(c => ({ ...c, nodeSpacing: v }))}
479
456
  >
480
457
  <NumberInputField bg="whiteAlpha.100" border="none" />
481
458
  <NumberInputStepper>
@@ -485,24 +462,22 @@ export default function LayoutSection({ view, canEdit }: Props) {
485
462
  </NumberInput>
486
463
  </FormControl>
487
464
 
488
- {elkConfig.algorithm === 'layered' && (
489
- <FormControl>
490
- <FormLabel {...LabelStyle}>Layer Gap</FormLabel>
491
- <NumberInput
492
- size="xs"
493
- variant="filled"
494
- value={elkConfig.layerSpacing}
495
- min={10} max={400} step={10}
496
- onChange={(_, v) => !isNaN(v) && setElkConfig(c => ({ ...c, layerSpacing: v }))}
497
- >
498
- <NumberInputField bg="whiteAlpha.100" border="none" />
499
- <NumberInputStepper>
500
- <NumberIncrementStepper border="none" />
501
- <NumberDecrementStepper border="none" />
502
- </NumberInputStepper>
503
- </NumberInput>
504
- </FormControl>
505
- )}
465
+ <FormControl>
466
+ <FormLabel {...LabelStyle}>Layer Gap</FormLabel>
467
+ <NumberInput
468
+ size="xs"
469
+ variant="filled"
470
+ value={dagreConfig.layerSpacing}
471
+ min={10} max={400} step={10}
472
+ onChange={(_, v) => !isNaN(v) && setDagreConfig(c => ({ ...c, layerSpacing: v }))}
473
+ >
474
+ <NumberInputField bg="whiteAlpha.100" border="none" />
475
+ <NumberInputStepper>
476
+ <NumberIncrementStepper border="none" />
477
+ <NumberDecrementStepper border="none" />
478
+ </NumberInputStepper>
479
+ </NumberInput>
480
+ </FormControl>
506
481
  </Grid>
507
482
  ) : (
508
483
  <Grid templateColumns="1fr 1fr" gap={4}>