Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts
Benjamin Lu ff5d0923ca Refactor vue slot tracking (#5463)
* add dom element resize observer registry for vue node components

* Update src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts

Co-authored-by: AustinMroz <austin@comfy.org>

* refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates

* chore: make TransformState interface non-exported to satisfy knip pre-push

* Revert "chore: make TransformState interface non-exported to satisfy knip pre-push"

This reverts commit 110ecf31da.

* Revert "refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates"

This reverts commit 428752619c.

* [refactor] Improve resize tracking composable documentation and test utilities

- Rename parameters in useVueElementTracking for clarity (appIdentifier, trackingType)
- Add comprehensive docstring with examples to prevent DOM attribute confusion
- Extract mountLGraphNode test utility to eliminate repetitive mock setup
- Add technical implementation notes documenting optimization decisions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* remove typo comment

* convert to functional bounds collection

* remove inline import

* add interfaces for bounds mutations

* remove change log

* fix bounds collection when vue nodes turned off

* fix title offset on y

* move from resize observer to selection toolbox bounds

* refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates

* Fix conversion

* Readd padding

* revert churn reducings from layoutStore.ts

* Rely on RO for resize, and batch

* Improve churn

* Cache canvas offset

* rename from measure

* remove unused

* address review comments

* Update legacy injection

* nit

* Split into store

* nit

* perf improvement

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 19:28:04 -07:00

221 lines
6.2 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 { type Ref, onMounted, onUnmounted, watch } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
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'
// RAF batching
const pendingNodes = new Set<string>()
let rafId: number | null = null
function scheduleSlotLayoutSync(nodeId: string) {
pendingNodes.add(nodeId)
if (rafId == null) {
rafId = requestAnimationFrame(() => {
rafId = null
flushScheduledSlotLayoutSync()
})
}
}
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<{ key: string; layout: SlotLayout }> = []
for (const [slotKey, entry] of node.slots) {
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({
key: slotKey,
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.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
}
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
key: slotKey,
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.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
// Ensure node entry
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')
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')
node.slots.delete(slotKey)
layoutStore.deleteSlotLayout(slotKey)
// If node has no more slots, clean up
if (node.slots.size === 0) {
// Stop the node-level watcher when the last slot is gone
if (node.stopWatch) node.stopWatch()
nodeSlotRegistryStore.deleteNode(nodeId)
}
})
return {
requestSlotLayoutSync: () => scheduleSlotLayoutSync(nodeId)
}
}