Skip to content

Commit b72e22f

Browse files
christian-byrneAustinMrozbenceruleanluclaude
authored
Add Centralized Vue Node Size/Pos Tracking (#5442)
* add dom element resize observer registry for vue node components * Update src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts Co-authored-by: AustinMroz <[email protected]> * refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates * chore: make TransformState interface non-exported to satisfy knip pre-push * Revert "chore: make TransformState interface non-exported to satisfy knip pre-push" This reverts commit 110ecf3. * Revert "refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates" This reverts commit 4287526. * [refactor] Improve resize tracking composable documentation and test utilities - Rename parameters in useVueElementTracking for clarity (appIdentifier, trackingType) - Add comprehensive docstring with examples to prevent DOM attribute confusion - Extract mountLGraphNode test utility to eliminate repetitive mock setup - Add technical implementation notes documenting optimization decisions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * remove typo comment * convert to functional bounds collection * remove inline import * add interfaces for bounds mutations * remove change log * fix bounds collection when vue nodes turned off * fix title offset on y * move from resize observer to selection toolbox bounds --------- Co-authored-by: AustinMroz <[email protected]> Co-authored-by: Benjamin Lu <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 5f045b3 commit b72e22f

File tree

8 files changed

+491
-9
lines changed

8 files changed

+491
-9
lines changed

src/composables/canvas/useSelectionToolboxPosition.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ import type { Ref } from 'vue'
33

44
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
55
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
6-
import { createBounds } from '@/lib/litegraph/src/litegraph'
6+
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
7+
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
8+
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
9+
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
710
import { useCanvasStore } from '@/stores/graphStore'
11+
import { computeUnionBounds } from '@/utils/mathUtil'
812

913
/**
1014
* Manages the position of the selection toolbox independently.
@@ -16,6 +20,7 @@ export function useSelectionToolboxPosition(
1620
const canvasStore = useCanvasStore()
1721
const lgCanvas = canvasStore.getCanvas()
1822
const { getSelectableItems } = useSelectedLiteGraphItems()
23+
const { shouldRenderVueNodes } = useVueFeatureFlags()
1924

2025
// World position of selection center
2126
const worldPosition = ref({ x: 0, y: 0 })
@@ -34,17 +39,40 @@ export function useSelectionToolboxPosition(
3439
}
3540

3641
visible.value = true
37-
const bounds = createBounds(selectableItems)
3842

39-
if (!bounds) {
40-
return
43+
// Get bounds for all selected items
44+
const allBounds: ReadOnlyRect[] = []
45+
for (const item of selectableItems) {
46+
// Skip items without valid IDs
47+
if (item.id == null) continue
48+
49+
if (shouldRenderVueNodes.value && typeof item.id === 'string') {
50+
// Use layout store for Vue nodes (only works with string IDs)
51+
const layout = layoutStore.getNodeLayoutRef(item.id).value
52+
if (layout) {
53+
allBounds.push([
54+
layout.bounds.x,
55+
layout.bounds.y,
56+
layout.bounds.width,
57+
layout.bounds.height
58+
])
59+
}
60+
} else {
61+
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
62+
if (item instanceof LGraphNode) {
63+
const bounds = item.getBounding()
64+
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
65+
}
66+
}
4167
}
4268

43-
const [xBase, y, width] = bounds
69+
// Compute union bounds
70+
const unionBounds = computeUnionBounds(allBounds)
71+
if (!unionBounds) return
4472

4573
worldPosition.value = {
46-
x: xBase + width / 2,
47-
y: y
74+
x: unionBounds.x + unionBounds.width / 2,
75+
y: unionBounds.y - 10
4876
}
4977

5078
updateTransform()

src/renderer/core/layout/store/layoutStore.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
LayoutOperation,
2020
MoveNodeOperation,
2121
MoveRerouteOperation,
22+
NodeBoundsUpdate,
2223
ResizeNodeOperation,
2324
SetNodeZIndexOperation
2425
} from '@/renderer/core/layout/types'
@@ -1425,6 +1426,31 @@ class LayoutStoreImpl implements LayoutStore {
14251426
getStateAsUpdate(): Uint8Array {
14261427
return Y.encodeStateAsUpdate(this.ydoc)
14271428
}
1429+
1430+
/**
1431+
* Batch update node bounds using Yjs transaction for atomicity.
1432+
*/
1433+
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
1434+
if (updates.length === 0) return
1435+
1436+
// Set source to Vue for these DOM-driven updates
1437+
const originalSource = this.currentSource
1438+
this.currentSource = LayoutSource.Vue
1439+
1440+
this.ydoc.transact(() => {
1441+
for (const { nodeId, bounds } of updates) {
1442+
const ynode = this.ynodes.get(nodeId)
1443+
if (!ynode) continue
1444+
1445+
this.spatialIndex.update(nodeId, bounds)
1446+
ynode.set('bounds', bounds)
1447+
ynode.set('size', { width: bounds.width, height: bounds.height })
1448+
}
1449+
}, this.currentActor)
1450+
1451+
// Restore original source
1452+
this.currentSource = originalSource
1453+
}
14281454
}
14291455

