diff --git a/src/composables/canvas/useSelectionToolboxPosition.ts b/src/composables/canvas/useSelectionToolboxPosition.ts index 8e327b05f..7a8d199f4 100644 --- a/src/composables/canvas/useSelectionToolboxPosition.ts +++ b/src/composables/canvas/useSelectionToolboxPosition.ts @@ -3,8 +3,10 @@ import type { Ref } from 'vue' import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync' import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' -import { createBounds } from '@/lib/litegraph/src/litegraph' +import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { useCanvasStore } from '@/stores/graphStore' +import { computeUnionBounds } from '@/utils/mathUtil' /** * Manages the position of the selection toolbox independently. @@ -34,17 +36,30 @@ export function useSelectionToolboxPosition( } visible.value = true - const bounds = createBounds(selectableItems) - if (!bounds) { - return + // Get bounds from layout store for all selected items + const allBounds: ReadOnlyRect[] = [] + for (const item of selectableItems) { + if (typeof item.id === 'string') { + const layout = layoutStore.getNodeLayoutRef(item.id).value + if (layout) { + allBounds.push([ + layout.bounds.x, + layout.bounds.y, + layout.bounds.width, + layout.bounds.height + ]) + } + } } - const [xBase, y, width] = bounds + // Compute union bounds + const unionBounds = computeUnionBounds(allBounds) + if (!unionBounds) return worldPosition.value = { - x: xBase + width / 2, - y: y + x: unionBounds.x + unionBounds.width / 2, + y: unionBounds.y } updateTransform() diff --git a/src/renderer/core/layout/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts index 10cb420eb..385e09bcd 100644 --- a/src/renderer/core/layout/store/layoutStore.ts +++ b/src/renderer/core/layout/store/layoutStore.ts @@ -1425,6 +1425,33 @@ class LayoutStoreImpl implements LayoutStore { getStateAsUpdate(): Uint8Array { return Y.encodeStateAsUpdate(this.ydoc) } + + /** + * Batch update node bounds using Yjs transaction for atomicity. + */ + batchUpdateNodeBounds( + updates: Array<{ nodeId: NodeId; bounds: Bounds }> + ): void { + if (updates.length === 0) return + + // Set source to Vue for these DOM-driven updates + const originalSource = this.currentSource + this.currentSource = LayoutSource.Vue + + this.ydoc.transact(() => { + for (const { nodeId, bounds } of updates) { + const ynode = this.ynodes.get(nodeId) + if (!ynode) continue + + this.spatialIndex.update(nodeId, bounds) + ynode.set('bounds', bounds) + ynode.set('size', { width: bounds.width, height: bounds.height }) + } + }, this.currentActor) + + // Restore original source + this.currentSource = originalSource + } } // Create singleton instance diff --git a/src/renderer/core/layout/types.ts b/src/renderer/core/layout/types.ts index fdfcff430..dfc246996 100644 --- a/src/renderer/core/layout/types.ts +++ b/src/renderer/core/layout/types.ts @@ -320,4 +320,9 @@ export interface LayoutStore { setActor(actor: string): void getCurrentSource(): LayoutSource getCurrentActor(): string + + // Batch updates + batchUpdateNodeBounds( + updates: Array<{ nodeId: NodeId; bounds: Bounds }> + ): void } diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index bcd2a3636..a5833ee63 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -121,6 +121,7 @@ import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayo import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD' import { cn } from '@/utils/tailwindUtil' +import { useVueElementTracking } from '../composables/useVueNodeResizeTracking' import NodeContent from './NodeContent.vue' import NodeHeader from './NodeHeader.vue' import NodeSlots from './NodeSlots.vue' @@ -153,6 +154,8 @@ const emit = defineEmits<{ 'update:title': [nodeId: string, newTitle: string] }>() +useVueElementTracking(props.nodeData.id, 'node') + // Inject selection state from parent const selectedNodeIds = inject(SelectedNodeIdsKey) if (!selectedNodeIds) { diff --git a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts new file mode 100644 index 000000000..afb26e4d6 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts @@ -0,0 +1,145 @@ +/** + * Generic Vue Element Tracking System + * + * Automatically tracks DOM size and position changes for Vue-rendered elements + * and syncs them to the layout store. Uses a single shared ResizeObserver for + * performance, with elements identified by configurable data attributes. + * + * Supports different element types (nodes, slots, widgets, etc.) with + * customizable data attributes and update handlers. + */ +import { getCurrentInstance, onMounted, onUnmounted } from 'vue' + +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import type { Bounds, NodeId } from '@/renderer/core/layout/types' + +/** + * Configuration for different types of tracked elements + */ +interface ElementTrackingConfig { + /** Data attribute name (e.g., 'nodeId') */ + dataAttribute: string + /** Handler for processing bounds updates */ + updateHandler: (updates: Array<{ id: string; bounds: Bounds }>) => void +} + +/** + * Registry of tracking configurations by element type + */ +const trackingConfigs: Map = new Map([ + [ + 'node', + { + dataAttribute: 'nodeId', + updateHandler: (updates) => { + const nodeUpdates = updates.map(({ id, bounds }) => ({ + nodeId: id as NodeId, + bounds + })) + layoutStore.batchUpdateNodeBounds(nodeUpdates) + } + } + ] +]) + +// Single ResizeObserver instance for all Vue elements +const resizeObserver = new ResizeObserver((entries) => { + // Group updates by element type + const updatesByType = new Map>() + + for (const entry of entries) { + if (!(entry.target instanceof HTMLElement)) continue + const element = entry.target + + // Find which type this element belongs to + let elementType: string | undefined + let elementId: string | undefined + + for (const [type, config] of trackingConfigs) { + const id = element.dataset[config.dataAttribute] + if (id) { + elementType = type + elementId = id + break + } + } + + if (!elementType || !elementId) continue + + const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0] + const rect = element.getBoundingClientRect() + + const bounds: Bounds = { + x: rect.left, + y: rect.top, + width, + height + } + + if (!updatesByType.has(elementType)) { + updatesByType.set(elementType, []) + } + const updates = updatesByType.get(elementType) + if (updates) { + updates.push({ id: elementId, bounds }) + } + } + + // Process updates by type + for (const [type, updates] of updatesByType) { + const config = trackingConfigs.get(type) + if (config && updates.length > 0) { + config.updateHandler(updates) + } + } +}) + +/** + * Tracks DOM element size/position changes for a Vue component and syncs to layout store + * + * Sets up automatic ResizeObserver tracking when the component mounts and cleans up + * when unmounted. The tracked element is identified by a data attribute set on the + * component's root DOM element. + * + * @param appIdentifier - Application-level identifier for this tracked element (not a DOM ID) + * Example: node ID like 'node-123', widget ID like 'widget-456' + * @param trackingType - Type of element being tracked, determines which tracking config to use + * Example: 'node' for Vue nodes, 'widget' for UI widgets + * + * @example + * ```ts + * // Track a Vue node component with ID 'my-node-123' + * useVueElementTracking('my-node-123', 'node') + * + * // Would set data-node-id="my-node-123" on the component's root element + * // and sync size changes to layoutStore.batchUpdateNodeBounds() + * ``` + */ +export function useVueElementTracking( + appIdentifier: string, + trackingType: string +) { + onMounted(() => { + const element = getCurrentInstance()?.proxy?.$el + if (!(element instanceof HTMLElement) || !appIdentifier) return + + const config = trackingConfigs.get(trackingType) + if (config) { + // Set the appropriate data attribute + element.dataset[config.dataAttribute] = appIdentifier + resizeObserver.observe(element) + } + }) + + onUnmounted(() => { + const element = getCurrentInstance()?.proxy?.$el + if (!(element instanceof HTMLElement)) return + + const config = trackingConfigs.get(trackingType) + if (config) { + // Remove the data attribute + delete element.dataset[config.dataAttribute] + resizeObserver.unobserve(element) + } + }) +} diff --git a/src/utils/mathUtil.ts b/src/utils/mathUtil.ts index 2fb74e1cf..953901dbf 100644 --- a/src/utils/mathUtil.ts +++ b/src/utils/mathUtil.ts @@ -1,3 +1,6 @@ +import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' +import type { Bounds } from '@/renderer/core/layout/types' + /** * Finds the greatest common divisor (GCD) for two numbers. * @@ -5,12 +8,12 @@ * @param b - The second number. * @returns The GCD of the two numbers. */ -const gcd = (a: number, b: number): number => { +export const gcd = (a: number, b: number): number => { return b === 0 ? a : gcd(b, a % b) } /** - * Finds the least common multiple (LCM) for two numbers. + * Finds the export least common multiple (LCM) for two numbers. * * @param a - The first number. * @param b - The second number. @@ -19,3 +22,48 @@ const gcd = (a: number, b: number): number => { export const lcm = (a: number, b: number): number => { return Math.abs(a * b) / gcd(a, b) } + +/** + * Computes the union (bounding box) of multiple rectangles using a single-pass algorithm. + * + * Finds the minimum and maximum x/y coordinates across all rectangles to create + * a single bounding rectangle that contains all input rectangles. Optimized for + * performance with V8-friendly tuple access patterns. + * + * @param rectangles - Array of rectangle tuples in [x, y, width, height] format + * @returns Bounds object with union rectangle, or null if no rectangles provided + */ +export function computeUnionBounds( + rectangles: readonly ReadOnlyRect[] +): Bounds | null { + const n = rectangles.length + if (n === 0) { + return null + } + + const r0 = rectangles[0] + let minX = r0[0] + let minY = r0[1] + let maxX = minX + r0[2] + let maxY = minY + r0[3] + + for (let i = 1; i < n; i++) { + const r = rectangles[i] + const x1 = r[0] + const y1 = r[1] + const x2 = x1 + r[2] + const y2 = y1 + r[3] + + if (x1 < minX) minX = x1 + if (y1 < minY) minY = y1 + if (x2 > maxX) maxX = x2 + if (y2 > maxY) maxY = y2 + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + } +} diff --git a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts new file mode 100644 index 000000000..b389a7a3c --- /dev/null +++ b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts @@ -0,0 +1,121 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' +import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' + +vi.mock( + '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking', + () => ({ + useVueElementTracking: vi.fn() + }) +) + +vi.mock('@/composables/useErrorHandling', () => ({ + useErrorHandling: () => ({ + toastErrorHandler: vi.fn() + }) +})) + +vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({ + useNodeLayout: () => ({ + position: { x: 100, y: 50 }, + startDrag: vi.fn(), + handleDrag: vi.fn(), + endDrag: vi.fn() + }) +})) + +vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({ + useLOD: () => ({ + lodLevel: { value: 0 }, + shouldRenderWidgets: { value: true }, + shouldRenderSlots: { value: true }, + shouldRenderContent: { value: false }, + lodCssClass: { value: '' } + }), + LODLevel: { MINIMAL: 0 } +})) + +describe('LGraphNode', () => { + const mockNodeData: VueNodeData = { + id: 'test-node-123', + title: 'Test Node', + type: 'TestNode', + mode: 0, + flags: {}, + inputs: [], + outputs: [], + widgets: [], + selected: false, + executing: false + } + + const mountLGraphNode = (props: any, selectedNodeIds = new Set()) => { + return mount(LGraphNode, { + props, + global: { + provide: { + [SelectedNodeIdsKey as symbol]: ref(selectedNodeIds) + } + } + }) + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should call resize tracking composable with node ID', async () => { + const { useVueElementTracking } = vi.mocked( + await import( + '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' + ) + ) + + mountLGraphNode({ nodeData: mockNodeData }) + + expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node') + }) + + it('should render with data-node-id attribute', () => { + const wrapper = mountLGraphNode({ nodeData: mockNodeData }) + + expect(wrapper.attributes('data-node-id')).toBe('test-node-123') + }) + + it('should render node title', () => { + const wrapper = mountLGraphNode({ nodeData: mockNodeData }) + + expect(wrapper.text()).toContain('Test Node') + }) + + it('should apply selected styling when selected prop is true', () => { + const wrapper = mountLGraphNode( + { nodeData: mockNodeData, selected: true }, + new Set(['test-node-123']) + ) + + expect(wrapper.classes()).toContain('border-blue-500') + expect(wrapper.classes()).toContain('ring-2') + expect(wrapper.classes()).toContain('ring-blue-300') + }) + + it('should apply executing animation when executing prop is true', () => { + const wrapper = mountLGraphNode({ nodeData: mockNodeData, executing: true }) + + expect(wrapper.classes()).toContain('animate-pulse') + }) + + it('should emit node-click event on pointer down', async () => { + const wrapper = mountLGraphNode({ nodeData: mockNodeData }) + + await wrapper.trigger('pointerdown') + + expect(wrapper.emitted('node-click')).toHaveLength(1) + expect(wrapper.emitted('node-click')?.[0]).toHaveLength(2) + expect(wrapper.emitted('node-click')?.[0][1]).toEqual(mockNodeData) + }) +}) diff --git a/tests-ui/tests/utils/mathUtil.test.ts b/tests-ui/tests/utils/mathUtil.test.ts new file mode 100644 index 000000000..c4cb16dd8 --- /dev/null +++ b/tests-ui/tests/utils/mathUtil.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' +import { computeUnionBounds, gcd, lcm } from '@/utils/mathUtil' + +describe('mathUtil', () => { + describe('gcd', () => { + it('should compute greatest common divisor correctly', () => { + expect(gcd(48, 18)).toBe(6) + expect(gcd(100, 25)).toBe(25) + expect(gcd(17, 13)).toBe(1) + expect(gcd(0, 5)).toBe(5) + expect(gcd(5, 0)).toBe(5) + }) + }) + + describe('lcm', () => { + it('should compute least common multiple correctly', () => { + expect(lcm(4, 6)).toBe(12) + expect(lcm(15, 20)).toBe(60) + expect(lcm(7, 11)).toBe(77) + }) + }) + + describe('computeUnionBounds', () => { + it('should return null for empty input', () => { + expect(computeUnionBounds([])).toBe(null) + }) + + // Tests for tuple format (ReadOnlyRect) + it('should work with ReadOnlyRect tuple format', () => { + const tuples: ReadOnlyRect[] = [ + [10, 20, 30, 40] as const, // bounds: 10,20 to 40,60 + [50, 10, 20, 30] as const // bounds: 50,10 to 70,40 + ] + + const result = computeUnionBounds(tuples) + + expect(result).toEqual({ + x: 10, // min(10, 50) + y: 10, // min(20, 10) + width: 60, // max(40, 70) - min(10, 50) = 70 - 10 + height: 50 // max(60, 40) - min(20, 10) = 60 - 10 + }) + }) + + it('should handle single ReadOnlyRect tuple', () => { + const tuple: ReadOnlyRect = [10, 20, 30, 40] as const + const result = computeUnionBounds([tuple]) + + expect(result).toEqual({ + x: 10, + y: 20, + width: 30, + height: 40 + }) + }) + + it('should handle tuple format with negative dimensions', () => { + const tuples: ReadOnlyRect[] = [ + [100, 50, -20, -10] as const, // x+width=80, y+height=40 + [90, 45, 15, 20] as const // x+width=105, y+height=65 + ] + + const result = computeUnionBounds(tuples) + + expect(result).toEqual({ + x: 90, // min(100, 90) + y: 45, // min(50, 45) + width: 15, // max(80, 105) - min(100, 90) = 105 - 90 + height: 20 // max(40, 65) - min(50, 45) = 65 - 45 + }) + }) + + it('should maintain optimal performance with SoA tuples', () => { + // Test that array access is as expected for typical selection sizes + const tuples: ReadOnlyRect[] = Array.from( + { length: 10 }, + (_, i) => + [ + i * 20, // x + i * 15, // y + 100 + i * 5, // width + 80 + i * 3 // height + ] as const + ) + + const result = computeUnionBounds(tuples) + + expect(result).toBeTruthy() + expect(result!.x).toBe(0) + expect(result!.y).toBe(0) + expect(result!.width).toBe(325) + expect(result!.height).toBe(242) + }) + }) +})