fix: Vue Node <-> Litegraph node height offset normalization (#6966)
## Summary Changes the layout store to treat node sizes as body-only measurements while LiteGraph continues to reason about full heights. DOM-driven updates are tagged with `LayoutSource.DOM`, which lets the store strip the title height exactly once before persisting. That classification (a new mutation source - `LayoutSource.DOM`) is accurate because those mutations are triggered by the browser’s layout engine via ResizeObserver, rather than by direct calls into the layout APIs (e.g., `moveNodeTo`, `useNodeDrag`). So all sources are: - `LayoutSource.DOM`: browser layout/ResizeObserver measurements that include the title bar - `LayoutSource.Vue`: direct Vue-driven mutations routed through the layout store - `LayoutSource.Canvas`: legacy LiteGraph/canvas updates that will be phased out over time - `LayoutSource.External`: for multiplayer or syncing with a when going online after making changes offline (in teams/workspace) When layout state flows back into LiteGraph we add the title height just in time for `liteNode.setSize`, so LiteGraph’s rendering stays unchanged. This makes Vue node resizing and workflow persistence deterministic - multiline widgets hold their dimensions across reloads because every path that crosses the layout/LiteGraph boundary performs the same normalization. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6966-normalize-height-at-sites-2b76d73d365081b6bcb4f4ce6a27663a) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
@@ -10,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|||||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||||
|
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||||
import { app as comfyApp } from '@/scripts/app'
|
import { app as comfyApp } from '@/scripts/app'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
@@ -38,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
|
|||||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||||
id: node.id.toString(),
|
id: node.id.toString(),
|
||||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||||
size: [node.size[0], node.size[1]] as [number, number]
|
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
]
|
||||||
}))
|
}))
|
||||||
layoutStore.initializeFromLiteGraph(nodes)
|
layoutStore.initializeFromLiteGraph(nodes)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
|
|||||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||||
|
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||||
|
|
||||||
import { CanvasPointer } from './CanvasPointer'
|
import { CanvasPointer } from './CanvasPointer'
|
||||||
import type { ContextMenu } from './ContextMenu'
|
import type { ContextMenu } from './ContextMenu'
|
||||||
@@ -4043,16 +4044,25 @@ export class LGraphCanvas
|
|||||||
|
|
||||||
// TODO: Report failures, i.e. `failedNodes`
|
// TODO: Report failures, i.e. `failedNodes`
|
||||||
|
|
||||||
const newPositions = created.map((node) => ({
|
const newPositions = created
|
||||||
nodeId: String(node.id),
|
.filter((item): item is LGraphNode => item instanceof LGraphNode)
|
||||||
bounds: {
|
.map((node) => {
|
||||||
x: node.pos[0],
|
const fullHeight = node.size?.[1] ?? 200
|
||||||
y: node.pos[1],
|
const layoutHeight = LiteGraph.vueNodesMode
|
||||||
width: node.size?.[0] ?? 100,
|
? removeNodeTitleHeight(fullHeight)
|
||||||
height: node.size?.[1] ?? 200
|
: fullHeight
|
||||||
}
|
return {
|
||||||
}))
|
nodeId: String(node.id),
|
||||||
|
bounds: {
|
||||||
|
x: node.pos[0],
|
||||||
|
y: node.pos[1],
|
||||||
|
width: node.size?.[0] ?? 100,
|
||||||
|
height: layoutHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||||
|
|
||||||
this.selectItems(created)
|
this.selectItems(created)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { computed, customRef, ref } from 'vue'
|
|||||||
import type { ComputedRef, Ref } from 'vue'
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
|
|
||||||
|
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||||
|
|
||||||
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
|
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
|
||||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||||
import type {
|
import type {
|
||||||
@@ -1414,8 +1416,8 @@ class LayoutStoreImpl implements LayoutStore {
|
|||||||
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
|
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
|
||||||
if (updates.length === 0) return
|
if (updates.length === 0) return
|
||||||
|
|
||||||
// Set source to Vue for these DOM-driven updates
|
|
||||||
const originalSource = this.currentSource
|
const originalSource = this.currentSource
|
||||||
|
const shouldNormalizeHeights = originalSource === LayoutSource.DOM
|
||||||
this.currentSource = LayoutSource.Vue
|
this.currentSource = LayoutSource.Vue
|
||||||
|
|
||||||
const nodeIds: NodeId[] = []
|
const nodeIds: NodeId[] = []
|
||||||
@@ -1426,8 +1428,15 @@ class LayoutStoreImpl implements LayoutStore {
|
|||||||
if (!ynode) continue
|
if (!ynode) continue
|
||||||
const currentLayout = yNodeToLayout(ynode)
|
const currentLayout = yNodeToLayout(ynode)
|
||||||
|
|
||||||
|
const normalizedBounds = shouldNormalizeHeights
|
||||||
|
? {
|
||||||
|
...bounds,
|
||||||
|
height: removeNodeTitleHeight(bounds.height)
|
||||||
|
}
|
||||||
|
: bounds
|
||||||
|
|
||||||
boundsRecord[nodeId] = {
|
boundsRecord[nodeId] = {
|
||||||
bounds,
|
bounds: normalizedBounds,
|
||||||
previousBounds: currentLayout.bounds
|
previousBounds: currentLayout.bounds
|
||||||
}
|
}
|
||||||
nodeIds.push(nodeId)
|
nodeIds.push(nodeId)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'
|
|||||||
|
|
||||||
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||||
|
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for syncing LiteGraph with the Layout system
|
* Composable for syncing LiteGraph with the Layout system
|
||||||
@@ -43,12 +44,13 @@ export function useLayoutSync() {
|
|||||||
liteNode.pos[1] = layout.position.y
|
liteNode.pos[1] = layout.position.y
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetHeight = addNodeTitleHeight(layout.size.height)
|
||||||
if (
|
if (
|
||||||
liteNode.size[0] !== layout.size.width ||
|
liteNode.size[0] !== layout.size.width ||
|
||||||
liteNode.size[1] !== layout.size.height
|
liteNode.size[1] !== targetHeight
|
||||||
) {
|
) {
|
||||||
// Use setSize() to trigger onResize callback
|
// Use setSize() to trigger onResize callback
|
||||||
liteNode.setSize([layout.size.width, layout.size.height])
|
liteNode.setSize([layout.size.width, targetHeight])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { ComputedRef, Ref } from 'vue'
|
|||||||
export enum LayoutSource {
|
export enum LayoutSource {
|
||||||
Canvas = 'canvas',
|
Canvas = 'canvas',
|
||||||
Vue = 'vue',
|
Vue = 'vue',
|
||||||
|
DOM = 'dom',
|
||||||
External = 'external'
|
External = 'external'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
src/renderer/core/layout/utils/nodeSizeUtil.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||||
|
|
||||||
|
export const removeNodeTitleHeight = (height: number) =>
|
||||||
|
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
|
||||||
|
|
||||||
|
export const addNodeTitleHeight = (height: number) =>
|
||||||
|
height + LiteGraph.NODE_TITLE_HEIGHT
|
||||||
@@ -107,7 +107,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
|||||||
x: topLeftCanvas.x,
|
x: topLeftCanvas.x,
|
||||||
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
|
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
|
||||||
width: Math.max(0, width),
|
width: Math.max(0, width),
|
||||||
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
|
height: Math.max(0, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
let updates = updatesByType.get(elementType)
|
let updates = updatesByType.get(elementType)
|
||||||
@@ -123,8 +123,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set source to Vue before processing DOM-driven updates
|
layoutStore.setSource(LayoutSource.DOM)
|
||||||
layoutStore.setSource(LayoutSource.Vue)
|
|
||||||
|
|
||||||
// Flush per-type
|
// Flush per-type
|
||||||
for (const [type, updates] of updatesByType) {
|
for (const [type, updates] of updatesByType) {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||||
import {
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||||
type LayoutChange,
|
import type { LayoutChange, NodeLayout } from '@/renderer/core/layout/types'
|
||||||
LayoutSource,
|
|
||||||
type NodeLayout
|
|
||||||
} from '@/renderer/core/layout/types'
|
|
||||||
|
|
||||||
describe('layoutStore CRDT operations', () => {
|
describe('layoutStore CRDT operations', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -304,4 +302,108 @@ describe('layoutStore CRDT operations', () => {
|
|||||||
expect(recentOps.length).toBeGreaterThanOrEqual(1)
|
expect(recentOps.length).toBeGreaterThanOrEqual(1)
|
||||||
expect(recentOps[0].type).toBe('moveNode')
|
expect(recentOps[0].type).toBe('moveNode')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('normalizes DOM-sourced heights before storing', () => {
|
||||||
|
const nodeId = 'dom-node'
|
||||||
|
const layout = createTestNode(nodeId)
|
||||||
|
|
||||||
|
layoutStore.applyOperation({
|
||||||
|
type: 'createNode',
|
||||||
|
entity: 'node',
|
||||||
|
nodeId,
|
||||||
|
layout,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: LayoutSource.External,
|
||||||
|
actor: 'test'
|
||||||
|
})
|
||||||
|
|
||||||
|
layoutStore.setSource(LayoutSource.DOM)
|
||||||
|
layoutStore.batchUpdateNodeBounds([
|
||||||
|
{
|
||||||
|
nodeId,
|
||||||
|
bounds: {
|
||||||
|
x: layout.bounds.x,
|
||||||
|
y: layout.bounds.y,
|
||||||
|
width: layout.size.width,
|
||||||
|
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||||
|
expect(nodeRef.value?.size.height).toBe(layout.size.height)
|
||||||
|
expect(nodeRef.value?.size.width).toBe(layout.size.width)
|
||||||
|
expect(nodeRef.value?.position).toEqual(layout.position)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes very small DOM-sourced heights safely', () => {
|
||||||
|
const nodeId = 'small-dom-node'
|
||||||
|
const layout = createTestNode(nodeId)
|
||||||
|
layout.size.height = 10
|
||||||
|
|
||||||
|
layoutStore.applyOperation({
|
||||||
|
type: 'createNode',
|
||||||
|
entity: 'node',
|
||||||
|
nodeId,
|
||||||
|
layout,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: LayoutSource.External,
|
||||||
|
actor: 'test'
|
||||||
|
})
|
||||||
|
|
||||||
|
layoutStore.setSource(LayoutSource.DOM)
|
||||||
|
layoutStore.batchUpdateNodeBounds([
|
||||||
|
{
|
||||||
|
nodeId,
|
||||||
|
bounds: {
|
||||||
|
x: layout.bounds.x,
|
||||||
|
y: layout.bounds.y,
|
||||||
|
width: layout.size.width,
|
||||||
|
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||||
|
expect(nodeRef.value?.size.height).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles undefined NODE_TITLE_HEIGHT without NaN results', () => {
|
||||||
|
const nodeId = 'undefined-title-height'
|
||||||
|
const layout = createTestNode(nodeId)
|
||||||
|
|
||||||
|
layoutStore.applyOperation({
|
||||||
|
type: 'createNode',
|
||||||
|
entity: 'node',
|
||||||
|
nodeId,
|
||||||
|
layout,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: LayoutSource.External,
|
||||||
|
actor: 'test'
|
||||||
|
})
|
||||||
|
|
||||||
|
const originalTitleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||||
|
// @ts-expect-error – intentionally simulate undefined runtime value
|
||||||
|
LiteGraph.NODE_TITLE_HEIGHT = undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
layoutStore.setSource(LayoutSource.DOM)
|
||||||
|
layoutStore.batchUpdateNodeBounds([
|
||||||
|
{
|
||||||
|
nodeId,
|
||||||
|
bounds: {
|
||||||
|
x: layout.bounds.x,
|
||||||
|
y: layout.bounds.y,
|
||||||
|
width: layout.size.width,
|
||||||
|
height: layout.size.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||||
|
expect(nodeRef.value?.size.height).toBe(layout.size.height)
|
||||||
|
} finally {
|
||||||
|
LiteGraph.NODE_TITLE_HEIGHT = originalTitleHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { LayoutSource } from '@/renderer/core/layout/types'
|
|||||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||||
|
|
||||||
// Mock the layout mutations module
|
// Mock the layout mutations module
|
||||||
vi.mock('@/renderer/core/layout/operations/layoutMutations')
|
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
|
||||||
|
useLayoutMutations: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
const mockedUseLayoutMutations = vi.mocked(useLayoutMutations)
|
const mockedUseLayoutMutations = vi.mocked(useLayoutMutations)
|
||||||
|
|
||||||
|
|||||||