14301456
// Create singleton instance

src/renderer/core/layout/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ export interface Bounds {
3131
height: number
3232
}
3333

34+
export interface NodeBoundsUpdate {
35+
nodeId: NodeId
36+
bounds: Bounds
37+
}
38+
3439
export type NodeId = string
3540
export type LinkId = number
3641
export type RerouteId = number
@@ -320,4 +325,9 @@ export interface LayoutStore {
320325
setActor(actor: string): void
321326
getCurrentSource(): LayoutSource
322327
getCurrentActor(): string
328+
329+
// Batch updates
330+
batchUpdateNodeBounds(
331+
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
332+
): void
323333
}

src/renderer/extensions/vueNodes/components/LGraphNode.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@
113113
<script setup lang="ts">
114114
import { computed, inject, onErrorCaptured, ref, toRef, watch } from 'vue'
115115
116-
// Import the VueNodeData type
117116
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
118117
import { useErrorHandling } from '@/composables/useErrorHandling'
119118
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -122,6 +121,7 @@ import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayo
122121
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
123122
import { cn } from '@/utils/tailwindUtil'
124123
124+
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
125125
import NodeContent from './NodeContent.vue'
126126
import NodeHeader from './NodeHeader.vue'
127127
import NodeSlots from './NodeSlots.vue'
@@ -154,6 +154,8 @@ const emit = defineEmits<{
154154
'update:title': [nodeId: string, newTitle: string]
155155
}>()
156156
157+
useVueElementTracking(props.nodeData.id, 'node')
158+
157159
// Inject selection state from parent
158160
const selectedNodeIds = inject(SelectedNodeIdsKey)
159161
if (!selectedNodeIds) {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* Generic Vue Element Tracking System
3+
*
4+
* Automatically tracks DOM size and position changes for Vue-rendered elements
5+
* and syncs them to the layout store. Uses a single shared ResizeObserver for
6+
* performance, with elements identified by configurable data attributes.
7+
*
8+
* Supports different element types (nodes, slots, widgets, etc.) with
9+
* customizable data attributes and update handlers.
10+
*/
11+
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
12+
13+
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
14+
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
15+
16+
/**
17+
* Generic update item for element bounds tracking
18+
*/
19+
interface ElementBoundsUpdate {
20+
/** Element identifier (could be nodeId, widgetId, slotId, etc.) */
21+
id: string
22+
/** Updated bounds */
23+
bounds: Bounds
24+
}
25+
26+
/**
27+
* Configuration for different types of tracked elements
28+
*/
29+
interface ElementTrackingConfig {
30+
/** Data attribute name (e.g., 'nodeId') */
31+
dataAttribute: string
32+
/** Handler for processing bounds updates */
33+
updateHandler: (updates: ElementBoundsUpdate[]) => void
34+
}
35+
36+
/**
37+
* Registry of tracking configurations by element type
38+
*/
39+
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
40+
[
41+
'node',
42+
{
43+
dataAttribute: 'nodeId',
44+
updateHandler: (updates) => {
45+
const nodeUpdates = updates.map(({ id, bounds }) => ({
46+
nodeId: id as NodeId,
47+
bounds
48+
}))
49+
layoutStore.batchUpdateNodeBounds(nodeUpdates)
50+
}
51+
}
52+
]
53+
])
54+
55+
// Single ResizeObserver instance for all Vue elements
56+
const resizeObserver = new ResizeObserver((entries) => {
57+
// Group updates by element type
58+
const updatesByType = new Map<string, ElementBoundsUpdate[]>()
59+
60+
for (const entry of entries) {
61+
if (!(entry.target instanceof HTMLElement)) continue
62+
const element = entry.target
63+
64+
// Find which type this element belongs to
65+
let elementType: string | undefined
66+
let elementId: string | undefined
67+
68+
for (const [type, config] of trackingConfigs) {
69+
const id = element.dataset[config.dataAttribute]
70+
if (id) {
71+
elementType = type
72+
elementId = id
73+
break
74+
}
75+
}
76+
77+
if (!elementType || !elementId) continue
78+
79+
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]
80+
const rect = element.getBoundingClientRect()
81+
82+
const bounds: Bounds = {
83+
x: rect.left,
84+
y: rect.top,
85+
width,
86+
height: height
87+
}
88+
89+
if (!updatesByType.has(elementType)) {
90+
updatesByType.set(elementType, [])
91+
}
92+
const updates = updatesByType.get(elementType)
93+
if (updates) {
94+
updates.push({ id: elementId, bounds })
95+
}
96+
}
97+
98+
// Process updates by type
99+
for (const [type, updates] of updatesByType) {
100+
const config = trackingConfigs.get(type)
101+
if (config && updates.length > 0) {
102+
config.updateHandler(updates)
103+
}
104+
}
105+
})
106+
107+
/**
108+
* Tracks DOM element size/position changes for a Vue component and syncs to layout store
109+
*
110+
* Sets up automatic ResizeObserver tracking when the component mounts and cleans up
111+
* when unmounted. The tracked element is identified by a data attribute set on the
112+
* component's root DOM element.
113+
*
114+
* @param appIdentifier - Application-level identifier for this tracked element (not a DOM ID)
115+
* Example: node ID like 'node-123', widget ID like 'widget-456'
116+
* @param trackingType - Type of element being tracked, determines which tracking config to use
117+
* Example: 'node' for Vue nodes, 'widget' for UI widgets
118+
*
119+
* @example
120+
* ```ts
121+
* // Track a Vue node component with ID 'my-node-123'
122+
* useVueElementTracking('my-node-123', 'node')
123+
*
124+
* // Would set data-node-id="my-node-123" on the component's root element
125+
* // and sync size changes to layoutStore.batchUpdateNodeBounds()
126+
* ```
127+
*/
128+
export function useVueElementTracking(
129+
appIdentifier: string,
130+
trackingType: string
131+
) {
132+
onMounted(() => {
133+
const element = getCurrentInstance()?.proxy?.$el
134+
if (!(element instanceof HTMLElement) || !appIdentifier) return
135+
136+
const config = trackingConfigs.get(trackingType)
137+
if (config) {
138+
// Set the appropriate data attribute
139+
element.dataset[config.dataAttribute] = appIdentifier
140+
resizeObserver.observe(element)
141+
}
142+
})
143+
144+
onUnmounted(() => {
145+
const element = getCurrentInstance()?.proxy?.$el
146+
if (!(element instanceof HTMLElement)) return
147+
148+
const config = trackingConfigs.get(trackingType)
149+
if (config) {
150+
// Remove the data attribute
151+
delete element.dataset[config.dataAttribute]
152+
resizeObserver.unobserve(element)
153+
}
154+
})
155+
}

