Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts
2025-10-10 12:58:25 -07:00

308 lines
8.3 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'
// Slot keys migrated to identity attributes
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import {
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 flushScheduledSlotLayoutSync() {
if (pendingNodes.size === 0) return
const conv = useSharedCanvasPositionConversion()
for (const nodeId of Array.from(pendingNodes)) {
pendingNodes.delete(nodeId)
syncNodeSlotLayoutsFromDOM(nodeId, conv)
}
}
export function syncNodeSlotLayoutsFromDOM(
nodeId: string,
conv?: ReturnType<typeof useSharedCanvasPositionConversion>
) {
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
const node = nodeSlotRegistryStore.getNode(nodeId)
if (!node) return
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) return
const batch: Array<{
nodeId: string
type: 'input' | 'output'
index: number
layout: SlotLayout
}> = []
for (const [index, entry] of node.slots.input) {
const rect = entry.el.getBoundingClientRect()
const screenCenter: [number, number] = [
rect.left + rect.width / 2,
rect.top + rect.height / 2
]
const [x, y] = (
conv ?? useSharedCanvasPositionConversion()
).clientPosToCanvasPos(screenCenter)
const centerCanvas = { x, y }
// Cache offset relative to node position for fast updates later
entry.cachedOffset = {
x: centerCanvas.x - nodeLayout.position.x,
y: centerCanvas.y - nodeLayout.position.y
}
// Persist layout in canvas coordinates
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
nodeId,
type: 'input',
index,
layout: {
nodeId,
index: entry.index,
type: entry.type,
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
}
})
}
for (const [index, entry] of node.slots.output) {
const rect = entry.el.getBoundingClientRect()
const screenCenter: [number, number] = [
rect.left + rect.width / 2,
rect.top + rect.height / 2
]
const [x, y] = (
conv ?? useSharedCanvasPositionConversion()
).clientPosToCanvasPos(screenCenter)
const centerCanvas = { x, y }
entry.cachedOffset = {
x: centerCanvas.x - nodeLayout.position.x,
y: centerCanvas.y - nodeLayout.position.y
}
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
nodeId,
type: 'output',
index,
layout: {
nodeId,
index: entry.index,
type: entry.type,
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
}
})
}
if (batch.length) layoutStore.batchUpdateSlotLayoutsBy(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<{
nodeId: string
type: 'input' | 'output'
index: number
layout: SlotLayout
}> = []
for (const [index, entry] of node.slots.input) {
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
}
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
nodeId,
type: 'input',
index,
layout: {
nodeId,
index: entry.index,
type: entry.type,
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
}
})
}
for (const [index, entry] of node.slots.output) {
if (!entry.cachedOffset) {
scheduleSlotLayoutSync(nodeId)
continue
}
const centerCanvas = {
x: nodeLayout.position.x + entry.cachedOffset.x,
y: nodeLayout.position.y + entry.cachedOffset.y
}
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
nodeId,
type: 'output',
index,
layout: {
nodeId,
index: entry.index,
type: entry.type,
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
}
})
}
if (batch.length) layoutStore.batchUpdateSlotLayoutsBy(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()
}
}
el.dataset.nodeId = nodeId
el.dataset.slotType = type
el.dataset.slotIndex = String(index)
if (type === 'input') {
node.slots.input.set(index, { el, index, type })
} else {
node.slots.output.set(index, { 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
const collection = type === 'input' ? node.slots.input : node.slots.output
const entry = collection.get(index)
if (entry) {
delete entry.el.dataset.nodeId
delete entry.el.dataset.slotType
delete entry.el.dataset.slotIndex
collection.delete(index)
}
layoutStore.deleteSlotLayoutBy(nodeId, type, index)
// If node has no more slots, clean up
if (node.slots.input.size === 0 && node.slots.output.size === 0) {
if (node.stopWatch) node.stopWatch()
nodeSlotRegistryStore.deleteNode(nodeId)
}
})
return {
requestSlotLayoutSync: () => scheduleSlotLayoutSync(nodeId)
}
}