Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts
jaeone94 df42b7a2a8 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>
2026-04-01 13:49:12 +09:00

355 lines
11 KiB
TypeScript

/**
* Centralized Slot Element Tracking
*
* Registers slot connector DOM elements per node, measures their canvas-space
* positions in a single batched pass, and caches offsets so that node moves
* update slot positions without DOM reads.
*/
import { onMounted, onUnmounted, watch } from 'vue'
import type { Ref } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { app } from '@/scripts/app'
import type { SlotLayout } from '@/renderer/core/layout/types'
import {
isBoundsEqual,
isPointEqual,
isSizeEqual
} from '@/renderer/core/layout/utils/geometry'
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
import { createRafBatch } from '@/utils/rafBatch'
// RAF batching
const pendingNodes = new Set<string>()
const raf = createRafBatch(() => {
flushScheduledSlotLayoutSync()
})
function scheduleSlotLayoutSync(nodeId: string) {
pendingNodes.add(nodeId)
raf.schedule()
}
function shouldWaitForSlotLayouts(): boolean {
const graph = app.canvas?.graph
const hasNodes = Boolean(graph && graph._nodes && graph._nodes.length > 0)
return hasNodes && !layoutStore.hasSlotLayouts
}
function completePendingSlotSync(): void {
layoutStore.setPendingSlotSync(false)
app.canvas?.setDirty(true, true)
}
function getSlotElementRect(el: HTMLElement): DOMRect | null {
if (!el.isConnected) return null
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
return rect
}
export function requestSlotLayoutSyncForAllNodes(): void {
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
for (const nodeId of nodeSlotRegistryStore.getNodeIds()) {
scheduleSlotLayoutSync(nodeId)
}
// If no slots are currently registered, run the completion check immediately
// so pendingSlotSync can be cleared when the graph has no nodes.
if (pendingNodes.size === 0) {
flushScheduledSlotLayoutSync()
}
}
function createSlotLayout(options: {
nodeId: string
index: number
type: 'input' | 'output'
centerCanvas: { x: number; y: number }
}): SlotLayout {
const { nodeId, index, type, centerCanvas } = options
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
return {
nodeId,
index,
type,
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
}
}
export function flushScheduledSlotLayoutSync() {
if (pendingNodes.size === 0) {
// No pending nodes - check if we should wait for Vue components to mount
if (shouldWaitForSlotLayouts()) {
// Graph has nodes but no slot layouts yet - Vue hasn't mounted.
// Keep flag set so late mounts can re-assert via scheduleSlotLayoutSync()
return
}
// Either no nodes (nothing to wait for) or slot layouts already exist
// (undo/redo preserved them). Clear the flag so links can render.
completePendingSlotSync()
return
}
for (const nodeId of Array.from(pendingNodes)) {
pendingNodes.delete(nodeId)
syncNodeSlotLayoutsFromDOM(nodeId)
}
// Keep pending sync active until at least one measurable slot layout has
// been captured for the current graph.
if (shouldWaitForSlotLayouts()) return
completePendingSlotSync()
}
export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
const node = nodeSlotRegistryStore.getNode(nodeId)
if (!node) return
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) return
// Find the node's DOM element for relative offset measurement.
// Using DOM-relative measurement avoids the transform desync issue where
// lgCanvas.ds (used by clientPosToCanvasPos) can diverge from the
// TransformPane's CSS transform during workflow loading (e.g., after
// fitView or ensureCorrectLayoutScale). Both the slot and node elements
// share the same DOM transform, so their pixel difference divided by the
// effective scale yields a correct canvas-space offset regardless of
// whether the TransformPane has flushed its latest transform to the DOM.
const closestNode = node.slots
.values()
.next()
.value?.el.closest('[data-node-id]')
const nodeEl = closestNode instanceof HTMLElement ? closestNode : null
const nodeRect = nodeEl?.getBoundingClientRect()
// Collapsed nodes preserve expanded size in layoutStore, so DOM-relative
// scale derivation breaks. Fall back to clientPosToCanvasPos instead.
const isCollapsed = nodeEl?.dataset.collapsed != null
const effectiveScale =
!isCollapsed && nodeRect && nodeLayout.size.width > 0
? nodeRect.width / nodeLayout.size.width
: 0
const canvasStore = useCanvasStore()
const conv =
isCollapsed && canvasStore.canvas
? useSharedCanvasPositionConversion()
: null
if (isCollapsed && !conv) {
scheduleSlotLayoutSync(nodeId)
return
}
const batch: Array<{ key: string; layout: SlotLayout }> = []
for (const [slotKey, entry] of node.slots) {
const rect = getSlotElementRect(entry.el)
if (!rect) {
// Drop stale layout values while the slot is hidden so we don't render
// links with off-screen coordinates from a previous graph/tab state.
layoutStore.deleteSlotLayout(slotKey)
continue
}
const screenCenter: [number, number] = [
rect.left + rect.width / 2,
rect.top + rect.height / 2
]
let centerCanvas: { x: number; y: number }
if (conv) {
const [cx, cy] = conv.clientPosToCanvasPos(screenCenter)
centerCanvas = { x: cx, y: cy }
entry.cachedOffset = undefined
} else {
if (!nodeRect || effectiveScale <= 0) continue
// DOM-relative measurement: compute offset from the node element's
// top-left corner in canvas units. The node element is rendered at
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
entry.cachedOffset = {
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
y:
(screenCenter[1] - nodeRect.top) / effectiveScale -
LiteGraph.NODE_TITLE_HEIGHT
}
centerCanvas = {
x: nodeLayout.position.x + entry.cachedOffset.x,
y: nodeLayout.position.y + entry.cachedOffset.y
}
}
const nextLayout = createSlotLayout({
nodeId,
index: entry.index,
type: entry.type,
centerCanvas
})
const existingSlotLayout = layoutStore.getSlotLayout(slotKey)
if (
existingSlotLayout &&
isPointEqual(existingSlotLayout.position, nextLayout.position) &&
isBoundsEqual(existingSlotLayout.bounds, nextLayout.bounds)
) {
continue
}
batch.push({
key: slotKey,
layout: nextLayout
})
}
if (batch.length) layoutStore.batchUpdateSlotLayouts(batch)
}
function updateNodeSlotsFromCache(nodeId: string) {
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
const node = nodeSlotRegistryStore.getNode(nodeId)
if (!node) return
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) return
const batch: Array<{ key: string; layout: SlotLayout }> = []
for (const [slotKey, entry] of node.slots) {
if (!entry.cachedOffset) {
// schedule a sync to seed offset
scheduleSlotLayoutSync(nodeId)
continue
}
const centerCanvas = {
x: nodeLayout.position.x + entry.cachedOffset.x,
y: nodeLayout.position.y + entry.cachedOffset.y
}
batch.push({
key: slotKey,
layout: createSlotLayout({
nodeId,
index: entry.index,
type: entry.type,
centerCanvas
})
})
}
if (batch.length) layoutStore.batchUpdateSlotLayouts(batch)
}
export function useSlotElementTracking(options: {
nodeId: string
index: number
type: 'input' | 'output'
element: Ref<HTMLElement | null>
}) {
const { nodeId, index, type, element } = options
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
onMounted(() => {
if (!nodeId) return
const stop = watch(
element,
(el) => {
if (!el) return
const node = nodeSlotRegistryStore.ensureNode(nodeId)
if (!node.stopWatch) {
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
const stopPositionWatch = watch(
() => layoutRef.value?.position,
(newPosition, oldPosition) => {
if (!newPosition) return
if (!oldPosition || !isPointEqual(newPosition, oldPosition)) {
updateNodeSlotsFromCache(nodeId)
}
}
)
const stopSizeWatch = watch(
() => layoutRef.value?.size,
(newSize, oldSize) => {
if (!newSize) return
if (!oldSize || !isSizeEqual(newSize, oldSize)) {
scheduleSlotLayoutSync(nodeId)
}
}
)
node.stopWatch = () => {
stopPositionWatch()
stopSizeWatch()
}
}
// Register slot
const slotKey = getSlotKey(nodeId, index, type === 'input')
// Defensive cleanup: remove stale entry if it exists with different element
// This handles edge cases where Vue component reuse prevents proper unmount
const existingEntry = node.slots.get(slotKey)
if (existingEntry && existingEntry.el !== el) {
delete existingEntry.el.dataset.slotKey
layoutStore.deleteSlotLayout(slotKey)
}
el.dataset.slotKey = slotKey
node.slots.set(slotKey, { el, index, type })
// Seed initial sync from DOM
scheduleSlotLayoutSync(nodeId)
// Stop watching once registered
stop()
},
{ immediate: true, flush: 'post' }
)
})
onUnmounted(() => {
if (!nodeId) return
const node = nodeSlotRegistryStore.getNode(nodeId)
if (!node) return
// Remove this slot from registry and layout
const slotKey = getSlotKey(nodeId, index, type === 'input')
const entry = node.slots.get(slotKey)
if (entry) {
delete entry.el.dataset.slotKey
node.slots.delete(slotKey)
}
layoutStore.deleteSlotLayout(slotKey)
// If node has no more slots, clean up
if (node.slots.size === 0) {
if (node.stopWatch) node.stopWatch()
nodeSlotRegistryStore.deleteNode(nodeId)
}
})
return {
requestSlotLayoutSync: () => scheduleSlotLayoutSync(nodeId)
}
}