@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
@@ -37,6 +37,7 @@ export interface ScreenRect {
37
37
  bottom: number
38
38
  }
39
39
 
40
+ const frameLabelRects: ScreenRect[] = []
40
41
 
41
42
  /**
42
43
  * Returns a world-space font size that, when multiplied by zoom,
@@ -62,19 +63,61 @@ const TYPE_COLOR_400: Record<string, string> = {
62
63
  }
63
64
 
64
65
  /** Border color: type .400 at 50% alpha - bold branded tint */
66
+ const typeBorderColorCache = new Map<string, string>()
65
67
  function typeBorderColor(type: string, alpha = 0.5): string {
68
+ const cacheKey = `${type}:${alpha}`
69
+ const cached = typeBorderColorCache.get(cacheKey)
70
+ if (cached) return cached
71
+
66
72
  const color = TYPE_COLOR_400[type]
67
73
  const hex = typeof color === 'string' ? color : '#a0aec0'
68
74
  const r = parseInt(hex.slice(1, 3), 16)
69
75
  const g = parseInt(hex.slice(3, 5), 16)
70
76
  const b = parseInt(hex.slice(5, 7), 16)
71
- return `rgba(${r},${g},${b},${alpha})`
77
+ const rgba = `rgba(${r},${g},${b},${alpha})`
78
+ typeBorderColorCache.set(cacheKey, rgba)
79
+ return rgba
80
+ }
81
+
82
+ interface RendererThemeVars {
83
+ canvasBg: string
84
+ nodeBg: string
85
+ accent: string
86
+ labelBg: string
87
+ }
88
+
89
+ const themeFallbacks: RendererThemeVars = {
90
+ canvasBg: '#0d121e',
91
+ nodeBg: '#2d3748',
92
+ accent: '#63b3ed',
93
+ labelBg: '#171923',
72
94
  }
73
95
 
74
- /** Read a CSS custom property value from :root (resolves color-mix, etc.). */
75
- function readCSSVar(name: string, fallback: string): string {
76
- const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
77
- return v || fallback
96
+ let cachedThemeVars: RendererThemeVars = themeFallbacks
97
+ let themeObserverStarted = false
98
+
99
+ function refreshThemeVars(): void {
100
+ if (typeof document === 'undefined') return
101
+ const styles = getComputedStyle(document.documentElement)
102
+ cachedThemeVars = {
103
+ canvasBg: styles.getPropertyValue('--bg-main').trim() || themeFallbacks.canvasBg,
104
+ nodeBg: styles.getPropertyValue('--bg-element').trim() || themeFallbacks.nodeBg,
105
+ accent: styles.getPropertyValue('--accent').trim() || themeFallbacks.accent,
106
+ labelBg: styles.getPropertyValue('--chakra-colors-gray-900').trim() || themeFallbacks.labelBg,
107
+ }
108
+ }
109
+
110
+ function getThemeVars(): RendererThemeVars {
111
+ if (!themeObserverStarted && typeof document !== 'undefined') {
112
+ themeObserverStarted = true
113
+ refreshThemeVars()
114
+ const update = () => refreshThemeVars()
115
+ const mo = new MutationObserver(update)
116
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] })
117
+ window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener?.('change', update)
118
+ window.matchMedia?.('(prefers-color-scheme: light)').addEventListener?.('change', update)
119
+ }
120
+ return cachedThemeVars
78
121
  }
79
122
 
80
123
  // ── Geometry helpers ───────────────────────────────────────────────
@@ -133,14 +176,6 @@ function rectsOverlap(a: ScreenRect, b: ScreenRect): boolean {
133
176
  return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
134
177
  }
135
178
 
