fix: emit layout change for batch node bounds (#5939)

## Summary

Fixes issue where node size changes are not serialized by routing
DOM-driven node bounds updates through a single CRDT operation so Vue
node geometry stays synchronized with LiteGraph.

## Changes

- **What**: Added `BatchUpdateBoundsOperation` to the layout store,
applied it via the existing Yjs pipeline, notified link sync to
recompute touched nodes, and covered the path with a regression test

## Review Focus

Correctness of the new batch operation when multiple nodes update
simultaneously, especially remote replay/undo scenarios and link
geometry recomputation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5939-fix-emit-layout-change-for-batch-node-bounds-2846d73d365081db8f8cca5bf7b85308)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-10-10 20:47:12 -07:00
committed by GitHub
parent 7e3c04399a
commit e6534f17e6
29 changed files with 331 additions and 55 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -12,6 +12,7 @@ import * as Y from 'yjs'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
BatchUpdateBoundsOperation,
Bounds,
CreateLinkOperation,
CreateNodeOperation,
@@ -871,6 +872,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
@@ -1099,6 +1106,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
@@ -1379,19 +1418,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
}
}

View File

@@ -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

View File

@@ -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<NodeLayout>
previousValues: Partial<NodeLayout>
export interface BatchUpdateBoundsOperation extends OperationMeta {
entity: 'node'
type: 'batchUpdateBounds'
nodeIds: NodeId[]
bounds: Record<NodeId, { bounds: Bounds; previousBounds: Bounds }>
}
/**
@@ -244,7 +245,7 @@ export type LayoutOperation =
| CreateNodeOperation
| DeleteNodeOperation
| SetNodeVisibilityOperation
| BatchUpdateOperation
| BatchUpdateBoundsOperation
| CreateLinkOperation
| DeleteLinkOperation
| CreateRerouteOperation

View File

@@ -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
*/

View File

@@ -154,6 +154,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'
@@ -247,7 +248,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
@@ -269,13 +270,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`
}
})

View File

@@ -4,6 +4,7 @@ import { ref } from 'vue'
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { calculateIntrinsicSize } from '@/renderer/extensions/vueNodes/utils/calculateIntrinsicSize'
interface Size {
width: number
@@ -64,29 +65,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 (

View File

@@ -258,14 +258,6 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
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,
@@ -278,7 +270,6 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
// Mutations
moveTo,
resize,
// Drag handlers
startDrag,

View File

@@ -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')
})
})

View File

@@ -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
}
}

View File

@@ -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 } },