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

This commit is contained in:
Benjamin Lu
2025-09-09 16:20:25 -07:00
parent b6269c0e37
commit 428752619c
9 changed files with 335 additions and 293 deletions

View File

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

View File

@@ -0,0 +1,18 @@
import type { InjectionKey } from 'vue'
import type { Point } from '@/renderer/core/layout/types'
export interface TransformState {
screenToCanvas: (p: Point) => Point
canvasToScreen: (p: Point) => Point
camera?: { x: number; y: number; z: number }
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

@@ -456,6 +456,20 @@ class LayoutStoreImpl implements LayoutStore {
const existing = this.slotLayouts.get(key)
if (existing) {
// Short-circuit if nothing changed to avoid spatial index churn
if (
existing.nodeId === layout.nodeId &&
existing.index === layout.index &&
existing.type === layout.type &&
existing.position.x === layout.position.x &&
existing.position.y === layout.position.y &&
existing.bounds.x === layout.bounds.x &&
existing.bounds.y === layout.bounds.y &&
existing.bounds.width === layout.bounds.width &&
existing.bounds.height === layout.bounds.height
) {
return
}
// Update spatial index
this.slotSpatialIndex.update(key, layout.bounds)
} else {
@@ -1443,9 +1457,26 @@ class LayoutStoreImpl implements LayoutStore {
const ynode = this.ynodes.get(nodeId)
if (!ynode) continue
// Short-circuit when bounds are unchanged to avoid churn
const currentBounds = this.getNodeField(ynode, 'bounds')
const sameBounds =
currentBounds.x === bounds.x &&
currentBounds.y === bounds.y &&
currentBounds.width === bounds.width &&
currentBounds.height === bounds.height
if (sameBounds) continue
this.spatialIndex.update(nodeId, bounds)
ynode.set('bounds', bounds)
ynode.set('size', { width: bounds.width, height: bounds.height })
// Keep size in sync with bounds
const currentSize = this.getNodeField(ynode, 'size')
if (
currentSize.width !== bounds.width ||
currentSize.height !== bounds.height
) {
ynode.set('size', { width: bounds.width, height: bounds.height })
}
}
}, this.currentActor)

View File

@@ -32,7 +32,6 @@
import {
type ComponentPublicInstance,
computed,
inject,
onErrorCaptured,
ref,
watchEffect
@@ -42,10 +41,7 @@ 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 +71,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 +83,10 @@ watchEffect(() => {
slotElRef.value = el || null
})
useDomSlotRegistration({
useSlotElementTracking({
nodeId: props.nodeId ?? '',
slotIndex: props.index,
index: props.index,
isInput: true,
element: slotElRef,
transform: transformState
element: slotElRef
})
</script>

View File

@@ -33,7 +33,6 @@
import {
type ComponentPublicInstance,
computed,
inject,
onErrorCaptured,
ref,
watchEffect
@@ -43,10 +42,7 @@ 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 +73,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 +85,10 @@ watchEffect(() => {
slotElRef.value = el || null
})
useDomSlotRegistration({
useSlotElementTracking({
nodeId: props.nodeId ?? '',
slotIndex: props.index,
index: props.index,
isInput: false,
element: slotElRef,
transform: transformState
element: slotElRef
})
</script>

View File

@@ -0,0 +1,199 @@
/**
* 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, inject, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point } from '@/renderer/core/layout/types'
type SlotEntry = {
el: HTMLElement
index: number
isInput: boolean
cachedOffset?: { x: number; y: number }
}
type NodeEntry = {
nodeId: string
screenToCanvas?: (p: Point) => Point
slots: Map<string, SlotEntry>
stopWatch?: () => void
}
// Registry of nodes and their slots
const nodeRegistry = new Map<string, NodeEntry>()
// RAF batching
const pendingNodes = new Set<string>()
let rafId: number | null = null
function scheduleNodeMeasure(nodeId: string) {
pendingNodes.add(nodeId)
if (rafId == null) {
rafId = requestAnimationFrame(() => {
rafId = null
runBatchedMeasure()
})
}
}
function runBatchedMeasure() {
if (pendingNodes.size === 0) return
// Read container origin once
const container = document.getElementById('graph-canvas-container')
const originRect = container?.getBoundingClientRect()
const originLeft = originRect?.left ?? 0
const originTop = originRect?.top ?? 0
for (const nodeId of Array.from(pendingNodes)) {
pendingNodes.delete(nodeId)
const node = nodeRegistry.get(nodeId)
if (!node) continue
if (!node.screenToCanvas) continue
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) continue
for (const [slotKey, entry] of node.slots) {
const rect = entry.el.getBoundingClientRect()
const centerScreen = {
x: rect.left + rect.width / 2 - originLeft,
y: rect.top + rect.height / 2 - originTop
}
const centerCanvas = node.screenToCanvas(centerScreen)
// 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
layoutStore.updateSlotLayout(slotKey, {
nodeId,
index: entry.index,
type: entry.isInput ? 'input' : 'output',
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
})
}
}
}
function updateNodeSlotsFromCache(nodeId: string) {
const node = nodeRegistry.get(nodeId)
if (!node) return
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) return
for (const [slotKey, entry] of node.slots) {
if (!entry.cachedOffset) {
// schedule a remeasure to seed offset
scheduleNodeMeasure(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
layoutStore.updateSlotLayout(slotKey, {
nodeId,
index: entry.index,
type: entry.isInput ? 'input' : 'output',
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
})
}
}
export function useSlotElementTracking(options: {
nodeId: string
index: number
isInput: boolean
element: Ref<HTMLElement | null>
}) {
const { nodeId, index, isInput, element } = options
// Get transform utilities from TransformPane
const transformState = inject(TransformStateKey)
onMounted(async () => {
if (!nodeId) return
await nextTick()
const el = element.value
if (!el) return
// Ensure node entry
let node = nodeRegistry.get(nodeId)
if (!node) {
node = {
nodeId,
screenToCanvas: transformState?.screenToCanvas,
slots: new Map()
}
nodeRegistry.set(nodeId, node)
// Subscribe once per node to layout changes for fast cached updates
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
const stop = watch(
nodeRef,
(newLayout, oldLayout) => {
if (newLayout && oldLayout) {
// Update from cache on any position/size change
updateNodeSlotsFromCache(nodeId)
}
},
{ flush: 'post' }
)
node.stopWatch = () => stop()
}
// Register slot
const slotKey = getSlotKey(nodeId, index, isInput)
node.slots.set(slotKey, { el, index, isInput })
// Seed measurement
scheduleNodeMeasure(nodeId)
})
onUnmounted(() => {
if (!nodeId) return
const node = nodeRegistry.get(nodeId)
if (!node) return
// Remove this slot from registry and layout
const slotKey = getSlotKey(nodeId, index, isInput)
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()
nodeRegistry.delete(nodeId)
}
})
return {
remeasure: () => scheduleNodeMeasure(nodeId)
}
}

View File

@@ -8,11 +8,20 @@
* Supports different element types (nodes, slots, widgets, etc.) with
* customizable data attributes and update handlers.
*/
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
import { getCurrentInstance, inject, onMounted, onUnmounted } from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point } from '@/renderer/core/layout/types'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
// Per-element conversion context
const elementConversion = new WeakMap<
HTMLElement,
{ screenToCanvas?: (p: Point) => Point }
>()
/**
* Configuration for different types of tracked elements
*/
@@ -44,14 +53,20 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
// Single ResizeObserver instance for all Vue elements
const resizeObserver = new ResizeObserver((entries) => {
// Group updates by element type
// Group updates by type, then flush via each config's handler
const updatesByType = new Map<string, Array<{ id: string; bounds: Bounds }>>()
// Read container origin once per batch to avoid repeated layout reads
const container = document.getElementById('graph-canvas-container')
const originRect = container?.getBoundingClientRect()
const originLeft = originRect?.left ?? 0
const originTop = originRect?.top ?? 0
for (const entry of entries) {
if (!(entry.target instanceof HTMLElement)) continue
const element = entry.target
// Find which type this element belongs to
// Identify type + id via config dataAttribute
let elementType: string | undefined
let elementId: string | undefined
@@ -66,31 +81,54 @@ 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()
let bounds: Bounds = { x: rect.left, y: rect.top, width, height }
const bounds: Bounds = {
x: rect.left,
y: rect.top,
width,
height: height-LiteGraph.NODE_TITLE_HEIGHT
// Convert to canvas space and adjust for title band when possible
const ctx = elementConversion.get(element)
if (ctx?.screenToCanvas) {
const topLeftCanvas = ctx.screenToCanvas({
x: bounds.x - originLeft,
y: bounds.y - originTop
})
const dimCanvas = ctx.screenToCanvas({
x: bounds.width,
y: bounds.height
})
const originCanvas = ctx.screenToCanvas({ x: 0, y: 0 })
const canvasWidth = Math.max(0, dimCanvas.x - originCanvas.x)
const canvasHeight = Math.max(0, dimCanvas.y - originCanvas.y)
bounds = {
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
width: canvasWidth,
height: Math.max(0, canvasHeight - LiteGraph.NODE_TITLE_HEIGHT)
}
}
if (!updatesByType.has(elementType)) {
updatesByType.set(elementType, [])
}
const updates = updatesByType.get(elementType)
if (updates) {
updates.push({ id: elementId, bounds })
let updates = updatesByType.get(elementType)
if (!updates) {
updates = []
updatesByType.set(elementType, updates)
}
updates.push({ id: elementId, bounds })
}
// 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)
}
})
@@ -119,16 +157,23 @@ export function useVueElementTracking(
appIdentifier: string,
trackingType: string
) {
// For canvas-space conversion: provided by TransformPane
const transformState = inject(TransformStateKey)
onMounted(() => {
const element = getCurrentInstance()?.proxy?.$el
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
// Remember transformer for this element
if (transformState?.screenToCanvas) {
elementConversion.set(element, {
screenToCanvas: transformState.screenToCanvas
})
}
resizeObserver.observe(element)
})
onUnmounted(() => {
@@ -136,10 +181,11 @@ 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)
elementConversion.delete(element)
})
}

View File

@@ -6,6 +6,7 @@
*/
import { computed, inject } from 'vue'
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'
@@ -19,12 +20,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)