136
- function worldToScreen(matrix: DOMMatrix, x: number, y: number) {
137
- return new DOMPoint(x, y).matrixTransform(matrix)
138
- }
139
-
140
- function screenToWorld(matrix: DOMMatrix, x: number, y: number) {
141
- return new DOMPoint(x, y).matrixTransform(matrix.inverse())
142
- }
143
-
144
179
  export function pickEdgeLabelPosition(
145
180
  matrix: DOMMatrix,
146
181
  midX: number,
@@ -151,7 +186,8 @@ export function pickEdgeLabelPosition(
151
186
  dy: number,
152
187
  occupiedLabelRects: ScreenRect[],
153
188
  ) {
154
- const screenMid = worldToScreen(matrix, midX, midY)
189
+ const screenMidX = matrix.a * midX + matrix.c * midY + matrix.e
190
+ const screenMidY = matrix.b * midX + matrix.d * midY + matrix.f
155
191
  const screenTextW = Math.max(1, textW * matrix.a)
156
192
  const screenTextH = Math.max(1, textH * matrix.d)
157
193
  const gap = 6
@@ -161,21 +197,38 @@ export function pickEdgeLabelPosition(
161
197
  const normalY = dx / length
162
198
  const tangentX = dx / length
163
199
  const tangentY = dy / length
164
- const candidateOffsets = [
165
- { x: 0, y: 0 },
166
- { x: normalX * step, y: normalY * step },
167
- { x: -normalX * step, y: -normalY * step },
168
- { x: normalX * step * 2, y: normalY * step * 2 },
169
- { x: -normalX * step * 2, y: -normalY * step * 2 },
170
- { x: tangentX * step, y: tangentY * step },
171
- { x: -tangentX * step, y: -tangentY * step },
172
- { x: tangentX * step + normalX * step, y: tangentY * step + normalY * step },
173
- { x: -tangentX * step - normalX * step, y: -tangentY * step - normalY * step },
174
- ]
175
-
176
- for (const offset of candidateOffsets) {
177
- const centerX = screenMid.x + offset.x
178
- const centerY = screenMid.y + offset.y
200
+
201
+ for (let i = 0; i < 9; i++) {
202
+ let offsetX = 0
203
+ let offsetY = 0
204
+ if (i === 1) {
205
+ offsetX = normalX * step
206
+ offsetY = normalY * step
207
+ } else if (i === 2) {
208
+ offsetX = -normalX * step
209
+ offsetY = -normalY * step
210
+ } else if (i === 3) {
211
+ offsetX = normalX * step * 2
212
+ offsetY = normalY * step * 2
213
+ } else if (i === 4) {
214
+ offsetX = -normalX * step * 2
215
+ offsetY = -normalY * step * 2
216
+ } else if (i === 5) {
217
+ offsetX = tangentX * step
218
+ offsetY = tangentY * step
219
+ } else if (i === 6) {
220
+ offsetX = -tangentX * step
221
+ offsetY = -tangentY * step
222
+ } else if (i === 7) {
223
+ offsetX = tangentX * step + normalX * step
224
+ offsetY = tangentY * step + normalY * step
225
+ } else if (i === 8) {
226
+ offsetX = -tangentX * step - normalX * step
227
+ offsetY = -tangentY * step - normalY * step
228
+ }
229
+
230
+ const centerX = screenMidX + offsetX
231
+ const centerY = screenMidY + offsetY
179
232
  const rect: ScreenRect = {
180
233
  left: centerX - screenTextW / 2 - gap,
181
234
  top: centerY - screenTextH / 2 - gap / 2,
@@ -184,15 +237,21 @@ export function pickEdgeLabelPosition(
184
237
  }
185
238
  if (occupiedLabelRects.some((existing) => rectsOverlap(rect, existing))) continue
186
239
  occupiedLabelRects.push(rect)
187
- const worldPoint = screenToWorld(matrix, centerX, centerY)
188
- return { x: worldPoint.x, y: worldPoint.y }
240
+ const det = matrix.a * matrix.d - matrix.b * matrix.c
241
+ if (det === 0) return { x: midX, y: midY }
242
+ const translatedX = centerX - matrix.e
243
+ const translatedY = centerY - matrix.f
244
+ return {
245
+ x: (matrix.d * translatedX - matrix.c * translatedY) / det,
246
+ y: (-matrix.b * translatedX + matrix.a * translatedY) / det,
247
+ }
189
248
  }
190
249
 
191
250
  const fallbackRect: ScreenRect = {
192
- left: screenMid.x - screenTextW / 2 - gap,
193
- top: screenMid.y - screenTextH / 2 - gap / 2,
194
- right: screenMid.x + screenTextW / 2 + gap,
195
- bottom: screenMid.y + screenTextH / 2 + gap / 2,
251
+ left: screenMidX - screenTextW / 2 - gap,
252
+ top: screenMidY - screenTextH / 2 - gap / 2,
253
+ right: screenMidX + screenTextW / 2 + gap,
254
+ bottom: screenMidY + screenTextH / 2 + gap / 2,
196
255
  }
197
256
  occupiedLabelRects.push(fallbackRect)
198
257
  return { x: midX, y: midY }
@@ -301,9 +360,15 @@ function parseHex(hex: string): { r: number; g: number; b: number } {
301
360
  }
302
361
 
303
362
  /** Derive a portal tint color from the accent: same hue, very low alpha. */
363
+ const portalTintColorCache = new Map<string, string>()
304
364
  function portalTintColor(accent: string, alpha: number): string {
365
+ const cacheKey = `${accent}:${alpha}`
366
+ const cached = portalTintColorCache.get(cacheKey)
367
+ if (cached) return cached
305
368
  const { r, g, b } = parseHex(accent)
306
- return `rgba(${r},${g},${b},${alpha})`
369
+ const rgba = `rgba(${r},${g},${b},${alpha})`
370
+ portalTintColorCache.set(cacheKey, rgba)
371
+ return rgba
307
372
  }
308
373
 
309
374
  /** Draw a squiggly line from (x1, y1) to (x2, y2). */
@@ -719,24 +784,38 @@ function drawNode(
719
784
 
720
785
  // ── Edge drawing ───────────────────────────────────────────────────
721
786
 
722
- function drawEdges(
723
- ctx: CanvasRenderingContext2D,
724
- nodes: LayoutNode[],
725
- alpha: number,
726
- zoom: number,
727
- thresholds: { start: number; end: number },
728
- accent: string,
729
- labelBg: string,
730
- occupiedLabelRects: ScreenRect[],
731
- ): void {
732
- if (alpha < 0.05) return
733
- const nodeMap = new Map(nodes.map((n) => [n.id, n]))
734
- const handleUsage: Record<string, { edgeKey: string; type: 'source' | 'target'; otherNodeCoord: number }[]> = {}
787
+ interface HandleUsage {
788
+ edgeKey: string
789
+ type: 'source' | 'target'
790
+ otherNodeCoord: number
791
+ }
735
792
 
736
- nodes.forEach((node) => {
737
- node.edgesOut.forEach((edge, edgeIndex) => {
793
+ interface DrawEdgesLayoutMetadata {
794
+ nodeMap: Map<string, LayoutNode>
795
+ handleUsage: Record<string, HandleUsage[]>
796
+ handleUsageIndex: Record<string, number>
797
+ }
798
+
799
+ const drawEdgesMetadataCache = new WeakMap<LayoutNode[], DrawEdgesLayoutMetadata>()
800
+ const emptyHandleUsage: HandleUsage[] = []
801
+
802
+ function getDrawEdgesLayoutMetadata(nodes: LayoutNode[]): DrawEdgesLayoutMetadata {
803
+ const cached = drawEdgesMetadataCache.get(nodes)
804
+ if (cached) return cached
805
+
806
+ const nodeMap = new Map<string, LayoutNode>()
807
+ const handleUsage: Record<string, HandleUsage[]> = {}
808
+ const handleUsageIndex: Record<string, number> = {}
809
+
810
+ for (const node of nodes) {
811
+ nodeMap.set(node.id, node)
812
+ }
813
+
814
+ for (const node of nodes) {
815
+ for (let edgeIndex = 0; edgeIndex < node.edgesOut.length; edgeIndex++) {
816
+ const edge = node.edgesOut[edgeIndex]
738
817
  const target = nodeMap.get(edge.targetId)
739
- if (!target) return
818
+ if (!target) continue
740
819
 
741
820
  const edgeKey = `${node.id}:${edgeIndex}`
742
821
  const sourceSide = getLogicalHandleId(edge.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
@@ -761,12 +840,34 @@ function drawEdges(
761
840
  ? node.worldY + node.worldH / 2
762
841
  : node.worldX + node.worldW / 2,
763
842
  })
764
- })
765
- })
843
+ }
844
+ }
766
845
 
767
- Object.values(handleUsage).forEach((usages) => {
846
+ for (const [usageKey, usages] of Object.entries(handleUsage)) {
768
847
  usages.sort((a, b) => a.otherNodeCoord - b.otherNodeCoord)
769
- })
848
+ for (let i = 0; i < usages.length; i++) {
849
+ const usage = usages[i]
850
+ handleUsageIndex[`${usageKey}:${usage.edgeKey}:${usage.type}`] = i
851
+ }
852
+ }
853
+
854
+ const metadata = { nodeMap, handleUsage, handleUsageIndex }
855
+ drawEdgesMetadataCache.set(nodes, metadata)
856
+ return metadata
857
+ }
858
+
859
+ function drawEdges(
860
+ ctx: CanvasRenderingContext2D,
861
+ nodes: LayoutNode[],
862
+ alpha: number,
863
+ zoom: number,
864
+ thresholds: { start: number; end: number },
865
+ accent: string,
866
+ labelBg: string,
867
+ occupiedLabelRects: ScreenRect[],
868
+ ): void {
869
+ if (alpha < 0.05) return
870
+ const { nodeMap, handleUsage, handleUsageIndex } = getDrawEdgesLayoutMetadata(nodes)
770
871
 
771
872
  for (const node of nodes) {
772
873
  for (const [edgeIndex, edge] of node.edgesOut.entries()) {
@@ -807,10 +908,12 @@ function drawEdges(
807
908
  const edgeKey = `${node.id}:${edgeIndex}`
808
909
  const sourceSide = getLogicalHandleId(edge.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
809
910
  const targetSide = getLogicalHandleId(edge.targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? DEFAULT_TARGET_HANDLE_SIDE
810
- const srcGroup = handleUsage[`${node.id}-${sourceSide}`] ?? []
811
- const tgtGroup = handleUsage[`${target.id}-${targetSide}`] ?? []
812
- const sourceGroupIndex = srcGroup.findIndex((usage) => usage.edgeKey === edgeKey && usage.type === 'source')
813
- const targetGroupIndex = tgtGroup.findIndex((usage) => usage.edgeKey === edgeKey && usage.type === 'target')
911
+ const srcKey = `${node.id}-${sourceSide}`
912
+ const tgtKey = `${target.id}-${targetSide}`
913
+ const srcGroup = handleUsage[srcKey] ?? emptyHandleUsage
914
+ const tgtGroup = handleUsage[tgtKey] ?? emptyHandleUsage
915
+ const sourceGroupIndex = handleUsageIndex[`${srcKey}:${edgeKey}:source`] ?? -1
916
+ const targetGroupIndex = handleUsageIndex[`${tgtKey}:${edgeKey}:target`] ?? -1
814
917
 
815
918
  const sH = getHandlePos(
816
919
  effXSource,
@@ -1095,11 +1198,7 @@ export function renderFrame(
1095
1198
  canvasW: number,
1096
1199
  canvasH: number,
1097
1200
  ): ScreenRect[] {
1098
- // Read user-customisable CSS vars once per frame
1099
- const canvasBg = readCSSVar('--bg-main', '#0d121e')
1100
- const nodeBg = readCSSVar('--bg-element', '#2d3748')
1101
- const accent = readCSSVar('--accent', '#63b3ed')
1102
- const labelBg = readCSSVar('--chakra-colors-gray-900', '#171923')
1201
+ const { canvasBg, nodeBg, accent, labelBg } = getThemeVars()
1103
1202
 
1104
1203
  ctx.clearRect(0, 0, canvasW, canvasH)
1105
1204
 
@@ -1114,7 +1213,8 @@ export function renderFrame(
1114
1213
  ctx.scale(view.zoom, view.zoom)
1115
1214
 
1116
1215
  const thresholds = getExpandThresholds(canvasW)
1117
- const occupiedLabelRects: ScreenRect[] = []
1216
+ const occupiedLabelRects = frameLabelRects
1217
+ occupiedLabelRects.length = 0
1118
1218
 
1119
1219
  for (const group of groups) {
1120
1220
  if (!isVisible(group.worldX, group.worldY, group.worldW, group.worldH, view, canvasW, canvasH)) {