@tldiagram/core-ui 2.0.1 → 2.0.3

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.
@@ -3,6 +3,11 @@
3
3
  import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
4
4
  import type { BBox, DiagramGroupLayout, LayoutNode, ZUIViewState, HoveredItem } from './types'
5
5
  import { getExpandThresholds, screenToWorldX, screenToWorldY, viewOriginX, viewOriginY } from './renderer'
6
+ import {
7
+ DEFAULT_SOURCE_HANDLE_SIDE,
8
+ DEFAULT_TARGET_HANDLE_SIDE,
9
+ getHandleFlowPosition,
10
+ } from '../../utils/edgeDistribution'
6
11
 
7
12
  export function constrainViewState(view: ZUIViewState, canvasW: number, canvasH: number, bbox: BBox): ZUIViewState {
8
13
  const padding = Math.min(600, canvasW * 0.45, canvasH * 0.45)
@@ -46,6 +51,8 @@ interface DeepestNodeResult {
46
51
  cumulativeScale: number
47
52
  }
48
53
 
54
+ type EdgeRoutePoint = { x: number; y: number }
55
+
49
56
  interface NodeSpatialIndex {
50
57
  cellSize: number
51
58
  cells: Map<string, LayoutNode[]>
@@ -194,6 +201,7 @@ type IndexedEdge =
194
201
  y2: number
195
202
  midX: number
196
203
  midY: number
204
+ points: EdgeRoutePoint[]
197
205
  sourceLabel: string
198
206
  targetLabel: string
199
207
  label: string
@@ -209,6 +217,7 @@ type IndexedEdge =
209
217
  y2: number
210
218
  midX: number
211
219
  midY: number
220
+ points: EdgeRoutePoint[]
212
221
  sourceLabel: string
213
222
  targetLabel: string
214
223
  diagramId: number
@@ -227,10 +236,11 @@ function cellKey(cx: number, cy: number): string {
227
236
  }
228
237
 
229
238
  function addEdgeToSpatialIndex(index: EdgeSpatialIndex, edge: IndexedEdge): void {
230
- const minX = Math.min(edge.x1, edge.x2)
231
- const maxX = Math.max(edge.x1, edge.x2)
232
- const minY = Math.min(edge.y1, edge.y2)
233
- const maxY = Math.max(edge.y1, edge.y2)
239
+ const points = edge.points.length > 0 ? edge.points : [{ x: edge.x1, y: edge.y1 }, { x: edge.x2, y: edge.y2 }]
240
+ const minX = Math.min(...points.map((point) => point.x))
241
+ const maxX = Math.max(...points.map((point) => point.x))
242
+ const minY = Math.min(...points.map((point) => point.y))
243
+ const maxY = Math.max(...points.map((point) => point.y))
234
244
  const startX = Math.floor(minX / index.cellSize)
235
245
  const endX = Math.floor(maxX / index.cellSize)
236
246
  const startY = Math.floor(minY / index.cellSize)
@@ -249,6 +259,128 @@ function addEdgeToSpatialIndex(index: EdgeSpatialIndex, edge: IndexedEdge): void
249
259
  }
250
260
  }
251
261
 
262
+ function normalizeEdgeRouteType(type: string | null | undefined): 'bezier' | 'straight' | 'step' | 'smoothstep' {
263
+ if (type === 'straight' || type === 'step' || type === 'smoothstep') return type
264
+ return 'bezier'
265
+ }
266
+
267
+ function cubicBezierPoint(
268
+ p0: EdgeRoutePoint,
269
+ p1: EdgeRoutePoint,
270
+ p2: EdgeRoutePoint,
271
+ p3: EdgeRoutePoint,
272
+ t: number,
273
+ ): EdgeRoutePoint {
274
+ const mt = 1 - t
275
+ const mt2 = mt * mt
276
+ const t2 = t * t
277
+ return {
278
+ x: mt2 * mt * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t2 * t * p3.x,
279
+ y: mt2 * mt * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t2 * t * p3.y,
280
+ }
281
+ }
282
+
283
+ function buildEdgeRoutePoints(
284
+ source: LayoutNode,
285
+ target: LayoutNode,
286
+ edge: DiagramGroupLayout['edges'][number],
287
+ ): { points: EdgeRoutePoint[]; midX: number; midY: number } {
288
+ const sourcePoint = getHandleFlowPosition(
289
+ source.worldX,
290
+ source.worldY,
291
+ source.worldW,
292
+ source.worldH,
293
+ edge.sourceHandle,
294
+ DEFAULT_SOURCE_HANDLE_SIDE,
295
+ )
296
+ const targetPoint = getHandleFlowPosition(
297
+ target.worldX,
298
+ target.worldY,
299
+ target.worldW,
300
+ target.worldH,
301
+ edge.targetHandle,
302
+ DEFAULT_TARGET_HANDLE_SIDE,
303
+ )
304
+ const type = normalizeEdgeRouteType(edge.type)
305
+
306
+ if (type === 'bezier') {
307
+ const dx = Math.abs(targetPoint.x - sourcePoint.x)
308
+ const dy = Math.abs(targetPoint.y - sourcePoint.y)
309
+ const sourceStem = Math.max(
310
+ (sourcePoint.side === 'left' || sourcePoint.side === 'right' ? dx : dy) * 0.5,
311
+ (sourcePoint.side === 'left' || sourcePoint.side === 'right' ? source.worldW : source.worldH) * 0.5,
312
+ )
313
+ const targetStem = Math.max(
314
+ (targetPoint.side === 'left' || targetPoint.side === 'right' ? dx : dy) * 0.5,
315
+ (targetPoint.side === 'left' || targetPoint.side === 'right' ? target.worldW : target.worldH) * 0.5,
316
+ )
317
+ const cp1 = {
318
+ x: sourcePoint.x + (sourcePoint.side === 'left' ? -sourceStem : sourcePoint.side === 'right' ? sourceStem : 0),
319
+ y: sourcePoint.y + (sourcePoint.side === 'top' ? -sourceStem : sourcePoint.side === 'bottom' ? sourceStem : 0),
320
+ }
321
+ const cp2 = {
322
+ x: targetPoint.x + (targetPoint.side === 'left' ? -targetStem : targetPoint.side === 'right' ? targetStem : 0),
323
+ y: targetPoint.y + (targetPoint.side === 'top' ? -targetStem : targetPoint.side === 'bottom' ? targetStem : 0),
324
+ }
325
+ const p0 = { x: sourcePoint.x, y: sourcePoint.y }
326
+ const p3 = { x: targetPoint.x, y: targetPoint.y }
327
+ const points = Array.from({ length: 17 }, (_, index) => cubicBezierPoint(p0, cp1, cp2, p3, index / 16))
328
+ const mid = cubicBezierPoint(p0, cp1, cp2, p3, 0.5)
329
+ return { points, midX: mid.x, midY: mid.y }
330
+ }
331
+
332
+ if (type === 'step' || type === 'smoothstep') {
333
+ const midX = (sourcePoint.x + targetPoint.x) / 2
334
+ const midY = (sourcePoint.y + targetPoint.y) / 2
335
+ const sourceOrth = sourcePoint.side === 'left' || sourcePoint.side === 'right' ? 'h' : 'v'
336
+ const targetOrth = targetPoint.side === 'left' || targetPoint.side === 'right' ? 'h' : 'v'
337
+ const points: EdgeRoutePoint[] = [{ x: sourcePoint.x, y: sourcePoint.y }]
338
+
339
+ if (sourceOrth === 'h' && targetOrth === 'h') {
340
+ points.push({ x: midX, y: sourcePoint.y }, { x: midX, y: targetPoint.y })
341
+ } else if (sourceOrth === 'v' && targetOrth === 'v') {
342
+ points.push({ x: sourcePoint.x, y: midY }, { x: targetPoint.x, y: midY })
343
+ } else if (sourceOrth === 'h' && targetOrth === 'v') {
344
+ points.push({ x: targetPoint.x, y: sourcePoint.y })
345
+ } else {
346
+ points.push({ x: sourcePoint.x, y: targetPoint.y })
347
+ }
348
+ points.push({ x: targetPoint.x, y: targetPoint.y })
349
+ const midIndex = Math.floor((points.length - 1) / 2)
350
+ return {
351
+ points,
352
+ midX: (points[midIndex].x + points[midIndex + 1].x) / 2,
353
+ midY: (points[midIndex].y + points[midIndex + 1].y) / 2,
354
+ }
355
+ }
356
+
357
+ const points = [{ x: sourcePoint.x, y: sourcePoint.y }, { x: targetPoint.x, y: targetPoint.y }]
358
+ return {
359
+ points,
360
+ midX: (sourcePoint.x + targetPoint.x) / 2,
361
+ midY: (sourcePoint.y + targetPoint.y) / 2,
362
+ }
363
+ }
364
+
365
+ function nearestDistanceSquaredToRoute(worldX: number, worldY: number, points: EdgeRoutePoint[]): number {
366
+ let best = Number.POSITIVE_INFINITY
367
+ for (let index = 1; index < points.length; index++) {
368
+ const start = points[index - 1]
369
+ const end = points[index]
370
+ const dx = end.x - start.x
371
+ const dy = end.y - start.y
372
+ const lengthSquared = dx * dx + dy * dy
373
+ if (lengthSquared === 0) continue
374
+
375
+ let t = ((worldX - start.x) * dx + (worldY - start.y) * dy) / lengthSquared
376
+ t = Math.max(0, Math.min(1, t))
377
+ const nearestX = start.x + t * dx
378
+ const nearestY = start.y + t * dy
379
+ best = Math.min(best, (worldX - nearestX) ** 2 + (worldY - nearestY) ** 2)
380
+ }
381
+ return best
382
+ }
383
+
252
384
  function buildEdgeSpatialIndex(groups: DiagramGroupLayout[]): EdgeSpatialIndex {
253
385
  const index: EdgeSpatialIndex = { cellSize: EDGE_INDEX_CELL_SIZE, cells: new Map() }
254
386
 
@@ -263,18 +395,18 @@ function buildEdgeSpatialIndex(groups: DiagramGroupLayout[]): EdgeSpatialIndex {
263
395
  const target = nodeMap.get(edge.targetId)
264
396
  if (!source || !target) continue
265
397
 
266
- const x1 = source.worldX + source.worldW / 2
267
- const y1 = source.worldY + source.worldH / 2
268
- const x2 = target.worldX + target.worldW / 2
269
- const y2 = target.worldY + target.worldH / 2
398
+ const route = buildEdgeRoutePoints(source, target, edge)
399
+ const first = route.points[0]
400
+ const last = route.points[route.points.length - 1]
270
401
  addEdgeToSpatialIndex(index, {
271
402
  kind: 'edge',
272
- x1,
273
- y1,
274
- x2,
275
- y2,
276
- midX: (x1 + x2) / 2,
277
- midY: (y1 + y2) / 2,
403
+ x1: first.x,
404
+ y1: first.y,
405
+ x2: last.x,
406
+ y2: last.y,
407
+ midX: route.midX,
408
+ midY: route.midY,
409
+ points: route.points,
278
410
  sourceLabel: source.label,
279
411
  targetLabel: target.label,
280
412
  label: edge.label || 'Connection',
@@ -298,6 +430,7 @@ function buildEdgeSpatialIndex(groups: DiagramGroupLayout[]): EdgeSpatialIndex {
298
430
  y2,
299
431
  midX: (x1 + x2) / 2,
300
432
  midY: (y1 + y2) / 2,
433
+ points: [{ x: x1, y: y1 }, { x: x2, y: y2 }],
301
434
  sourceLabel: group.label,
302
435
  targetLabel: node.label,
303
436
  diagramId: group.diagramId,
@@ -330,17 +463,7 @@ function findHoveredEdge(
330
463
  if (!bucket) continue
331
464
 
332
465
  for (const edge of bucket) {
333
- const dx = edge.x2 - edge.x1
334
- const dy = edge.y2 - edge.y1
335
- const l2 = dx * dx + dy * dy
336
- if (l2 === 0) continue
337
-
338
- let t = ((worldX - edge.x1) * dx + (worldY - edge.y1) * dy) / l2
339
- t = Math.max(0, Math.min(1, t))
340
-
341
- const nearestX = edge.x1 + t * dx
342
- const nearestY = edge.y1 + t * dy
343
- const distSquared = (worldX - nearestX) ** 2 + (worldY - nearestY) ** 2
466
+ const distSquared = nearestDistanceSquaredToRoute(worldX, worldY, edge.points)
344
467
  if (distSquared < bestDistSquared) {
345
468
  bestDistSquared = distSquared
346
469
  bestEdge = edge
@@ -552,6 +552,8 @@ export interface ZUIHiddenProxyBadge {
552
552
  targetAnchorElementId: number
553
553
  sourceNodeId: string
554
554
  targetNodeId: string
555
+ sourceHandle: string | null
556
+ targetHandle: string | null
555
557
  count: number
556
558
  details: ProxyConnectorDetails
557
559
  }
@@ -754,13 +756,13 @@ export function resolveZUIProxyConnectors(
754
756
  return candidates
755
757
  }
756
758
  const grouped = new Map<string, ProxyConnectorLeaf[]>()
757
- const nativeVisiblePairs = new Set<string>()
759
+ const nativeVisiblePairs = new Map<string, Connector>()
758
760
 
759
761
  for (const connector of connectors) {
760
762
  if (!visibleElements.has(connector.source_element_id) || !visibleElements.has(connector.target_element_id)) continue
761
763
  if (!isNativelyRenderedInZUI(connector, connector.source_element_id, connector.target_element_id, visibleNodeIdsByElementId)) continue
762
764
  const [leftAnchorElementId, rightAnchorElementId] = canonicalPairElements(connector.source_element_id, connector.target_element_id)
763
- nativeVisiblePairs.add([leftAnchorElementId, rightAnchorElementId].join('::'))
765
+ nativeVisiblePairs.set([leftAnchorElementId, rightAnchorElementId].join('::'), connector)
764
766
  }
765
767
 
766
768
  for (const connector of connectors) {
@@ -871,15 +873,22 @@ export function resolveZUIProxyConnectors(
871
873
  endpointCandidates(leaf.ownerViewId, leaf.target.actualElementId)[0]?.anchorElementId === leaf.target.anchorElementId
872
874
  return sourceOk && targetOk
873
875
  })
874
- const pairHasNativeDirect = nativeVisiblePairs.has(key)
875
- if (pairHasNativeDirect) {
876
+ const nativeDirectConnector = nativeVisiblePairs.get(key)
877
+ if (nativeDirectConnector) {
876
878
  if (isDirectChildBadgeOnly) {
879
+ const nativeSourceIsCanonical = nativeDirectConnector.source_element_id === canonicalSourceAnchorElementId
877
880
  hiddenBadges.push({
878
881
  key: `badge:${key}`,
879
882
  sourceAnchorElementId: canonicalSourceAnchorElementId,
880
883
  targetAnchorElementId: canonicalTargetAnchorElementId,
881
884
  sourceNodeId: visibleNodeIdsByElementId.get(canonicalSourceAnchorElementId) ?? '',
882
885
  targetNodeId: visibleNodeIdsByElementId.get(canonicalTargetAnchorElementId) ?? '',
886
+ sourceHandle: nativeSourceIsCanonical
887
+ ? (nativeDirectConnector.source_handle ?? 'right')
888
+ : (nativeDirectConnector.target_handle ?? 'left'),
889
+ targetHandle: nativeSourceIsCanonical
890
+ ? (nativeDirectConnector.target_handle ?? 'left')
891
+ : (nativeDirectConnector.source_handle ?? 'right'),
883
892
  count: details.count,
884
893
  details,
885
894
  })
@@ -8,6 +8,7 @@ export interface ViewEditorDemoOptions {
8
8
  disableOnboarding?: boolean
9
9
  hideFocusView?: boolean
10
10
  hideExpandExtras?: boolean
11
+ defaultHiddenLayerTags?: string[]
11
12
  }
12
13
 
13
14
  export const DEMO_VIEW_EDITOR_OPTIONS: Omit<ViewEditorDemoOptions, 'revealProgress'> = {
@@ -16,6 +17,7 @@ export const DEMO_VIEW_EDITOR_OPTIONS: Omit<ViewEditorDemoOptions, 'revealProgre
16
17
  disableOnboarding: true,
17
18
  hideFocusView: true,
18
19
  hideExpandExtras: true,
20
+ defaultHiddenLayerTags: ['view_layers:admin', 'view_layers:ops'],
19
21
  }
20
22
 
21
23
 
@@ -88,7 +88,7 @@ import { useCrossBranchContextSettings } from '../../crossBranch/settings'
88
88
  import { removeConnectorGraphSnapshot, upsertConnectorGraphSnapshot, useWorkspaceGraphSnapshot } from '../../crossBranch/store'
89
89
  import type { ProxyConnectorDetails } from '../../crossBranch/types'
90
90
  import { useDemoRevealViewport, type ViewEditorDemoOptions } from '../../demo/viewEditor'
91
- import { buildElementLibraryItems, useStore, placedElementToLibraryElement } from '../../store/useStore'
91
+ import { buildElementLibraryItems, useStore, placedElementToLibraryElement, resolveElementForUpdate } from '../../store/useStore'
92
92
  import { useWorkspaceVersionPreview } from '../../context/WorkspaceVersionContext'
93
93
  import { WATCH_REPRESENTATION_UPDATED_EVENT } from '../../components/WorkspacePanel'
94
94
 
@@ -375,7 +375,7 @@ function ViewEditorInner({
375
375
  }, [])
376
376
 
377
377
  const [layers, setLayers] = useState<import('../../types').ViewLayer[]>([])
378
- const [hiddenLayerTags, setHiddenLayerTags] = useState<string[]>([])
378
+ const [hiddenLayerTags, setHiddenLayerTags] = useState<string[]>(() => demoOptions?.defaultHiddenLayerTags ?? [])
379
379
  const hiddenLayerTagsRef = useRef<string[]>([])
380
380
  hiddenLayerTagsRef.current = hiddenLayerTags
381
381
  const [hoveredLayerTags, setHoveredLayerTags] = useState<string[] | null>(null)
@@ -786,7 +786,7 @@ function ViewEditorInner({
786
786
 
787
787
  const handleUpdateTags = useCallback(async (elementId: number, tags: string[]) => {
788
788
  if (!canEdit) return
789
- const obj = selectedElement?.id === elementId ? selectedElement : allElements.find(o => o.id === elementId)
789
+ const obj = resolveElementForUpdate(elementId, selectedElement, allElements, viewElements)
790
790
  if (!obj) return
791
791
  try {
792
792
  const saved = await api.elements.update(elementId, {
@@ -801,7 +801,7 @@ function ViewEditorInner({
801
801
  } catch (err) {
802
802
  console.error('Failed to update tags:', err)
803
803
  }
804
- }, [canEdit, selectedElement, allElements, applyElementSaved, pushElementEditAction, setSelectedElement])
804
+ }, [canEdit, selectedElement, allElements, viewElements, applyElementSaved, pushElementEditAction, setSelectedElement])
805
805
 
806
806
  const pushPlacementMoveAction = useCallback((before: PlacedElement, after: PlacedElement) => {
807
807
  if (placementSnapshotsEqual(before, after)) return
@@ -10,6 +10,7 @@ import {
10
10
  mergeSavedElementIntoPlacements,
11
11
  removeConnectorFromList,
12
12
  removePlacedElement,
13
+ resolveElementForUpdate,
13
14
  selectConnectorById,
14
15
  selectElementById,
15
16
  selectExistingElementIds,
@@ -163,6 +164,16 @@ describe('pure view helpers', () => {
163
164
  expect(afterRemoval[0]).toMatchObject({ id: 10, name: 'Saved' })
164
165
  })
165
166
 
167
+ it('resolves update payloads from placed elements when the library store is empty', () => {
168
+ const placed = { ...element(10), tags: ['current'] }
169
+ const resolved = resolveElementForUpdate(10, null, [], [placed])
170
+
171
+ expect(resolved).toMatchObject({ id: 10, name: 'Element 10', tags: ['current'] })
172
+ expect(resolveElementForUpdate(20, libraryElement(20), [], [placed])?.id).toBe(20)
173
+ expect(resolveElementForUpdate(30, null, [libraryElement(30)], [placed])?.id).toBe(30)
174
+ expect(resolveElementForUpdate(40, null, [], [placed])).toBeNull()
175
+ })
176
+
166
177
  it('upserts and removes connectors', () => {
167
178
  const first = connector(1)
168
179
  const second = { ...connector(2), label: 'two' }
@@ -221,6 +221,21 @@ export function buildElementLibraryItems(allElements: LibraryElement[], viewElem
221
221
  return Array.from(byId.values())
222
222
  }
223
223
 
224
+ export function resolveElementForUpdate(
225
+ elementId: number,
226
+ selectedElement: LibraryElement | null,
227
+ allElements: LibraryElement[],
228
+ viewElements: PlacedElement[],
229
+ ): LibraryElement | null {
230
+ if (selectedElement?.id === elementId) return selectedElement
231
+
232
+ const libraryElement = allElements.find((element) => element.id === elementId)
233
+ if (libraryElement) return libraryElement
234
+
235
+ const placedElement = viewElements.find((element) => element.element_id === elementId)
236
+ return placedElement ? placedElementToLibraryElement(placedElement) : null
237
+ }
238
+
224
239
  export function mergeSavedElementIntoPlacements(elements: PlacedElement[], saved: LibraryElement): PlacedElement[] {
225
240
  return elements.map((element) =>
226
241
  element.element_id === saved.id
@@ -30,3 +30,19 @@ export type WebviewToExtensionMessage =
30
30
  | { type: 'request-symbol-list-for-file'; requestId: string; filePath: string }
31
31
  | { type: 'diagram-loaded'; diagramId: number; elements: LibraryElement[] }
32
32
  | { type: 'request-file-content'; requestId: string; filePath: string; startLine: number }
33
+
34
+ // Watch event (extension → webview)
35
+ export interface WatchEventDetail {
36
+ type: string
37
+ repository_id?: number
38
+ message?: string
39
+ at: string
40
+ data?: any
41
+ }
42
+
43
+ // Sync status payload
44
+ export interface SyncStatusPayload {
45
+ localChanges: number
46
+ needsPush: boolean
47
+ needsPull: boolean
48
+ }
@@ -25,4 +25,10 @@ describe('resolveElementIconUrl', () => {
25
25
  { type: 'catalog', slug: 'golang', label: 'Go', is_primary_icon: true },
26
26
  ])).toBeNull()
27
27
  })
28
+
29
+ it('does not infer icons from custom technology labels when logo_url is missing', () => {
30
+ expect(resolveElementIconUrl(null, [
31
+ { type: 'custom', label: 'kafka' },
32
+ ])).toBeNull()
33
+ })
28
34
  })