mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
ResizeObserver was converting DOM positions back to canvas coordinates using clientPosToCanvasPos, which depends on the current canvas scale/offset. During graph transitions (e.g. entering a subgraph from a draft-loaded workflow), the canvas viewport was stale (still had the parent graph's zoom level), causing all subgraph node positions to be permanently corrupted. The corruption accumulated across page refreshes as drafts saved the corrupted positions. Use the layout store's existing position (initialized from LiteGraph) instead of reverse-converting DOM screen coordinates. Only fall back to getBoundingClientRect conversion for nodes not yet in the layout store. Fixes subgraph node positions drifting apart or compressing together when entering a subgraph after draft workflow reload.
282 lines
7.4 KiB
TypeScript
282 lines
7.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { ref } from 'vue'
|
|
import type { Ref } from 'vue'
|
|
|
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
|
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
|
|
|
|
type ResizeEntryLike = Pick<
|
|
ResizeObserverEntry,
|
|
| 'target'
|
|
| 'borderBoxSize'
|
|
| 'contentBoxSize'
|
|
| 'devicePixelContentBoxSize'
|
|
| 'contentRect'
|
|
>
|
|
|
|
const resizeObserverState = vi.hoisted(() => {
|
|
const state = {
|
|
callback: null as ResizeObserverCallback | null,
|
|
observe: vi.fn<(element: Element) => void>(),
|
|
unobserve: vi.fn<(element: Element) => void>(),
|
|
disconnect: vi.fn<() => void>()
|
|
}
|
|
|
|
const MockResizeObserver: typeof ResizeObserver = class MockResizeObserver implements ResizeObserver {
|
|
observe = state.observe
|
|
unobserve = state.unobserve
|
|
disconnect = state.disconnect
|
|
|
|
constructor(callback: ResizeObserverCallback) {
|
|
state.callback = callback
|
|
}
|
|
}
|
|
|
|
globalThis.ResizeObserver = MockResizeObserver
|
|
|
|
return state
|
|
})
|
|
|
|
const testState = vi.hoisted(() => ({
|
|
linearMode: false,
|
|
nodeLayouts: new Map<NodeId, NodeLayout>(),
|
|
batchUpdateNodeBounds: vi.fn(),
|
|
setSource: vi.fn(),
|
|
syncNodeSlotLayoutsFromDOM: vi.fn()
|
|
}))
|
|
|
|
vi.mock('@vueuse/core', () => ({
|
|
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible')
|
|
}))
|
|
|
|
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
|
useCanvasStore: () => ({
|
|
linearMode: testState.linearMode
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
|
useSharedCanvasPositionConversion: () => ({
|
|
clientPosToCanvasPos: ([x, y]: [number, number]) => [x, y]
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
|
layoutStore: {
|
|
batchUpdateNodeBounds: testState.batchUpdateNodeBounds,
|
|
setSource: testState.setSource,
|
|
getNodeLayoutRef: (nodeId: NodeId): Ref<NodeLayout | null> =>
|
|
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null)
|
|
}
|
|
}))
|
|
|
|
vi.mock('./useSlotElementTracking', () => ({
|
|
syncNodeSlotLayoutsFromDOM: testState.syncNodeSlotLayoutsFromDOM
|
|
}))
|
|
|
|
import './useVueNodeResizeTracking'
|
|
|
|
function createResizeEntry(options?: {
|
|
nodeId?: NodeId
|
|
width?: number
|
|
height?: number
|
|
left?: number
|
|
top?: number
|
|
collapsed?: boolean
|
|
}) {
|
|
const {
|
|
nodeId = 'test-node',
|
|
width = 240,
|
|
height = 180,
|
|
left = 100,
|
|
top = 200,
|
|
collapsed = false
|
|
} = options ?? {}
|
|
|
|
const element = document.createElement('div')
|
|
element.dataset.nodeId = nodeId
|
|
if (collapsed) {
|
|
element.dataset.collapsed = ''
|
|
}
|
|
const rectSpy = vi.fn(() => new DOMRect(left, top, width, height))
|
|
element.getBoundingClientRect = rectSpy
|
|
const boxSizes = [{ inlineSize: width, blockSize: height }]
|
|
|
|
const entry = {
|
|
target: element,
|
|
borderBoxSize: boxSizes,
|
|
contentBoxSize: boxSizes,
|
|
devicePixelContentBoxSize: boxSizes,
|
|
contentRect: new DOMRect(left, top, width, height)
|
|
} satisfies ResizeEntryLike
|
|
|
|
return {
|
|
entry,
|
|
rectSpy
|
|
}
|
|
}
|
|
|
|
function createObserverMock(): ResizeObserver {
|
|
return {
|
|
observe: vi.fn(),
|
|
unobserve: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
|
|
function seedNodeLayout(options: {
|
|
nodeId: NodeId
|
|
left: number
|
|
top: number
|
|
width: number
|
|
height: number
|
|
}) {
|
|
const { nodeId, left, top, width, height } = options
|
|
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
|
const contentHeight = height - titleHeight
|
|
|
|
testState.nodeLayouts.set(nodeId, {
|
|
id: nodeId,
|
|
position: { x: left, y: top + titleHeight },
|
|
size: { width, height: contentHeight },
|
|
zIndex: 0,
|
|
visible: true,
|
|
bounds: {
|
|
x: left,
|
|
y: top + titleHeight,
|
|
width,
|
|
height: contentHeight
|
|
}
|
|
})
|
|
}
|
|
|
|
describe('useVueNodeResizeTracking', () => {
|
|
beforeEach(() => {
|
|
testState.linearMode = false
|
|
testState.nodeLayouts.clear()
|
|
testState.batchUpdateNodeBounds.mockReset()
|
|
testState.setSource.mockReset()
|
|
testState.syncNodeSlotLayoutsFromDOM.mockReset()
|
|
resizeObserverState.observe.mockReset()
|
|
resizeObserverState.unobserve.mockReset()
|
|
resizeObserverState.disconnect.mockReset()
|
|
})
|
|
|
|
it('skips repeated no-op resize entries after first measurement', () => {
|
|
const nodeId = 'test-node'
|
|
const width = 240
|
|
const height = 180
|
|
const left = 100
|
|
const top = 200
|
|
const { entry, rectSpy } = createResizeEntry({
|
|
nodeId,
|
|
width,
|
|
height,
|
|
left,
|
|
top
|
|
})
|
|
|
|
seedNodeLayout({ nodeId, left, top, width, height })
|
|
|
|
resizeObserverState.callback?.([entry], createObserverMock())
|
|
|
|
// When layout store already has correct position, getBoundingClientRect
|
|
// is not needed — position is read from the store instead.
|
|
expect(rectSpy).not.toHaveBeenCalled()
|
|
expect(testState.setSource).not.toHaveBeenCalled()
|
|
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
|
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
|
|
|
|
testState.setSource.mockReset()
|
|
testState.batchUpdateNodeBounds.mockReset()
|
|
testState.syncNodeSlotLayoutsFromDOM.mockReset()
|
|
|
|
resizeObserverState.callback?.([entry], createObserverMock())
|
|
|
|
expect(rectSpy).not.toHaveBeenCalled()
|
|
expect(testState.setSource).not.toHaveBeenCalled()
|
|
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
|
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves layout store position when size matches but DOM position differs', () => {
|
|
const nodeId = 'test-node'
|
|
const width = 240
|
|
const height = 180
|
|
const { entry, rectSpy } = createResizeEntry({
|
|
nodeId,
|
|
width,
|
|
height,
|
|
left: 100,
|
|
top: 200
|
|
})
|
|
|
|
seedNodeLayout({
|
|
nodeId,
|
|
left: 90,
|
|
top: 190,
|
|
width,
|
|
height
|
|
})
|
|
|
|
resizeObserverState.callback?.([entry], createObserverMock())
|
|
|
|
// Position from DOM should NOT override layout store position
|
|
expect(rectSpy).not.toHaveBeenCalled()
|
|
expect(testState.setSource).not.toHaveBeenCalled()
|
|
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('updates node bounds + slot layouts when size changes', () => {
|
|
const nodeId = 'test-node'
|
|
const { entry } = createResizeEntry({
|
|
nodeId,
|
|
width: 240,
|
|
height: 180,
|
|
left: 100,
|
|
top: 200
|
|
})
|
|
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
|
|
|
seedNodeLayout({
|
|
nodeId,
|
|
left: 100,
|
|
top: 200,
|
|
width: 220,
|
|
height: 140
|
|
})
|
|
|
|
resizeObserverState.callback?.([entry], createObserverMock())
|
|
|
|
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
|
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
|
|
{
|
|
nodeId,
|
|
bounds: {
|
|
x: 100,
|
|
y: 200 + titleHeight,
|
|
width: 240,
|
|
height: 180
|
|
}
|
|
}
|
|
])
|
|
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
|
})
|
|
|
|
it('resyncs slot anchors for collapsed nodes without writing bounds', () => {
|
|
const nodeId = 'test-node'
|
|
const { entry, rectSpy } = createResizeEntry({
|
|
nodeId,
|
|
collapsed: true
|
|
})
|
|
|
|
resizeObserverState.callback?.([entry], createObserverMock())
|
|
|
|
expect(rectSpy).not.toHaveBeenCalled()
|
|
expect(testState.setSource).not.toHaveBeenCalled()
|
|
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
|
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
|
})
|
|
})
|