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)