diff --git a/src/renderer/core/layout/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts index 15d4128142..f32a101f74 100644 --- a/src/renderer/core/layout/store/layoutStore.ts +++ b/src/renderer/core/layout/store/layoutStore.ts @@ -10,6 +10,7 @@ import * as Y from 'yjs' import { ACTOR_CONFIG } from '@/renderer/core/layout/constants' import type { + BatchUpdateBoundsOperation, CreateLinkOperation, CreateNodeOperation, CreateRerouteOperation, @@ -864,6 +865,12 @@ class LayoutStoreImpl implements LayoutStore { case 'deleteNode': this.handleDeleteNode(operation as DeleteNodeOperation, change) break + case 'batchUpdateBounds': + this.handleBatchUpdateBounds( + operation as BatchUpdateBoundsOperation, + change + ) + break case 'createLink': this.handleCreateLink(operation as CreateLinkOperation, change) break @@ -1092,6 +1099,38 @@ class LayoutStoreImpl implements LayoutStore { change.nodeIds.push(operation.nodeId) } + private handleBatchUpdateBounds( + operation: BatchUpdateBoundsOperation, + change: LayoutChange + ): void { + const spatialUpdates: Array<{ nodeId: NodeId; bounds: Bounds }> = [] + + for (const nodeId of operation.nodeIds) { + const data = operation.bounds[nodeId] + const ynode = this.ynodes.get(nodeId) + if (!ynode || !data) continue + + ynode.set('position', { x: data.bounds.x, y: data.bounds.y }) + ynode.set('size', { + width: data.bounds.width, + height: data.bounds.height + }) + ynode.set('bounds', data.bounds) + + spatialUpdates.push({ nodeId, bounds: data.bounds }) + change.nodeIds.push(nodeId) + } + + // Batch update spatial index for better performance + if (spatialUpdates.length > 0) { + this.spatialIndex.batchUpdate(spatialUpdates) + } + + if (change.nodeIds.length) { + change.type = 'update' + } + } + private handleCreateLink( operation: CreateLinkOperation, change: LayoutChange @@ -1372,19 +1411,38 @@ class LayoutStoreImpl implements LayoutStore { 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 + const nodeIds: NodeId[] = [] + const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {} - this.spatialIndex.update(nodeId, bounds) - ynode.set('bounds', bounds) - ynode.set('position', { x: bounds.x, y: bounds.y }) - ynode.set('size', { width: bounds.width, height: bounds.height }) + for (const { nodeId, bounds } of updates) { + const ynode = this.ynodes.get(nodeId) + if (!ynode) continue + const currentLayout = yNodeToLayout(ynode) + + boundsRecord[nodeId] = { + bounds, + previousBounds: currentLayout.bounds } - }, this.currentActor) + nodeIds.push(nodeId) + } + + if (!nodeIds.length) { + this.currentSource = originalSource + return + } + + const operation: BatchUpdateBoundsOperation = { + type: 'batchUpdateBounds', + entity: 'node', + nodeIds, + bounds: boundsRecord, + timestamp: Date.now(), + source: this.currentSource, + actor: this.currentActor + } + + this.applyOperation(operation) - // Restore original source this.currentSource = originalSource } } diff --git a/src/renderer/core/layout/sync/useLinkLayoutSync.ts b/src/renderer/core/layout/sync/useLinkLayoutSync.ts index 565022582d..b1cbc5fa0b 100644 --- a/src/renderer/core/layout/sync/useLinkLayoutSync.ts +++ b/src/renderer/core/layout/sync/useLinkLayoutSync.ts @@ -267,6 +267,11 @@ export function useLinkLayoutSync() { case 'resizeNode': recomputeLinksForNode(parseInt(change.operation.nodeId)) break + case 'batchUpdateBounds': + for (const nodeId of change.operation.nodeIds) { + recomputeLinksForNode(parseInt(nodeId)) + } + break case 'createLink': recomputeLinkById(change.operation.linkId) break diff --git a/src/renderer/core/layout/types.ts b/src/renderer/core/layout/types.ts index ae2b761398..5703751284 100644 --- a/src/renderer/core/layout/types.ts +++ b/src/renderer/core/layout/types.ts @@ -122,7 +122,7 @@ type OperationType = | 'createNode' | 'deleteNode' | 'setNodeVisibility' - | 'batchUpdate' + | 'batchUpdateBounds' | 'createLink' | 'deleteLink' | 'createReroute' @@ -184,10 +184,11 @@ interface SetNodeVisibilityOperation extends NodeOpBase { /** * Batch update operation for atomic multi-property changes */ -interface BatchUpdateOperation extends NodeOpBase { - type: 'batchUpdate' - updates: Partial - previousValues: Partial +export interface BatchUpdateBoundsOperation extends OperationMeta { + entity: 'node' + type: 'batchUpdateBounds' + nodeIds: NodeId[] + bounds: Record } /** @@ -244,7 +245,7 @@ export type LayoutOperation = | CreateNodeOperation | DeleteNodeOperation | SetNodeVisibilityOperation - | BatchUpdateOperation + | BatchUpdateBoundsOperation | CreateLinkOperation | DeleteLinkOperation | CreateRerouteOperation diff --git a/src/renderer/core/spatial/SpatialIndex.ts b/src/renderer/core/spatial/SpatialIndex.ts index 92a4a627f9..8541352c1c 100644 --- a/src/renderer/core/spatial/SpatialIndex.ts +++ b/src/renderer/core/spatial/SpatialIndex.ts @@ -55,6 +55,17 @@ export class SpatialIndexManager { this.invalidateCache() } + /** + * Batch update multiple nodes' bounds in the spatial index + * More efficient than calling update() multiple times as it only invalidates cache once + */ + batchUpdate(updates: Array<{ nodeId: NodeId; bounds: Bounds }>): void { + for (const { nodeId, bounds } of updates) { + this.quadTree.update(nodeId, bounds) + } + this.invalidateCache() + } + /** * Remove a node from the spatial index */ diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 1213936cb9..54d8f6948a 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -153,6 +153,7 @@ import { import { cn } from '@/utils/tailwindUtil' import { useNodeResize } from '../composables/useNodeResize' +import { calculateIntrinsicSize } from '../utils/calculateIntrinsicSize' import NodeContent from './NodeContent.vue' import NodeHeader from './NodeHeader.vue' import NodeSlots from './NodeSlots.vue' @@ -245,7 +246,7 @@ onErrorCaptured((error) => { }) // Use layout system for node position and dragging -const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id) +const { position, size, zIndex } = useNodeLayout(() => nodeData.id) const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions( () => nodeData, handleNodeSelect @@ -267,13 +268,19 @@ const handleContextMenu = (event: MouseEvent) => { } onMounted(() => { - if (size.value && transformState?.camera) { - const scale = transformState.camera.z - const screenSize = { - width: size.value.width * scale, - height: size.value.height * scale - } - resize(screenSize) + // Set initial DOM size from layout store, but respect intrinsic content minimum + if (size.value && nodeContainerRef.value && transformState) { + const intrinsicMin = calculateIntrinsicSize( + nodeContainerRef.value, + transformState.camera.z + ) + + // Use the larger of stored size or intrinsic minimum + const finalWidth = Math.max(size.value.width, intrinsicMin.width) + const finalHeight = Math.max(size.value.height, intrinsicMin.height) + + nodeContainerRef.value.style.width = `${finalWidth}px` + nodeContainerRef.value.style.height = `${finalHeight}px` } }) diff --git a/src/renderer/extensions/vueNodes/composables/useNodeResize.ts b/src/renderer/extensions/vueNodes/composables/useNodeResize.ts index a216646e23..4cecefc67b 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodeResize.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodeResize.ts @@ -2,6 +2,7 @@ import { useEventListener } from '@vueuse/core' import { ref } from 'vue' import type { TransformState } from '@/renderer/core/layout/injectionKeys' +import { calculateIntrinsicSize } from '@/renderer/extensions/vueNodes/utils/calculateIntrinsicSize' interface Size { width: number @@ -53,29 +54,16 @@ export function useNodeResize( if (!(nodeElement instanceof HTMLElement)) return const rect = nodeElement.getBoundingClientRect() - - // Calculate intrinsic content size once at start - const originalWidth = nodeElement.style.width - const originalHeight = nodeElement.style.height - nodeElement.style.width = 'auto' - nodeElement.style.height = 'auto' - - const intrinsicRect = nodeElement.getBoundingClientRect() - - // Restore original size - nodeElement.style.width = originalWidth - nodeElement.style.height = originalHeight - - // Convert to canvas coordinates using transform state const scale = transformState.camera.z + + // Calculate current size in canvas coordinates resizeStartSize.value = { width: rect.width / scale, height: rect.height / scale } - intrinsicMinSize.value = { - width: intrinsicRect.width / scale, - height: intrinsicRect.height / scale - } + + // Calculate intrinsic content size (minimum based on content) + intrinsicMinSize.value = calculateIntrinsicSize(nodeElement, scale) const handlePointerMove = (moveEvent: PointerEvent) => { if ( diff --git a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts index 052c7c1b0a..2ecbd32d90 100644 --- a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts +++ b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts @@ -167,14 +167,6 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter) { mutations.moveNode(nodeId, position) } - /** - * Update node size - */ - function resize(newSize: { width: number; height: number }) { - mutations.setSource(LayoutSource.Vue) - mutations.resizeNode(nodeId, newSize) - } - return { // Reactive state (via customRef) layoutRef, @@ -187,7 +179,6 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter) { // Mutations moveTo, - resize, // Drag handlers startDrag, diff --git a/src/renderer/extensions/vueNodes/utils/calculateIntrinsicSize.test.ts b/src/renderer/extensions/vueNodes/utils/calculateIntrinsicSize.test.ts new file mode 100644 index 0000000000..cd34f8a3fb --- /dev/null +++ b/src/renderer/extensions/vueNodes/utils/calculateIntrinsicSize.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { calculateIntrinsicSize } from './calculateIntrinsicSize' + +describe('calculateIntrinsicSize', () => { + let element: HTMLElement + + beforeEach(() => { + // Create a test element + element = document.createElement('div') + element.style.width = '200px' + element.style.height = '100px' + document.body.appendChild(element) + }) + + afterEach(() => { + document.body.removeChild(element) + }) + + it('should calculate intrinsic size and convert to canvas coordinates', () => { + // Mock getBoundingClientRect to return specific dimensions + const originalGetBoundingClientRect = element.getBoundingClientRect + element.getBoundingClientRect = () => ({ + width: 300, + height: 150, + top: 0, + left: 0, + bottom: 150, + right: 300, + x: 0, + y: 0, + toJSON: () => ({}) + }) + + const scale = 2 + const result = calculateIntrinsicSize(element, scale) + + // Should divide by scale to convert from screen to canvas coordinates + expect(result).toEqual({ + width: 150, // 300 / 2 + height: 75 // 150 / 2 + }) + + element.getBoundingClientRect = originalGetBoundingClientRect + }) + + it('should restore original size after measuring', () => { + const originalWidth = element.style.width + const originalHeight = element.style.height + + element.getBoundingClientRect = () => ({ + width: 300, + height: 150, + top: 0, + left: 0, + bottom: 150, + right: 300, + x: 0, + y: 0, + toJSON: () => ({}) + }) + + calculateIntrinsicSize(element, 1) + + // Should restore original styles + expect(element.style.width).toBe(originalWidth) + expect(element.style.height).toBe(originalHeight) + }) + + it('should handle scale of 1 correctly', () => { + element.getBoundingClientRect = () => ({ + width: 400, + height: 200, + top: 0, + left: 0, + bottom: 200, + right: 400, + x: 0, + y: 0, + toJSON: () => ({}) + }) + + const result = calculateIntrinsicSize(element, 1) + + expect(result).toEqual({ + width: 400, + height: 200 + }) + }) + + it('should handle fractional scales', () => { + element.getBoundingClientRect = () => ({ + width: 300, + height: 150, + top: 0, + left: 0, + bottom: 150, + right: 300, + x: 0, + y: 0, + toJSON: () => ({}) + }) + + const result = calculateIntrinsicSize(element, 0.5) + + expect(result).toEqual({ + width: 600, // 300 / 0.5 + height: 300 // 150 / 0.5 + }) + }) + + it('should temporarily set width and height to auto during measurement', () => { + let widthDuringMeasurement = '' + let heightDuringMeasurement = '' + + element.getBoundingClientRect = function (this: HTMLElement) { + widthDuringMeasurement = this.style.width + heightDuringMeasurement = this.style.height + return { + width: 300, + height: 150, + top: 0, + left: 0, + bottom: 150, + right: 300, + x: 0, + y: 0, + toJSON: () => ({}) + } + } + + calculateIntrinsicSize(element, 1) + + // During measurement, styles should be set to 'auto' + expect(widthDuringMeasurement).toBe('auto') + expect(heightDuringMeasurement).toBe('auto') + }) +}) diff --git a/src/renderer/extensions/vueNodes/utils/calculateIntrinsicSize.ts b/src/renderer/extensions/vueNodes/utils/calculateIntrinsicSize.ts new file mode 100644 index 0000000000..36da7cd827 --- /dev/null +++ b/src/renderer/extensions/vueNodes/utils/calculateIntrinsicSize.ts @@ -0,0 +1,34 @@ +/** + * Calculate the intrinsic (minimum content-based) size of a node element + * + * Temporarily sets the element to auto-size to measure its natural content dimensions, + * then converts from screen coordinates to canvas coordinates using the camera scale. + * + * @param element - The node element to measure + * @param scale - Camera zoom scale for coordinate conversion + * @returns The intrinsic minimum size in canvas coordinates + */ +export function calculateIntrinsicSize( + element: HTMLElement, + scale: number +): { width: number; height: number } { + // Store original size to restore later + const originalWidth = element.style.width + const originalHeight = element.style.height + + // Temporarily set to auto to measure natural content size + element.style.width = 'auto' + element.style.height = 'auto' + + const intrinsicRect = element.getBoundingClientRect() + + // Restore original size + element.style.width = originalWidth + element.style.height = originalHeight + + // Convert from screen coordinates to canvas coordinates + return { + width: intrinsicRect.width / scale, + height: intrinsicRect.height / scale + } +} diff --git a/tests-ui/tests/renderer/core/layout/layoutStore.test.ts b/tests-ui/tests/renderer/core/layout/layoutStore.test.ts index db3f9960d5..993bde8664 100644 --- a/tests-ui/tests/renderer/core/layout/layoutStore.test.ts +++ b/tests-ui/tests/renderer/core/layout/layoutStore.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { @@ -165,10 +165,11 @@ describe('layoutStore CRDT operations', () => { actor: layoutStore.getCurrentActor() }) - // Wait for async notification - await new Promise((resolve) => setTimeout(resolve, 50)) + // Wait for onChange callback to be called (uses setTimeout internally) + await vi.waitFor(() => { + expect(changes.length).toBeGreaterThanOrEqual(1) + }) - expect(changes.length).toBeGreaterThanOrEqual(1) const lastChange = changes[changes.length - 1] expect(lastChange.source).toBe('vue') expect(lastChange.operation.actor).toBe('user-123') @@ -176,6 +177,48 @@ describe('layoutStore CRDT operations', () => { unsubscribe() }) + it('should emit change when batch updating node bounds', async () => { + const nodeId = 'test-node-6' + const layout = createTestNode(nodeId) + + layoutStore.applyOperation({ + type: 'createNode', + entity: 'node', + nodeId, + layout, + timestamp: Date.now(), + source: LayoutSource.External, + actor: 'test' + }) + + const changes: LayoutChange[] = [] + const unsubscribe = layoutStore.onChange((change) => { + changes.push(change) + }) + + const newBounds = { x: 40, y: 60, width: 220, height: 120 } + layoutStore.batchUpdateNodeBounds([{ nodeId, bounds: newBounds }]) + + // Wait for onChange callback to be called (uses setTimeout internally) + await vi.waitFor(() => { + expect(changes.length).toBeGreaterThan(0) + const lastChange = changes[changes.length - 1] + expect(lastChange.operation.type).toBe('batchUpdateBounds') + }) + + const lastChange = changes[changes.length - 1] + if (lastChange.operation.type === 'batchUpdateBounds') { + expect(lastChange.nodeIds).toContain(nodeId) + expect(lastChange.operation.bounds[nodeId]?.bounds).toEqual(newBounds) + } + + const nodeRef = layoutStore.getNodeLayoutRef(nodeId) + expect(nodeRef.value?.position).toEqual({ x: 40, y: 60 }) + expect(nodeRef.value?.size).toEqual({ width: 220, height: 120 }) + + unsubscribe() + }) + it('should query nodes by spatial bounds', () => { const nodes = [ { id: 'node-a', position: { x: 0, y: 0 } },