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>
This commit is contained in:
Benjamin Lu
2025-09-16 19:28:04 -07:00
committed by GitHub
parent 6b59f839e0
commit ff5d0923ca
21 changed files with 498 additions and 379 deletions

View File

@@ -102,6 +102,8 @@ export function useSelectionToolboxPosition(
worldPosition.value = {
x: unionBounds.x + unionBounds.width / 2,
// createBounds() applied a default padding of 10px
// so adjust Y to maintain visual consistency
y: unionBounds.y - 10
}

View File

@@ -1,6 +1,10 @@
import { useElementBounding } from '@vueuse/core'
import type { LGraphCanvas, Vector2 } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, Point } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
let sharedConverter: ReturnType<typeof useCanvasPositionConversion> | null =
null
/**
* Convert between canvas and client positions
@@ -14,7 +18,7 @@ export const useCanvasPositionConversion = (
) => {
const { left, top, update } = useElementBounding(canvasElement)
const clientPosToCanvasPos = (pos: Vector2): Vector2 => {
const clientPosToCanvasPos = (pos: Point): Point => {
const { offset, scale } = lgCanvas.ds
return [
(pos[0] - left.value) / scale - offset[0],
@@ -22,7 +26,7 @@ export const useCanvasPositionConversion = (
]
}
const canvasPosToClientPos = (pos: Vector2): Vector2 => {
const canvasPosToClientPos = (pos: Point): Point => {
const { offset, scale } = lgCanvas.ds
return [
(pos[0] + offset[0]) * scale + left.value,
@@ -36,3 +40,10 @@ export const useCanvasPositionConversion = (
update
}
}
export function useSharedCanvasPositionConversion() {
if (sharedConverter) return sharedConverter
const lgCanvas = useCanvasStore().getCanvas()
sharedConverter = useCanvasPositionConversion(lgCanvas.canvas, lgCanvas)
return sharedConverter
}

View File

@@ -1,5 +1,6 @@
import { Ref } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -27,16 +28,19 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
const conv = useSharedCanvasPositionConversion()
const basePos = conv.clientPosToCanvasPos([loc.clientX, loc.clientY])
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const pos = [...basePos]
// Add an offset on y to make sure after adding the node, the cursor
// is on the node (top left corner)
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
litegraphService.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const pos = basePos
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
let targetProvider: ModelNodeProvider | null = null
let targetGraphNode: LGraphNode | null = null
@@ -73,11 +77,7 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
}
} else if (node.data instanceof ComfyWorkflow) {
const workflow = node.data
const position = comfyApp.clientPosToCanvasPos([
loc.clientX,
loc.clientY
])
await workflowService.insertWorkflow(workflow, { position })
await workflowService.insertWorkflow(workflow, { position: basePos })
}
}
}

View File

@@ -8,7 +8,7 @@ import type {
INodeOutputSlot,
ISlotType,
LLink,
Vector2
Point
} from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -557,7 +557,7 @@ app.registerExtension({
}
)
function isNodeAtPos(pos: Vector2) {
function isNodeAtPos(pos: Point) {
for (const n of app.graph.nodes) {
if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
return true

View File

@@ -2,7 +2,7 @@ import { toRaw } from 'vue'
import { t } from '@/i18n'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
@@ -346,7 +346,7 @@ export const useWorkflowService = () => {
*/
const insertWorkflow = async (
workflow: ComfyWorkflow,
options: { position?: Vector2 } = {}
options: { position?: Point } = {}
) => {
const loadedWorkflow = await workflow.load()
const workflowJSON = toRaw(loadedWorkflow.initialState)

View File

@@ -0,0 +1,49 @@
import type { InjectionKey } from 'vue'
import type { Point } from '@/renderer/core/layout/types'
/**
* Lightweight, injectable transform state used by layout-aware components.
*
* Consumers use this interface to convert coordinates between LiteGraph's
* canvas space and the DOM's screen space, access the current pan/zoom
* (camera), and perform basic viewport culling checks.
*
* Coordinate mapping:
* - screen = (canvas + offset) * scale
* - canvas = screen / scale - offset
*
* The full implementation and additional helpers live in
* `useTransformState()`. This interface deliberately exposes only the
* minimal surface needed outside that composable.
*
* @example
* const state = inject(TransformStateKey)!
* const screen = state.canvasToScreen({ x: 100, y: 50 })
*/
interface TransformState {
/** Convert a screen-space point (CSS pixels) to canvas space. */
screenToCanvas: (p: Point) => Point
/** Convert a canvas-space point to screen space (CSS pixels). */
canvasToScreen: (p: Point) => Point
/** Current pan/zoom; `x`/`y` are offsets, `z` is scale. */
camera?: { x: number; y: number; z: number }
/**
* Test whether a node's rectangle intersects the (expanded) viewport.
* Handy for viewport culling and lazy work.
*
* @param nodePos Top-left in canvas space `[x, y]`
* @param nodeSize Size in canvas units `[width, height]`
* @param viewport Screen-space viewport `{ width, height }`
* @param margin Optional fractional margin (e.g. `0.2` = 20%)
*/
isNodeInViewport?: (
nodePos: ArrayLike<number>,
nodeSize: ArrayLike<number>,
viewport: { width: number; height: number },
margin?: number
) => boolean
}
export const TransformStateKey: InjectionKey<TransformState> =
Symbol('transformState')

View File

@@ -1,229 +0,0 @@
/**
* DOM-based slot registration with performance optimization
*
* Measures the actual DOM position of a Vue slot connector and registers it
* into the LayoutStore so hit-testing and link rendering use the true position.
*
* Performance strategy:
* - Cache slot offset relative to node (avoids DOM reads during drag)
* - No measurements during pan/zoom (camera transforms don't change canvas coords)
* - Batch DOM reads via requestAnimationFrame
* - Only remeasure on structural changes (resize, collapse, LOD)
*/
import {
type Ref,
type WatchStopHandle,
nextTick,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point as LayoutPoint } from '@/renderer/core/layout/types'
import { getSlotKey } from './slotIdentifier'
export type TransformState = {
screenToCanvas: (p: LayoutPoint) => LayoutPoint
}
// Shared RAF queue for batching measurements
const measureQueue = new Set<() => void>()
let rafId: number | null = null
// Track mounted components to prevent execution on unmounted ones
const mountedComponents = new WeakSet<object>()
function scheduleMeasurement(fn: () => void) {
measureQueue.add(fn)
if (rafId === null) {
rafId = requestAnimationFrame(() => {
rafId = null
const batch = Array.from(measureQueue)
measureQueue.clear()
batch.forEach((measure) => measure())
})
}
}
const cleanupFunctions = new WeakMap<
Ref<HTMLElement | null>,
{
stopWatcher?: WatchStopHandle
handleResize?: () => void
}
>()
interface SlotRegistrationOptions {
nodeId: string
slotIndex: number
isInput: boolean
element: Ref<HTMLElement | null>
transform?: TransformState
}
export function useDomSlotRegistration(options: SlotRegistrationOptions) {
const { nodeId, slotIndex, isInput, element: elRef, transform } = options
// Early return if no nodeId
if (!nodeId || nodeId === '') {
return {
remeasure: () => {}
}
}
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
// Track if this component is mounted
const componentToken = {}
// Cached offset from node position (avoids DOM reads during drag)
const cachedOffset = ref<LayoutPoint | null>(null)
const lastMeasuredBounds = ref<DOMRect | null>(null)
// Measure DOM and cache offset (expensive, minimize calls)
const measureAndCacheOffset = () => {
// Skip if component was unmounted
if (!mountedComponents.has(componentToken)) return
const el = elRef.value
if (!el || !transform?.screenToCanvas) return
const rect = el.getBoundingClientRect()
// Skip if bounds haven't changed significantly (within 0.5px)
if (lastMeasuredBounds.value) {
const prev = lastMeasuredBounds.value
if (
Math.abs(rect.left - prev.left) < 0.5 &&
Math.abs(rect.top - prev.top) < 0.5 &&
Math.abs(rect.width - prev.width) < 0.5 &&
Math.abs(rect.height - prev.height) < 0.5
) {
return // No significant change - skip update
}
}
lastMeasuredBounds.value = rect
// Center of the visual connector (dot) in screen coords
const centerScreen = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
}
const centerCanvas = transform.screenToCanvas(centerScreen)
// Cache offset from node position for fast updates during drag
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (nodeLayout) {
cachedOffset.value = {
x: centerCanvas.x - nodeLayout.position.x,
y: centerCanvas.y - nodeLayout.position.y
}
}
updateSlotPosition(centerCanvas)
}
// Fast update using cached offset (no DOM read)
const updateFromCachedOffset = () => {
if (!cachedOffset.value) {
// No cached offset yet, need to measure
scheduleMeasurement(measureAndCacheOffset)
return
}
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) {
return
}
// Calculate absolute position from node position + cached offset
const centerCanvas = {
x: nodeLayout.position.x + cachedOffset.value.x,
y: nodeLayout.position.y + cachedOffset.value.y
}
updateSlotPosition(centerCanvas)
}
// Update slot position in layout store
const updateSlotPosition = (centerCanvas: LayoutPoint) => {
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
layoutStore.updateSlotLayout(slotKey, {
nodeId,
index: slotIndex,
type: isInput ? 'input' : 'output',
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
})
}
onMounted(async () => {
// Mark component as mounted
mountedComponents.add(componentToken)
// Initial measure after mount
await nextTick()
measureAndCacheOffset()
// Subscribe to node position changes for fast cached updates
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
const stopWatcher = watch(
nodeRef,
(newLayout) => {
if (newLayout) {
// Node moved/resized - update using cached offset
updateFromCachedOffset()
}
},
{ immediate: false }
)
// Store cleanup functions without type assertions
const cleanup = cleanupFunctions.get(elRef) || {}
cleanup.stopWatcher = stopWatcher
// Window resize - remeasure as viewport changed
const handleResize = () => {
scheduleMeasurement(measureAndCacheOffset)
}
window.addEventListener('resize', handleResize, { passive: true })
cleanup.handleResize = handleResize
cleanupFunctions.set(elRef, cleanup)
})
onUnmounted(() => {
// Mark component as unmounted
mountedComponents.delete(componentToken)
// Clean up watchers and listeners
const cleanup = cleanupFunctions.get(elRef)
if (cleanup) {
if (cleanup.stopWatcher) cleanup.stopWatcher()
if (cleanup.handleResize) {
window.removeEventListener('resize', cleanup.handleResize)
}
cleanupFunctions.delete(elRef)
}
// Remove from layout store
layoutStore.deleteSlotLayout(slotKey)
// Remove from measurement queue if pending
measureQueue.delete(measureAndCacheOffset)
})
return {
// Expose for forced remeasure on structural changes
remeasure: () => scheduleMeasurement(measureAndCacheOffset)
}
}

