@tldiagram/core-ui 1.95.0 → 2.0.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.
- package/dist/api/client.d.ts +184 -3
- package/dist/components/ConnectorPanel.d.ts +5 -1
- package/dist/components/CrossBranchControls.d.ts +4 -3
- package/dist/components/ElementNode.d.ts +5 -0
- package/dist/components/ElementPanel.d.ts +6 -1
- package/dist/components/LayoutSection.d.ts +2 -1
- package/dist/components/MergeDialog.d.ts +16 -0
- package/dist/components/MiniZoomOnboarding.d.ts +2 -1
- package/dist/components/NodeContainer.d.ts +2 -0
- package/dist/components/ProxyConnectorPanel.d.ts +4 -1
- package/dist/components/ViewExplorer/index.d.ts +1 -1
- package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
- package/dist/components/ViewFloatingMenu.d.ts +8 -1
- package/dist/components/ViewGridNode.d.ts +3 -0
- package/dist/components/ViewPanel.d.ts +2 -1
- package/dist/components/WorkspacePanel.d.ts +2 -0
- package/dist/components/ZUI/ZUICanvas.d.ts +5 -0
- package/dist/components/ZUI/focus.d.ts +32 -0
- package/dist/components/ZUI/focus.test.d.ts +1 -0
- package/dist/components/ZUI/layout.d.ts +2 -2
- package/dist/components/ZUI/proxy.d.ts +20 -4
- package/dist/components/ZUI/renderer.d.ts +35 -1
- package/dist/components/ZUI/types.d.ts +6 -0
- package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
- package/dist/context/WorkspaceVersionContext.d.ts +49 -0
- package/dist/crossBranch/resolve.d.ts +39 -2
- package/dist/crossBranch/resolve.test.d.ts +1 -0
- package/dist/crossBranch/settings.d.ts +6 -1
- package/dist/crossBranch/types.d.ts +8 -0
- package/dist/hooks/useElementSearch.d.ts +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +14597 -12083
- package/dist/pages/InfiniteZoom.d.ts +1 -0
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
- package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
- package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
- package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
- package/dist/pages/viewsJumpSearch.d.ts +22 -0
- package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
- package/dist/store/useStore.d.ts +3 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/utils/elementIcon.d.ts +2 -0
- package/dist/utils/elementIcon.test.d.ts +1 -0
- package/dist/utils/sourceEditor.d.ts +7 -0
- package/dist/utils/watchDiffSummary.d.ts +34 -0
- package/package.json +2 -2
- package/src/App.tsx +12 -8
- package/src/api/client.ts +488 -26
- package/src/components/CodePreviewPanel.tsx +90 -16
- package/src/components/ConnectorPanel.tsx +34 -3
- package/src/components/ContextNeighborElement.tsx +2 -5
- package/src/components/CrossBranchControls.tsx +46 -17
- package/src/components/ElementNode.tsx +98 -47
- package/src/components/ElementPanel.tsx +62 -25
- package/src/components/InlineElementAdder.tsx +8 -3
- package/src/components/LayoutSection.tsx +4 -1
- package/src/components/MergeDialog.tsx +269 -0
- package/src/components/MiniZoomOnboarding.tsx +29 -22
- package/src/components/NodeContainer.tsx +55 -17
- package/src/components/ProxyConnectorPanel.tsx +58 -16
- package/src/components/ViewBezierConnector.tsx +116 -21
- package/src/components/ViewExplorer/index.tsx +1 -1
- package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
- package/src/components/ViewFloatingMenu.tsx +110 -1
- package/src/components/ViewGridNode.tsx +59 -8
- package/src/components/ViewPanel.tsx +3 -2
- package/src/components/WorkspacePanel.tsx +938 -0
- package/src/components/ZUI/ZUICanvas.tsx +226 -127
- package/src/components/ZUI/focus.test.ts +534 -0
- package/src/components/ZUI/focus.ts +293 -0
- package/src/components/ZUI/layout.ts +7 -11
- package/src/components/ZUI/proxy.ts +470 -114
- package/src/components/ZUI/renderer.ts +510 -134
- package/src/components/ZUI/types.ts +6 -0
- package/src/components/ZUI/useZUIInteraction.ts +66 -29
- package/src/context/WorkspaceVersionContext.tsx +126 -0
- package/src/crossBranch/resolve.test.ts +342 -0
- package/src/crossBranch/resolve.ts +368 -68
- package/src/crossBranch/settings.ts +49 -3
- package/src/crossBranch/types.ts +9 -0
- package/src/hooks/useElementSearch.ts +45 -0
- package/src/index.css +11 -0
- package/src/index.ts +7 -0
- package/src/pages/AppearanceSettings.tsx +24 -1
- package/src/pages/Dependencies.tsx +231 -65
- package/src/pages/InfiniteZoom.tsx +76 -27
- package/src/pages/Settings.tsx +1 -1
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
- package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
- package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
- package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
- package/src/pages/ViewEditor/index.tsx +549 -59
- package/src/pages/Views.tsx +112 -41
- package/src/pages/ViewsGrid.tsx +332 -113
- package/src/pages/viewsJumpSearch.test.ts +193 -0
- package/src/pages/viewsJumpSearch.ts +111 -0
- package/src/store/useStore.ts +58 -0
- package/src/types/index.ts +10 -0
- package/src/utils/elementIcon.test.ts +28 -0
- package/src/utils/elementIcon.ts +20 -0
- package/src/utils/sourceEditor.ts +46 -0
- package/src/utils/watchDiffSummary.ts +159 -0
|
@@ -10,6 +10,10 @@ export interface ZUIViewState {
|
|
|
10
10
|
y: number
|
|
11
11
|
/** Current zoom multiplier (1 = 1 world-pixel per screen-pixel). */
|
|
12
12
|
zoom: number
|
|
13
|
+
/** World-space X rendered at the local camera origin. Keeps x screen-sized at deep zoom. */
|
|
14
|
+
originX?: number
|
|
15
|
+
/** World-space Y rendered at the local camera origin. Keeps y screen-sized at deep zoom. */
|
|
16
|
+
originY?: number
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
/**
|
|
@@ -71,6 +75,7 @@ export interface LayoutNode {
|
|
|
71
75
|
|
|
72
76
|
// ── Edges within the same diagram ────────────────────────────────
|
|
73
77
|
edgesOut: Array<{
|
|
78
|
+
id: number
|
|
74
79
|
/** LayoutNode id of the target. */
|
|
75
80
|
targetId: string
|
|
76
81
|
label: string
|
|
@@ -103,6 +108,7 @@ export interface DiagramGroupLayout {
|
|
|
103
108
|
nodes: LayoutNode[]
|
|
104
109
|
/** Edges whose both endpoints are in this diagram. */
|
|
105
110
|
edges: Array<{
|
|
111
|
+
id: number
|
|
106
112
|
sourceId: string
|
|
107
113
|
targetId: string
|
|
108
114
|
label: string
|
|
@@ -2,23 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
|
|
4
4
|
import type { BBox, DiagramGroupLayout, LayoutNode, ZUIViewState, HoveredItem } from './types'
|
|
5
|
-
import { getExpandThresholds } from './renderer'
|
|
5
|
+
import { getExpandThresholds, screenToWorldX, screenToWorldY, viewOriginX, viewOriginY } from './renderer'
|
|
6
|
+
|
|
7
|
+
export function constrainViewState(view: ZUIViewState, canvasW: number, canvasH: number, bbox: BBox): ZUIViewState {
|
|
8
|
+
const padding = Math.min(600, canvasW * 0.45, canvasH * 0.45)
|
|
9
|
+
const normalized = normalizeViewState(view, canvasW, canvasH)
|
|
10
|
+
const halfVisibleX = Math.max(0, canvasW / 2 - padding) / normalized.zoom
|
|
11
|
+
const halfVisibleY = Math.max(0, canvasH / 2 - padding) / normalized.zoom
|
|
12
|
+
const minOriginX = bbox.minX - halfVisibleX
|
|
13
|
+
const maxOriginX = bbox.maxX + halfVisibleX
|
|
14
|
+
const minOriginY = bbox.minY - halfVisibleY
|
|
15
|
+
const maxOriginY = bbox.maxY + halfVisibleY
|
|
6
16
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (maxY >= minY) y = Math.max(minY, Math.min(maxY, y))
|
|
19
|
-
else y = (minY + maxY) / 2
|
|
17
|
+
return {
|
|
18
|
+
...normalized,
|
|
19
|
+
originX: maxOriginX >= minOriginX
|
|
20
|
+
? Math.max(minOriginX, Math.min(maxOriginX, viewOriginX(normalized)))
|
|
21
|
+
: (minOriginX + maxOriginX) / 2,
|
|
22
|
+
originY: maxOriginY >= minOriginY
|
|
23
|
+
? Math.max(minOriginY, Math.min(maxOriginY, viewOriginY(normalized)))
|
|
24
|
+
: (minOriginY + maxOriginY) / 2,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
function normalizeViewState(view: ZUIViewState, canvasW: number, canvasH: number): ZUIViewState {
|
|
29
|
+
const zoom = Math.max(0.0001, view.zoom)
|
|
30
|
+
return {
|
|
31
|
+
...view,
|
|
32
|
+
x: canvasW / 2,
|
|
33
|
+
y: canvasH / 2,
|
|
34
|
+
zoom,
|
|
35
|
+
originX: screenToWorldX(canvasW / 2, { ...view, zoom }),
|
|
36
|
+
originY: screenToWorldY(canvasH / 2, { ...view, zoom }),
|
|
37
|
+
}
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
interface DeepestNodeResult {
|
|
@@ -392,6 +408,12 @@ export function calculateMaxZoom(groups: DiagramGroupLayout[], canvasW: number):
|
|
|
392
408
|
}
|
|
393
409
|
|
|
394
410
|
const MIN_ZOOM = 0.4
|
|
411
|
+
const ZUI_NATIVE_WHEEL_SELECTOR = '[data-zui-native-wheel="true"]'
|
|
412
|
+
|
|
413
|
+
function shouldIgnoreCapturedWheel(e: WheelEvent): boolean {
|
|
414
|
+
const target = e.target
|
|
415
|
+
return target instanceof Element && target.closest(ZUI_NATIVE_WHEEL_SELECTOR) !== null
|
|
416
|
+
}
|
|
395
417
|
|
|
396
418
|
function clampZoom(z: number, prevZ: number, maxZ: number): number {
|
|
397
419
|
if (z > prevZ) {
|
|
@@ -412,11 +434,16 @@ function zoomAround(
|
|
|
412
434
|
maxZoom: number,
|
|
413
435
|
): ZUIViewState {
|
|
414
436
|
const newZoom = clampZoom(view.zoom * factor, view.zoom, maxZoom)
|
|
415
|
-
const
|
|
437
|
+
const worldX = screenToWorldX(focalX, view)
|
|
438
|
+
const worldY = screenToWorldY(focalY, view)
|
|
439
|
+
const originX = viewOriginX(view)
|
|
440
|
+
const originY = viewOriginY(view)
|
|
416
441
|
return {
|
|
442
|
+
originX,
|
|
443
|
+
originY,
|
|
417
444
|
zoom: newZoom,
|
|
418
|
-
x: focalX - (
|
|
419
|
-
y: focalY - (
|
|
445
|
+
x: focalX - (worldX - originX) * newZoom,
|
|
446
|
+
y: focalY - (worldY - originY) * newZoom,
|
|
420
447
|
}
|
|
421
448
|
}
|
|
422
449
|
|
|
@@ -590,6 +617,17 @@ export function useZUIInteraction(
|
|
|
590
617
|
if (!el) return
|
|
591
618
|
|
|
592
619
|
function onWheel(e: WheelEvent) {
|
|
620
|
+
if (shouldIgnoreCapturedWheel(e)) return
|
|
621
|
+
|
|
622
|
+
const rect = el!.getBoundingClientRect()
|
|
623
|
+
const isInsideCanvas =
|
|
624
|
+
e.clientX >= rect.left &&
|
|
625
|
+
e.clientX <= rect.right &&
|
|
626
|
+
e.clientY >= rect.top &&
|
|
627
|
+
e.clientY <= rect.bottom
|
|
628
|
+
|
|
629
|
+
if (!isInsideCanvas) return
|
|
630
|
+
|
|
593
631
|
// Heuristic to distinguish between trackpad and physical mouse wheel:
|
|
594
632
|
// 1. If ctrlKey is true, it's a pinch (trackpad) or Ctrl+Wheel. We always zoom.
|
|
595
633
|
// 2. If deltaMode !== 0, it's a physical mouse wheel (DOM_DELTA_LINE/PAGE). We zoom.
|
|
@@ -619,7 +657,6 @@ export function useZUIInteraction(
|
|
|
619
657
|
const isRealMouseWheel = e.deltaMode !== 0 || isNotchedWheel
|
|
620
658
|
|
|
621
659
|
if (isPinch || isRealMouseWheel) {
|
|
622
|
-
const rect = el!.getBoundingClientRect()
|
|
623
660
|
const focalX = e.clientX - rect.left
|
|
624
661
|
const focalY = e.clientY - rect.top
|
|
625
662
|
|
|
@@ -628,8 +665,8 @@ export function useZUIInteraction(
|
|
|
628
665
|
factor = Math.max(0.85, Math.min(1.15, factor))
|
|
629
666
|
|
|
630
667
|
setViewState((prev) => {
|
|
631
|
-
const worldX = (focalX
|
|
632
|
-
const worldY = (focalY
|
|
668
|
+
const worldX = screenToWorldX(focalX, prev)
|
|
669
|
+
const worldY = screenToWorldY(focalY, prev)
|
|
633
670
|
const thresholds = getExpandThresholds(rect.width)
|
|
634
671
|
const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
|
|
635
672
|
|
|
@@ -677,8 +714,8 @@ export function useZUIInteraction(
|
|
|
677
714
|
|
|
678
715
|
// Hover detection
|
|
679
716
|
const view = viewStateRef.current
|
|
680
|
-
const worldX = (screenX
|
|
681
|
-
const worldY = (screenY
|
|
717
|
+
const worldX = screenToWorldX(screenX, view)
|
|
718
|
+
const worldY = screenToWorldY(screenY, view)
|
|
682
719
|
const thresholds = getExpandThresholds(rect.width)
|
|
683
720
|
|
|
684
721
|
const deepest = findDeepestAt(worldX, worldY, groupsRef.current, view, thresholds)
|
|
@@ -731,8 +768,8 @@ export function useZUIInteraction(
|
|
|
731
768
|
setHoveredItem(null, true) // Clear popover immediately on double-click zoom
|
|
732
769
|
|
|
733
770
|
setViewState((prev) => {
|
|
734
|
-
const worldX = (focalX
|
|
735
|
-
const worldY = (focalY
|
|
771
|
+
const worldX = screenToWorldX(focalX, prev)
|
|
772
|
+
const worldY = screenToWorldY(focalY, prev)
|
|
736
773
|
const thresholds = getExpandThresholds(rect.width)
|
|
737
774
|
const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
|
|
738
775
|
|
|
@@ -802,8 +839,8 @@ export function useZUIInteraction(
|
|
|
802
839
|
if (isFinite(factor) && factor > 0) {
|
|
803
840
|
setViewState((prev) => {
|
|
804
841
|
const rect = el!.getBoundingClientRect()
|
|
805
|
-
const worldX = (mid.x
|
|
806
|
-
const worldY = (mid.y
|
|
842
|
+
const worldX = screenToWorldX(mid.x, prev)
|
|
843
|
+
const worldY = screenToWorldY(mid.y, prev)
|
|
807
844
|
const thresholds = getExpandThresholds(rect.width)
|
|
808
845
|
const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
|
|
809
846
|
|
|
@@ -843,7 +880,7 @@ export function useZUIInteraction(
|
|
|
843
880
|
|
|
844
881
|
el.style.cursor = 'grab'
|
|
845
882
|
|
|
846
|
-
|
|
883
|
+
window.addEventListener('wheel', onWheel, { passive: false, capture: true })
|
|
847
884
|
el.addEventListener('mousedown', onMouseDown)
|
|
848
885
|
el.addEventListener('mouseleave', onMouseOut)
|
|
849
886
|
el.addEventListener('mouseout', onMouseOut)
|
|
@@ -856,7 +893,7 @@ export function useZUIInteraction(
|
|
|
856
893
|
el.addEventListener('touchcancel', onTouchEnd)
|
|
857
894
|
|
|
858
895
|
return () => {
|
|
859
|
-
|
|
896
|
+
window.removeEventListener('wheel', onWheel, { capture: true })
|
|
860
897
|
el.removeEventListener('mousedown', onMouseDown)
|
|
861
898
|
el.removeEventListener('mouseleave', onMouseOut)
|
|
862
899
|
el.removeEventListener('mouseout', onMouseOut)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { WatchDiff, WatchRepository, WatchVersion, WorkspaceVersion } from '../api/client'
|
|
3
|
+
import { normalizeWatchChangeType } from '../utils/watchDiffSummary'
|
|
4
|
+
|
|
5
|
+
export type VersionChangeType = 'added' | 'updated' | 'deleted' | 'initialized'
|
|
6
|
+
|
|
7
|
+
export interface VersionLineDelta {
|
|
8
|
+
added: number
|
|
9
|
+
removed: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface WorkspaceVersionPreview {
|
|
13
|
+
repository: WatchRepository | null
|
|
14
|
+
version: WatchVersion | null
|
|
15
|
+
workspaceVersions: WorkspaceVersion[]
|
|
16
|
+
diffs: WatchDiff[]
|
|
17
|
+
elementChanges: Map<number, VersionChangeType>
|
|
18
|
+
elementLineDeltas: Map<number, VersionLineDelta>
|
|
19
|
+
connectorChanges: Map<number, VersionChangeType>
|
|
20
|
+
summary: {
|
|
21
|
+
added: number
|
|
22
|
+
updated: number
|
|
23
|
+
deleted: number
|
|
24
|
+
initialized: number
|
|
25
|
+
elements: number
|
|
26
|
+
connectors: number
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WorkspaceVersionFollowTarget {
|
|
31
|
+
token: number
|
|
32
|
+
resourceType: string
|
|
33
|
+
resourceId?: number
|
|
34
|
+
viewId?: number
|
|
35
|
+
changeType?: VersionChangeType
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface WorkspaceVersionContextValue {
|
|
39
|
+
preview: WorkspaceVersionPreview | null
|
|
40
|
+
followToken: number
|
|
41
|
+
followTarget: WorkspaceVersionFollowTarget | null
|
|
42
|
+
setPreview: (preview: WorkspaceVersionPreview | null) => void
|
|
43
|
+
clearPreview: () => void
|
|
44
|
+
requestFollow: (target?: Omit<WorkspaceVersionFollowTarget, 'token'>) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const WorkspaceVersionContext = createContext<WorkspaceVersionContextValue | null>(null)
|
|
48
|
+
|
|
49
|
+
export function buildWorkspaceVersionPreview(args: {
|
|
50
|
+
repository: WatchRepository | null
|
|
51
|
+
version: WatchVersion | null
|
|
52
|
+
workspaceVersions: WorkspaceVersion[]
|
|
53
|
+
diffs: WatchDiff[] | null | undefined
|
|
54
|
+
}): WorkspaceVersionPreview {
|
|
55
|
+
const diffs = Array.isArray(args.diffs) ? args.diffs : []
|
|
56
|
+
const elementChanges = new Map<number, VersionChangeType>()
|
|
57
|
+
const elementLineDeltas = new Map<number, VersionLineDelta>()
|
|
58
|
+
const connectorChanges = new Map<number, VersionChangeType>()
|
|
59
|
+
const summary = { added: 0, updated: 0, deleted: 0, initialized: 0, elements: 0, connectors: 0 }
|
|
60
|
+
|
|
61
|
+
diffs.forEach((diff) => {
|
|
62
|
+
const change = normalizeWatchChangeType(diff.change_type)
|
|
63
|
+
summary[change] += 1
|
|
64
|
+
if (diff.resource_type === 'element' && diff.resource_id) {
|
|
65
|
+
elementChanges.set(diff.resource_id, change)
|
|
66
|
+
const added = Math.max(0, diff.added_lines ?? 0)
|
|
67
|
+
const removed = Math.max(0, diff.removed_lines ?? 0)
|
|
68
|
+
if (added > 0 || removed > 0) {
|
|
69
|
+
elementLineDeltas.set(diff.resource_id, { added, removed })
|
|
70
|
+
}
|
|
71
|
+
summary.elements += 1
|
|
72
|
+
}
|
|
73
|
+
if (diff.resource_type === 'connector' && diff.resource_id) {
|
|
74
|
+
connectorChanges.set(diff.resource_id, change)
|
|
75
|
+
summary.connectors += 1
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
repository: args.repository,
|
|
81
|
+
version: args.version,
|
|
82
|
+
workspaceVersions: args.workspaceVersions,
|
|
83
|
+
diffs,
|
|
84
|
+
elementChanges,
|
|
85
|
+
elementLineDeltas,
|
|
86
|
+
connectorChanges,
|
|
87
|
+
summary,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function WorkspaceVersionProvider({ children }: { children: React.ReactNode }) {
|
|
92
|
+
const [preview, setPreview] = useState<WorkspaceVersionPreview | null>(null)
|
|
93
|
+
const [followToken, setFollowToken] = useState(0)
|
|
94
|
+
const [followTarget, setFollowTarget] = useState<WorkspaceVersionFollowTarget | null>(null)
|
|
95
|
+
const followClearTimerRef = useRef<number | null>(null)
|
|
96
|
+
const clearPreview = useCallback(() => setPreview(null), [])
|
|
97
|
+
const requestFollow = useCallback((target?: Omit<WorkspaceVersionFollowTarget, 'token'>) => {
|
|
98
|
+
setFollowToken((value) => value + 1)
|
|
99
|
+
if (followClearTimerRef.current !== null) {
|
|
100
|
+
window.clearTimeout(followClearTimerRef.current)
|
|
101
|
+
followClearTimerRef.current = null
|
|
102
|
+
}
|
|
103
|
+
if (!target) {
|
|
104
|
+
setFollowTarget(null)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
setFollowTarget({ ...target, token: Date.now() })
|
|
108
|
+
followClearTimerRef.current = window.setTimeout(() => {
|
|
109
|
+
setFollowTarget(null)
|
|
110
|
+
followClearTimerRef.current = null
|
|
111
|
+
}, 1600)
|
|
112
|
+
}, [])
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
return () => {
|
|
115
|
+
if (followClearTimerRef.current !== null) window.clearTimeout(followClearTimerRef.current)
|
|
116
|
+
}
|
|
117
|
+
}, [])
|
|
118
|
+
const value = useMemo(() => ({ preview, followToken, followTarget, setPreview, clearPreview, requestFollow }), [preview, followToken, followTarget, clearPreview, requestFollow])
|
|
119
|
+
return <WorkspaceVersionContext.Provider value={value}>{children}</WorkspaceVersionContext.Provider>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function useWorkspaceVersionPreview() {
|
|
123
|
+
const value = useContext(WorkspaceVersionContext)
|
|
124
|
+
if (!value) throw new Error('useWorkspaceVersionPreview must be used within WorkspaceVersionProvider')
|
|
125
|
+
return value
|
|
126
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildWorkspaceGraphSnapshot } from './graph'
|
|
3
|
+
import { resolveZUIProxyConnectors } from './resolve'
|
|
4
|
+
import type { ResolveZUIProxyConnectorOptions, ZUIConnectorAnchorInfo } from './resolve'
|
|
5
|
+
import type { Connector, ExploreData, PlacedElement, ViewTreeNode } from '../types'
|
|
6
|
+
import type { CrossBranchContextSettings } from './types'
|
|
7
|
+
|
|
8
|
+
function placedElement(view_id: number, element_id: number, name: string): PlacedElement {
|
|
9
|
+
return {
|
|
10
|
+
id: view_id * 100 + element_id,
|
|
11
|
+
view_id,
|
|
12
|
+
element_id,
|
|
13
|
+
position_x: element_id * 10,
|
|
14
|
+
position_y: 0,
|
|
15
|
+
name,
|
|
16
|
+
description: null,
|
|
17
|
+
kind: 'service',
|
|
18
|
+
technology: null,
|
|
19
|
+
url: null,
|
|
20
|
+
logo_url: null,
|
|
21
|
+
technology_connectors: [],
|
|
22
|
+
tags: [],
|
|
23
|
+
has_view: element_id === 1 || element_id === 3,
|
|
24
|
+
view_label: null,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function connector(id: number, view_id: number, source_element_id: number, target_element_id: number, label: string): Connector {
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
view_id,
|
|
32
|
+
source_element_id,
|
|
33
|
+
target_element_id,
|
|
34
|
+
label,
|
|
35
|
+
description: null,
|
|
36
|
+
relationship: null,
|
|
37
|
+
direction: 'forward',
|
|
38
|
+
style: 'bezier',
|
|
39
|
+
url: null,
|
|
40
|
+
source_handle: null,
|
|
41
|
+
target_handle: null,
|
|
42
|
+
created_at: '2024-01-01',
|
|
43
|
+
updated_at: '2024-01-01',
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function zuiSettings(overrides: Partial<CrossBranchContextSettings> = {}): CrossBranchContextSettings {
|
|
48
|
+
return {
|
|
49
|
+
enabled: true,
|
|
50
|
+
depth: 5,
|
|
51
|
+
connectorBudget: 50,
|
|
52
|
+
connectorPriority: 'external',
|
|
53
|
+
...overrides,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function anchor(nodeId: string, x: number, y = 0): ZUIConnectorAnchorInfo {
|
|
58
|
+
return {
|
|
59
|
+
nodeId,
|
|
60
|
+
worldX: x,
|
|
61
|
+
worldY: y,
|
|
62
|
+
worldW: 10,
|
|
63
|
+
worldH: 10,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function viewportOptions(anchorsByElementId: Map<number, ZUIConnectorAnchorInfo>): ResolveZUIProxyConnectorOptions {
|
|
68
|
+
return {
|
|
69
|
+
anchorsByElementId,
|
|
70
|
+
viewport: {
|
|
71
|
+
minX: 0,
|
|
72
|
+
minY: 0,
|
|
73
|
+
maxX: 100,
|
|
74
|
+
maxY: 100,
|
|
75
|
+
centerX: 50,
|
|
76
|
+
centerY: 50,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const tree: ViewTreeNode[] = [{
|
|
82
|
+
id: 1,
|
|
83
|
+
owner_element_id: null,
|
|
84
|
+
name: 'Root',
|
|
85
|
+
description: null,
|
|
86
|
+
level_label: null,
|
|
87
|
+
level: 0,
|
|
88
|
+
depth: 0,
|
|
89
|
+
created_at: '2024-01-01',
|
|
90
|
+
updated_at: '2024-01-01',
|
|
91
|
+
parent_view_id: null,
|
|
92
|
+
children: [{
|
|
93
|
+
id: 2,
|
|
94
|
+
owner_element_id: 1,
|
|
95
|
+
name: 'A Child',
|
|
96
|
+
description: null,
|
|
97
|
+
level_label: null,
|
|
98
|
+
level: 1,
|
|
99
|
+
depth: 1,
|
|
100
|
+
created_at: '2024-01-01',
|
|
101
|
+
updated_at: '2024-01-01',
|
|
102
|
+
parent_view_id: 1,
|
|
103
|
+
children: [{
|
|
104
|
+
id: 3,
|
|
105
|
+
owner_element_id: 3,
|
|
106
|
+
name: 'AA Child',
|
|
107
|
+
description: null,
|
|
108
|
+
level_label: null,
|
|
109
|
+
level: 2,
|
|
110
|
+
depth: 2,
|
|
111
|
+
created_at: '2024-01-01',
|
|
112
|
+
updated_at: '2024-01-01',
|
|
113
|
+
parent_view_id: 2,
|
|
114
|
+
children: [],
|
|
115
|
+
}],
|
|
116
|
+
}],
|
|
117
|
+
}]
|
|
118
|
+
|
|
119
|
+
function baseData(connectors: Connector[]): ExploreData {
|
|
120
|
+
return {
|
|
121
|
+
tree,
|
|
122
|
+
navigations: [
|
|
123
|
+
{ id: 1, element_id: 1, from_view_id: 1, to_view_id: 2, to_view_name: 'A Child', relation_type: 'child' },
|
|
124
|
+
{ id: 2, element_id: 3, from_view_id: 2, to_view_id: 3, to_view_name: 'AA Child', relation_type: 'child' },
|
|
125
|
+
],
|
|
126
|
+
views: {
|
|
127
|
+
'1': {
|
|
128
|
+
placements: [placedElement(1, 1, 'A'), placedElement(1, 2, 'B')],
|
|
129
|
+
connectors,
|
|
130
|
+
},
|
|
131
|
+
'2': {
|
|
132
|
+
placements: [placedElement(2, 3, 'AA')],
|
|
133
|
+
connectors: [],
|
|
134
|
+
},
|
|
135
|
+
'3': {
|
|
136
|
+
placements: [placedElement(3, 4, 'AAA')],
|
|
137
|
+
connectors: [],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe('resolveZUIProxyConnectors', () => {
|
|
144
|
+
it('collapses direct-child cross-branch links into a native +N badge', () => {
|
|
145
|
+
const snapshot = buildWorkspaceGraphSnapshot(baseData([
|
|
146
|
+
connector(1, 1, 1, 2, 'A-B'),
|
|
147
|
+
connector(2, 1, 3, 2, 'AA-B'),
|
|
148
|
+
]))
|
|
149
|
+
|
|
150
|
+
const resolved = resolveZUIProxyConnectors(
|
|
151
|
+
snapshot,
|
|
152
|
+
new Map([
|
|
153
|
+
[1, 'd1-o1'],
|
|
154
|
+
[2, 'd1-o2'],
|
|
155
|
+
]),
|
|
156
|
+
zuiSettings(),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
expect(resolved.connectors).toHaveLength(0)
|
|
160
|
+
expect(resolved.hiddenBadges).toHaveLength(1)
|
|
161
|
+
expect(resolved.hiddenBadges[0]).toMatchObject({
|
|
162
|
+
sourceAnchorElementId: 1,
|
|
163
|
+
targetAnchorElementId: 2,
|
|
164
|
+
count: 1,
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('fractures the badge into direct child and parent connectors when the child is visible', () => {
|
|
169
|
+
const snapshot = buildWorkspaceGraphSnapshot(baseData([
|
|
170
|
+
connector(1, 1, 1, 2, 'A-B'),
|
|
171
|
+
connector(2, 1, 3, 2, 'AA-B'),
|
|
172
|
+
]))
|
|
173
|
+
|
|
174
|
+
const resolved = resolveZUIProxyConnectors(
|
|
175
|
+
snapshot,
|
|
176
|
+
new Map([
|
|
177
|
+
[1, 'd1-o1'],
|
|
178
|
+
[2, 'd1-o2'],
|
|
179
|
+
[3, 'd2-o3'],
|
|
180
|
+
]),
|
|
181
|
+
zuiSettings(),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
expect(resolved.hiddenBadges).toHaveLength(0)
|
|
185
|
+
expect(resolved.connectors.map((item) => [item.sourceAnchorElementId, item.targetAnchorElementId])).toEqual([[2, 3]])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('keeps only the deepest visible connector and its parent once grandchildren are visible', () => {
|
|
189
|
+
const snapshot = buildWorkspaceGraphSnapshot(baseData([
|
|
190
|
+
connector(1, 1, 1, 2, 'A-B'),
|
|
191
|
+
connector(2, 1, 4, 2, 'AAA-B'),
|
|
192
|
+
]))
|
|
193
|
+
|
|
194
|
+
const resolved = resolveZUIProxyConnectors(
|
|
195
|
+
snapshot,
|
|
196
|
+
new Map([
|
|
197
|
+
[1, 'd1-o1'],
|
|
198
|
+
[2, 'd1-o2'],
|
|
199
|
+
[3, 'd2-o3'],
|
|
200
|
+
[4, 'd3-o4'],
|
|
201
|
+
]),
|
|
202
|
+
zuiSettings(),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
expect(resolved.hiddenBadges).toHaveLength(0)
|
|
206
|
+
expect(resolved.connectors.map((item) => [item.sourceAnchorElementId, item.targetAnchorElementId]).sort()).toEqual([
|
|
207
|
+
[2, 3],
|
|
208
|
+
[2, 4],
|
|
209
|
+
])
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('budgets visible connector groups and reports the omitted leaf count', () => {
|
|
213
|
+
const data = baseData([
|
|
214
|
+
connector(1, 1, 3, 2, 'one'),
|
|
215
|
+
connector(2, 1, 4, 2, 'two'),
|
|
216
|
+
connector(3, 1, 5, 2, 'three'),
|
|
217
|
+
])
|
|
218
|
+
data.views['2'].placements = [
|
|
219
|
+
placedElement(2, 3, 'AA'),
|
|
220
|
+
placedElement(2, 4, 'AB'),
|
|
221
|
+
placedElement(2, 5, 'AC'),
|
|
222
|
+
]
|
|
223
|
+
const snapshot = buildWorkspaceGraphSnapshot(data)
|
|
224
|
+
|
|
225
|
+
const resolved = resolveZUIProxyConnectors(
|
|
226
|
+
snapshot,
|
|
227
|
+
new Map([
|
|
228
|
+
[1, 'd1-o1'],
|
|
229
|
+
[2, 'd1-o2'],
|
|
230
|
+
[3, 'd2-o3'],
|
|
231
|
+
[4, 'd2-o4'],
|
|
232
|
+
[5, 'd2-o5'],
|
|
233
|
+
]),
|
|
234
|
+
zuiSettings({ connectorBudget: 2 }),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
expect(resolved.connectors).toHaveLength(2)
|
|
238
|
+
expect(resolved.omittedConnectorCount).toBe(3)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('reducing the budget keeps a subset of the larger-budget result', () => {
|
|
242
|
+
const connectors = [
|
|
243
|
+
connector(1, 1, 4, 2, 'deep-one'),
|
|
244
|
+
connector(2, 1, 4, 2, 'deep-two'),
|
|
245
|
+
connector(3, 1, 3, 2, 'shallow'),
|
|
246
|
+
]
|
|
247
|
+
const snapshot = buildWorkspaceGraphSnapshot(baseData(connectors))
|
|
248
|
+
const visibleNodes = new Map([
|
|
249
|
+
[1, 'd1-o1'],
|
|
250
|
+
[2, 'd1-o2'],
|
|
251
|
+
[3, 'd2-o3'],
|
|
252
|
+
[4, 'd3-o4'],
|
|
253
|
+
])
|
|
254
|
+
|
|
255
|
+
const budgetTwo = resolveZUIProxyConnectors(
|
|
256
|
+
snapshot,
|
|
257
|
+
visibleNodes,
|
|
258
|
+
zuiSettings({ connectorBudget: 2 }),
|
|
259
|
+
)
|
|
260
|
+
const budgetOne = resolveZUIProxyConnectors(
|
|
261
|
+
snapshot,
|
|
262
|
+
visibleNodes,
|
|
263
|
+
zuiSettings({ connectorBudget: 1 }),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const budgetTwoKeys = new Set(budgetTwo.connectors.map((item) => item.key))
|
|
267
|
+
expect(budgetTwo.connectors).toHaveLength(2)
|
|
268
|
+
expect(budgetOne.connectors).toHaveLength(1)
|
|
269
|
+
expect(budgetOne.connectors.every((item) => budgetTwoKeys.has(item.key))).toBe(true)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('prioritizes one-near one-far connector groups in external mode', () => {
|
|
273
|
+
const data = baseData([
|
|
274
|
+
connector(1, 1, 3, 2, 'near-far'),
|
|
275
|
+
connector(2, 1, 4, 2, 'near-near'),
|
|
276
|
+
])
|
|
277
|
+
data.views['2'].placements = [
|
|
278
|
+
placedElement(2, 3, 'External Far'),
|
|
279
|
+
placedElement(2, 4, 'Internal Near'),
|
|
280
|
+
]
|
|
281
|
+
const snapshot = buildWorkspaceGraphSnapshot(data)
|
|
282
|
+
const visibleNodes = new Map([
|
|
283
|
+
[2, 'd1-o2'],
|
|
284
|
+
[3, 'd2-o3'],
|
|
285
|
+
[4, 'd2-o4'],
|
|
286
|
+
])
|
|
287
|
+
const options = viewportOptions(new Map([
|
|
288
|
+
[2, anchor('d1-o2', 45, 45)],
|
|
289
|
+
[3, anchor('d2-o3', 400, 45)],
|
|
290
|
+
[4, anchor('d2-o4', 60, 45)],
|
|
291
|
+
]))
|
|
292
|
+
|
|
293
|
+
const resolved = resolveZUIProxyConnectors(
|
|
294
|
+
snapshot,
|
|
295
|
+
visibleNodes,
|
|
296
|
+
zuiSettings({ connectorBudget: 1, connectorPriority: 'external' }),
|
|
297
|
+
options,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
expect(resolved.connectors).toHaveLength(1)
|
|
301
|
+
expect(resolved.connectors[0].details.connectors[0].connector.label).toBe('near-far')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('prioritizes both-near connector groups in internal mode', () => {
|
|
305
|
+
const data = baseData([
|
|
306
|
+
connector(1, 1, 3, 2, 'near-far'),
|
|
307
|
+
connector(2, 1, 4, 2, 'near-near'),
|
|
308
|
+
])
|
|
309
|
+
data.views['2'].placements = [
|
|
310
|
+
placedElement(2, 3, 'External Far'),
|
|
311
|
+
placedElement(2, 4, 'Internal Near'),
|
|
312
|
+
]
|
|
313
|
+
const snapshot = buildWorkspaceGraphSnapshot(data)
|
|
314
|
+
const visibleNodes = new Map([
|
|
315
|
+
[2, 'd1-o2'],
|
|
316
|
+
[3, 'd2-o3'],
|
|
317
|
+
[4, 'd2-o4'],
|
|
318
|
+
])
|
|
319
|
+
const options = viewportOptions(new Map([
|
|
320
|
+
[2, anchor('d1-o2', 45, 45)],
|
|
321
|
+
[3, anchor('d2-o3', 400, 45)],
|
|
322
|
+
[4, anchor('d2-o4', 60, 45)],
|
|
323
|
+
]))
|
|
324
|
+
|
|
325
|
+
const resolved = resolveZUIProxyConnectors(
|
|
326
|
+
snapshot,
|
|
327
|
+
visibleNodes,
|
|
328
|
+
zuiSettings({ connectorBudget: 1, connectorPriority: 'internal' }),
|
|
329
|
+
options,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
expect(resolved.connectors).toHaveLength(1)
|
|
333
|
+
expect(resolved.connectors[0].details.connectors[0].connector.label).toBe('near-near')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('uses a default budget of 50 and external priority in test settings', () => {
|
|
337
|
+
expect(zuiSettings()).toMatchObject({
|
|
338
|
+
connectorBudget: 50,
|
|
339
|
+
connectorPriority: 'external',
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
})
|