mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
fix: collapsed node connection link positions (#10641)
## Summary Fix connection links rendering at wrong positions when nodes are collapsed in Vue nodes mode. ## Changes - **What**: Fall back to `clientPosToCanvasPos` for collapsed node slot positioning since DOM-relative scale derivation is invalid when layout store preserves expanded size. Clear stale `cachedOffset` on collapse and defer sync when canvas is not yet initialized. - 3 unit tests for collapsed node slot sync fallback (clientPosToCanvasPos, cachedOffset clearing, canvas-not-initialized deferral) - 3 E2E tests for collapsed node link positions (within bounds, after position change, after expand recovery) ## Review Focus - `clientPosToCanvasPos` fallback is safe for collapsed nodes because collapse is user-initiated (no loading-time transform desync risk that #9121 originally fixed) - `cachedOffset` clearing prevents stale expanded-state offsets during collapsed node drag - Regression from #9121 (DOM-relative scale) combined with #9680 (collapsed node ResizeObserver skip) ## Screenshots Before <img width="1030" height="434" alt="image" src="https://github.com/user-attachments/assets/2f8b8a1f-ed22-4588-ab62-72b89880e53f" /> After <img width="1029" height="476" alt="image" src="https://github.com/user-attachments/assets/52dbbf7c-61ed-465b-ae19-a9781513e7e8" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10641-fix-collapsed-node-connection-link-positions-3316d73d365081f4aee3fecb92c83b91) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
This commit is contained in:
68
browser_tests/tests/collapsedNodeLinks.spec.ts
Normal file
68
browser_tests/tests/collapsedNodeLinks.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { assertNodeSlotsWithinBounds } from '../fixtures/utils/slotBoundsUtil'
|
||||
|
||||
const NODE_ID = '3'
|
||||
const NODE_TITLE = 'KSampler'
|
||||
|
||||
test.describe(
|
||||
'Collapsed node link positions',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('link endpoints stay within collapsed node bounds', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
|
||||
test('links follow collapsed node after drag', async ({ comfyPage }) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const box = await node.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2,
|
||||
box!.y + box!.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2 + 200,
|
||||
box!.y + box!.height / 2 + 100,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
|
||||
test('links recover correct positions after expand', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -7,6 +7,10 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
import {
|
||||
expectSlotsWithinBounds,
|
||||
measureNodeSlotOffsets
|
||||
} from '../../fixtures/utils/slotBoundsUtil'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
@@ -19,20 +23,6 @@ const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
isSubgraph: boolean
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -604,71 +594,19 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for slot elements to appear in DOM
|
||||
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
|
||||
|
||||
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph._nodes
|
||||
const slotData: NodeSlotData[] = []
|
||||
const nodeIds = await comfyPage.page.evaluate(() =>
|
||||
window
|
||||
.app!.graph._nodes.filter((n) => !!n.isSubgraphNode?.())
|
||||
.map((n) => String(n.id))
|
||||
)
|
||||
expect(nodeIds.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeId = String(node.id)
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
) as HTMLElement | null
|
||||
if (!nodeEl) continue
|
||||
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
if (slotEls.length === 0) continue
|
||||
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
|
||||
slots.push({
|
||||
key: slotKey,
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
slotData.push({
|
||||
nodeId,
|
||||
isSubgraph: !!node.isSubgraphNode?.(),
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
return slotData
|
||||
})
|
||||
|
||||
const subgraphNodes = result.filter((n) => n.isSubgraph)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of subgraphNodes) {
|
||||
for (const slot of node.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
|
||||
}
|
||||
for (const nodeId of nodeIds) {
|
||||
const data = await measureNodeSlotOffsets(comfyPage.page, nodeId)
|
||||
expect(data, `Node ${nodeId} not found in DOM`).not.toBeNull()
|
||||
expectSlotsWithinBounds(data!, SLOT_BOUNDS_MARGIN, `Node ${nodeId}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user