View File

@@ -38,6 +38,10 @@ import {
type RerouteLayout,
type SlotLayout
} from '@/renderer/core/layout/types'
import {
isBoundsEqual,
isPointEqual
} from '@/renderer/core/layout/utils/geometry'
import {
REROUTE_RADIUS,
boundsIntersect,
@@ -392,12 +396,8 @@ class LayoutStoreImpl implements LayoutStore {
// Short-circuit if bounds and centerPos unchanged
if (
existing &&
existing.bounds.x === layout.bounds.x &&
existing.bounds.y === layout.bounds.y &&
existing.bounds.width === layout.bounds.width &&
existing.bounds.height === layout.bounds.height &&
existing.centerPos.x === layout.centerPos.x &&
existing.centerPos.y === layout.centerPos.y
isBoundsEqual(existing.bounds, layout.bounds) &&
isPointEqual(existing.centerPos, layout.centerPos)
) {
// Only update path if provided (for hit detection)
if (layout.path) {
@@ -436,6 +436,13 @@ class LayoutStoreImpl implements LayoutStore {
const existing = this.slotLayouts.get(key)
if (existing) {
// Short-circuit if geometry is unchanged
if (
isPointEqual(existing.position, layout.position) &&
isBoundsEqual(existing.bounds, layout.bounds)
) {
return
}
// Update spatial index
this.slotSpatialIndex.update(key, layout.bounds)
} else {
@@ -446,6 +453,34 @@ class LayoutStoreImpl implements LayoutStore {
this.slotLayouts.set(key, layout)
}
/**
* Batch update slot layouts and spatial index in one pass
*/
batchUpdateSlotLayouts(
updates: Array<{ key: string; layout: SlotLayout }>
): void {
if (!updates.length) return
// Update spatial index and map entries (skip unchanged)
for (const { key, layout } of updates) {
const existing = this.slotLayouts.get(key)
if (existing) {
// Short-circuit if geometry is unchanged
if (
isPointEqual(existing.position, layout.position) &&
isBoundsEqual(existing.bounds, layout.bounds)
) {
continue
}
this.slotSpatialIndex.update(key, layout.bounds)
} else {
this.slotSpatialIndex.insert(key, layout.bounds)
}
this.slotLayouts.set(key, layout)
}
}
/**
* Delete slot layout data
*/
@@ -554,12 +589,8 @@ class LayoutStoreImpl implements LayoutStore {
// Short-circuit if bounds and centerPos unchanged (prevents spatial index churn)
if (
existing &&
existing.bounds.x === layout.bounds.x &&
existing.bounds.y === layout.bounds.y &&
existing.bounds.width === layout.bounds.width &&
existing.bounds.height === layout.bounds.height &&
existing.centerPos.x === layout.centerPos.x &&
existing.centerPos.y === layout.centerPos.y
isBoundsEqual(existing.bounds, layout.bounds) &&
isPointEqual(existing.centerPos, layout.centerPos)
) {
// Only update path if provided (for hit detection)
if (layout.path) {
@@ -968,9 +999,6 @@ class LayoutStoreImpl implements LayoutStore {
// Hit detection queries can run before CRDT updates complete
this.spatialIndex.update(operation.nodeId, newBounds)
// Update associated slot positions synchronously
this.updateNodeSlotPositions(operation.nodeId, operation.position)
// Then update CRDT
ynode.set('position', operation.position)
this.updateNodeBounds(ynode, operation.position, size)
@@ -997,9 +1025,6 @@ class LayoutStoreImpl implements LayoutStore {
// Hit detection queries can run before CRDT updates complete
this.spatialIndex.update(operation.nodeId, newBounds)
// Update associated slot positions synchronously (size changes may affect slot positions)
this.updateNodeSlotPositions(operation.nodeId, position)
// Then update CRDT
ynode.set('size', operation.size)
this.updateNodeBounds(ynode, position, operation.size)
@@ -1280,29 +1305,6 @@ class LayoutStoreImpl implements LayoutStore {
}
}
/**
* Update slot positions when a node moves
* TODO: This should be handled by the layout sync system (useSlotLayoutSync)
* rather than manually here. For now, we'll mark affected slots as needing recalculation.
*/
private updateNodeSlotPositions(nodeId: NodeId, _nodePosition: Point): void {
// Mark all slots for this node as potentially stale
// The layout sync system will recalculate positions on the next frame
const slotsToRemove: string[] = []
for (const [key, slotLayout] of this.slotLayouts) {
if (slotLayout.nodeId === nodeId) {
slotsToRemove.push(key)
}
}
// Remove from spatial index so they'll be recalculated
for (const key of slotsToRemove) {
this.slotSpatialIndex.remove(key)
this.slotLayouts.delete(key)
}
}
// Helper methods
private notifyChange(change: LayoutChange): void {

View File

@@ -14,6 +14,7 @@
import { computed, provide } from 'vue'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
@@ -39,7 +40,7 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
trackPan: true
})
provide('transformState', {
provide(TransformStateKey, {
camera,
canvasToScreen,
screenToCanvas,

View File

@@ -330,4 +330,8 @@ export interface LayoutStore {
batchUpdateNodeBounds(
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
): void
batchUpdateSlotLayouts(
updates: Array<{ key: string; layout: SlotLayout }>
): void
}

View File

@@ -0,0 +1,15 @@
import type { Bounds, Point, Size } from '@/renderer/core/layout/types'
export function isPointEqual(a: Point, b: Point): boolean {
return a.x === b.x && a.y === b.y
}
export function isBoundsEqual(a: Bounds, b: Bounds): boolean {
return (
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
)
}
export function isSizeEqual(a: Size, b: Size): boolean {
return a.width === b.width && a.height === b.height
}

View File

@@ -4,15 +4,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { useDomSlotRegistration } from '@/renderer/core/layout/slots/useDomSlotRegistration'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
// Mock composable used by InputSlot/OutputSlot so we can assert call params
vi.mock('@/renderer/core/layout/slots/useDomSlotRegistration', () => ({
useDomSlotRegistration: vi.fn(() => ({ remeasure: vi.fn() }))
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
() => ({
useSlotElementTracking: vi.fn(() => ({ stop: vi.fn() }))
})
)
type InputSlotProps = ComponentMountingOptions<typeof InputSlot>['props']
type OutputSlotProps = ComponentMountingOptions<typeof OutputSlot>['props']
@@ -49,7 +52,7 @@ const mountOutputSlot = (props: OutputSlotProps) =>
describe('InputSlot/OutputSlot', () => {
beforeEach(() => {
vi.mocked(useDomSlotRegistration).mockClear()
vi.mocked(useSlotElementTracking).mockClear()
})
it('InputSlot registers with correct options', () => {
@@ -59,11 +62,11 @@ describe('InputSlot/OutputSlot', () => {
slotData: { name: 'A', type: 'any', boundingRect: [0, 0, 0, 0] }
})
expect(useDomSlotRegistration).toHaveBeenLastCalledWith(
expect(useSlotElementTracking).toHaveBeenLastCalledWith(
expect.objectContaining({
nodeId: 'node-1',
slotIndex: 3,
isInput: true
index: 3,
type: 'input'
})
)
})
@@ -75,11 +78,11 @@ describe('InputSlot/OutputSlot', () => {
slotData: { name: 'B', type: 'any', boundingRect: [0, 0, 0, 0] }
})
expect(useDomSlotRegistration).toHaveBeenLastCalledWith(
expect(useSlotElementTracking).toHaveBeenLastCalledWith(
expect.objectContaining({
nodeId: 'node-2',
slotIndex: 1,
isInput: false
index: 1,
type: 'output'
})
)
})

View File

@@ -32,7 +32,6 @@
import {
type ComponentPublicInstance,
computed,
inject,
onErrorCaptured,
ref,
watchEffect
@@ -41,11 +40,7 @@ import {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
useDomSlotRegistration
} from '@/renderer/core/layout/slots/useDomSlotRegistration'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -75,11 +70,6 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
const transformState = inject<TransformState | undefined>(
'transformState',
undefined
)
const connectionDotRef = ref<ComponentPublicInstance<{
slotElRef: HTMLElement | undefined
}> | null>(null)
@@ -92,11 +82,10 @@ watchEffect(() => {
slotElRef.value = el || null
})
useDomSlotRegistration({
useSlotElementTracking({
nodeId: props.nodeId ?? '',
slotIndex: props.index,
isInput: true,
element: slotElRef,
transform: transformState
index: props.index,
type: 'input',
element: slotElRef
})
</script>

View File

@@ -147,6 +147,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
@@ -212,19 +213,7 @@ if (!selectedNodeIds) {
}
// Inject transform state for coordinate conversion
const transformState = inject('transformState') as
| {
camera: { z: number }
canvasToScreen: (point: { x: number; y: number }) => {
x: number
y: number
}
screenToCanvas: (point: { x: number; y: number }) => {
x: number
y: number
}
}
| undefined
const transformState = inject(TransformStateKey)
// Computed selection state - only this node re-evaluates when its selection changes
const isSelected = computed(() => {
@@ -281,7 +270,7 @@ const {
} = useNodeLayout(nodeData.id)
onMounted(() => {
if (size && transformState) {
if (size && transformState?.camera) {
const scale = transformState.camera.z
const screenSize = {
width: size.width * scale,

View File

@@ -33,7 +33,6 @@
import {
type ComponentPublicInstance,
computed,
inject,
onErrorCaptured,
ref,
watchEffect
@@ -42,11 +41,7 @@ import {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
useDomSlotRegistration
} from '@/renderer/core/layout/slots/useDomSlotRegistration'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -77,11 +72,6 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
const transformState = inject<TransformState | undefined>(
'transformState',
undefined
)
const connectionDotRef = ref<ComponentPublicInstance<{
slotElRef: HTMLElement | undefined
}> | null>(null)
@@ -94,11 +84,10 @@ watchEffect(() => {
slotElRef.value = el || null
})
useDomSlotRegistration({
useSlotElementTracking({
nodeId: props.nodeId ?? '',
slotIndex: props.index,
isInput: false,
element: slotElRef,
transform: transformState
index: props.index,
type: 'output',
element: slotElRef
})
</script>

View File

@@ -0,0 +1,220 @@
/**
* 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)
}
}

View File

@@ -10,9 +10,13 @@
*/
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
/**
* Generic update item for element bounds tracking
*/
@@ -54,8 +58,12 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
// Single ResizeObserver instance for all Vue elements
const resizeObserver = new ResizeObserver((entries) => {
// Group updates by element type
// Canvas is ready when this code runs; no defensive guards needed.
const conv = useSharedCanvasPositionConversion()
// Group updates by type, then flush via each config's handler
const updatesByType = new Map<string, ElementBoundsUpdate[]>()
// Track nodes whose slots should be resynced after node size changes
const nodesNeedingSlotResync = new Set<string>()
for (const entry of entries) {
if (!(entry.target instanceof HTMLElement)) continue
@@ -76,30 +84,50 @@ const resizeObserver = new ResizeObserver((entries) => {
if (!elementType || !elementId) continue
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]
// Use contentBoxSize when available; fall back to contentRect for older engines/tests
const contentBox = Array.isArray(entry.contentBoxSize)
? entry.contentBoxSize[0]
: {
inlineSize: entry.contentRect.width,
blockSize: entry.contentRect.height
}
const width = contentBox.inlineSize
const height = contentBox.blockSize
// Screen-space rect
const rect = element.getBoundingClientRect()
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
const topLeftCanvas = { x: cx, y: cy }
const bounds: Bounds = {
x: rect.left,
y: rect.top,
width,
height: height
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
width: Math.max(0, width),
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
}
if (!updatesByType.has(elementType)) {
updatesByType.set(elementType, [])
let updates = updatesByType.get(elementType)
if (!updates) {
updates = []
updatesByType.set(elementType, updates)
}
const updates = updatesByType.get(elementType)
if (updates) {
updates.push({ id: elementId, bounds })
updates.push({ id: elementId, bounds })
// If this entry is a node, mark it for slot layout resync
if (elementType === 'node' && elementId) {
nodesNeedingSlotResync.add(elementId)
}
}
// Process updates by type
// Flush per-type
for (const [type, updates] of updatesByType) {
const config = trackingConfigs.get(type)
if (config && updates.length > 0) {
config.updateHandler(updates)
if (config && updates.length) config.updateHandler(updates)
}
// After node bounds are updated, refresh slot cached offsets and layouts
if (nodesNeedingSlotResync.size > 0) {
for (const nodeId of nodesNeedingSlotResync) {
syncNodeSlotLayoutsFromDOM(nodeId)
}
}
})
@@ -134,11 +162,11 @@ export function useVueElementTracking(
if (!(element instanceof HTMLElement) || !appIdentifier) return
const config = trackingConfigs.get(trackingType)
if (config) {
// Set the appropriate data attribute
element.dataset[config.dataAttribute] = appIdentifier
resizeObserver.observe(element)
}
if (!config) return
// Set the data attribute expected by the RO pipeline for this type
element.dataset[config.dataAttribute] = appIdentifier
resizeObserver.observe(element)
})
onUnmounted(() => {
@@ -146,10 +174,10 @@ export function useVueElementTracking(
if (!(element instanceof HTMLElement)) return
const config = trackingConfigs.get(trackingType)
if (config) {
// Remove the data attribute
delete element.dataset[config.dataAttribute]
resizeObserver.unobserve(element)
}
if (!config) return
// Remove the data attribute and observer
delete element.dataset[config.dataAttribute]
resizeObserver.unobserve(element)
})
}

View File

@@ -7,6 +7,7 @@
import { computed, inject } from 'vue'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource, type Point } from '@/renderer/core/layout/types'
@@ -20,12 +21,7 @@ export function useNodeLayout(nodeId: string) {
const mutations = useLayoutMutations()
// Get transform utilities from TransformPane if available
const transformState = inject('transformState') as
| {
canvasToScreen: (point: Point) => Point
screenToCanvas: (point: Point) => Point
}
| undefined
const transformState = inject(TransformStateKey)
// Get the customRef for this node (shared write access)
const layoutRef = store.getNodeLayoutRef(nodeId)

View File

@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { markRaw } from 'vue'
type SlotEntry = {
el: HTMLElement
index: number
type: 'input' | 'output'
cachedOffset?: { x: number; y: number }
}
type NodeEntry = {
nodeId: string
slots: Map<string, SlotEntry>
stopWatch?: () => void
}
export const useNodeSlotRegistryStore = defineStore('nodeSlotRegistry', () => {
const registry = markRaw(new Map<string, NodeEntry>())
function getNode(nodeId: string) {
return registry.get(nodeId)
}
function ensureNode(nodeId: string) {
let node = registry.get(nodeId)
if (!node) {
node = {
nodeId,
slots: markRaw(new Map<string, SlotEntry>())
}
registry.set(nodeId, node)
}
return node
}
function deleteNode(nodeId: string) {
registry.delete(nodeId)
}
function clear() {
registry.clear()
}
return {
getNode,
ensureNode,
deleteNode,
clear
}
})

View File

@@ -12,10 +12,10 @@ import {
LGraphEventMode,
LGraphNode,
LiteGraph,
type Point,
RenderShape,
type Subgraph,
SubgraphNode,
type Vector2,
createBounds
} from '@/lib/litegraph/src/litegraph'
import type {
@@ -994,7 +994,7 @@ export const useLitegraphService = () => {
return node
}
function getCanvasCenter(): Vector2 {
function getCanvasCenter(): Point {
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
const [x, y, w, h] = app.canvas.ds.visible_area
return [x + w / dpi / 2, y + h / dpi / 2]

View File

@@ -19,7 +19,7 @@ describe('migrateReroute', () => {
'single_connected.json',
'floating.json',
'floating_branch.json'
])('should correctly migrate %s', (fileName) => {
])('should correctly migrate %s', async (fileName) => {
// Load the legacy workflow
const legacyWorkflow = loadWorkflow(
`workflows/reroute/legacy/${fileName}`
@@ -29,9 +29,9 @@ describe('migrateReroute', () => {
const migratedWorkflow = migrateLegacyRerouteNodes(legacyWorkflow)
// Compare with snapshot
expect(JSON.stringify(migratedWorkflow, null, 2)).toMatchFileSnapshot(
`workflows/reroute/native/${fileName}`
)
await expect(
JSON.stringify(migratedWorkflow, null, 2)
).toMatchFileSnapshot(`workflows/reroute/native/${fileName}`)
})
})
})