src/utils/mathUtil.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
2+
import type { Bounds } from '@/renderer/core/layout/types'
3+
14
/**
25
* Finds the greatest common divisor (GCD) for two numbers.
36
*
47
* @param a - The first number.
58
* @param b - The second number.
69
* @returns The GCD of the two numbers.
710
*/
8-
const gcd = (a: number, b: number): number => {
11+
export const gcd = (a: number, b: number): number => {
912
return b === 0 ? a : gcd(b, a % b)
1013
}
1114

@@ -19,3 +22,48 @@ const gcd = (a: number, b: number): number => {
1922
export const lcm = (a: number, b: number): number => {
2023
return Math.abs(a * b) / gcd(a, b)
2124
}
25+
26+
/**
27+
* Computes the union (bounding box) of multiple rectangles using a single-pass algorithm.
28+
*
29+
* Finds the minimum and maximum x/y coordinates across all rectangles to create
30+
* a single bounding rectangle that contains all input rectangles. Optimized for
31+
* performance with V8-friendly tuple access patterns.
32+
*
33+
* @param rectangles - Array of rectangle tuples in [x, y, width, height] format
34+
* @returns Bounds object with union rectangle, or null if no rectangles provided
35+
*/
36+
export function computeUnionBounds(
37+
rectangles: readonly ReadOnlyRect[]
38+
): Bounds | null {
39+
const n = rectangles.length
40+
if (n === 0) {
41+
return null
42+
}
43+
44+
const r0 = rectangles[0]
45+
let minX = r0[0]
46+
let minY = r0[1]
47+
let maxX = minX + r0[2]
48+
let maxY = minY + r0[3]
49+
50+
for (let i = 1; i < n; i++) {
51+
const r = rectangles[i]
52+
const x1 = r[0]
53+
const y1 = r[1]
54+
const x2 = x1 + r[2]
55+
const y2 = y1 + r[3]
56+
57+
if (x1 < minX) minX = x1
58+
if (y1 < minY) minY = y1
59+
if (x2 > maxX) maxX = x2
60+
if (y2 > maxY) maxY = y2
61+
}
62+
63+
return {
64+
x: minX,
65+
y: minY,
66+
width: maxX - minX,
67+
height: maxY - minY
68+
}
69+
}

0 commit comments

Comments
 (0)