[feat] Add Vue-based node rendering system with widget support

Complete Vue node lifecycle management system that safely extracts data from LiteGraph and renders nodes with working widgets in Vue components.

Key features:
- Safe data extraction pattern to avoid Vue proxy issues with LiteGraph private fields
- Event-driven lifecycle management using onNodeAdded/onNodeRemoved hooks
- Widget system integration with functional dropdowns, inputs, and controls
- Performance optimizations including viewport culling and RAF batching
- Transform container pattern for O(1) scaling regardless of node count
- QuadTree spatial indexing for efficient visibility queries
- Debug tools and performance monitoring
- Feature flag system for safe rollout

Architecture:
- LiteGraph remains source of truth for all graph logic and data
- Vue components render nodes positioned over canvas using CSS transforms
- Widget updates flow through LiteGraph callbacks to maintain consistency
- Reactive state separated from node references to prevent proxy overhead

Components:
- useGraphNodeManager: Core lifecycle management with safe data extraction
- TransformPane: Performance-optimized viewport container
- LGraphNode.vue: Vue node component with widget rendering
- Widget system: PrimeVue-based components for all widget types
This commit is contained in:
bymyself
2025-07-04 22:20:52 -07:00
parent 0de3b8a864
commit a23d8be77b
9 changed files with 701 additions and 183 deletions

View File

@@ -4,7 +4,8 @@
*/
import type { LGraph, LGraphNode } from '@comfyorg/litegraph'
import { nextTick, reactive, readonly } from 'vue'
import { QuadTree, type Bounds } from '@/utils/spatial/QuadTree'
import { type Bounds, QuadTree } from '../../utils/spatial/QuadTree'
export interface NodeState {
visible: boolean
@@ -52,10 +53,8 @@ export interface VueNodeData {
}
export interface SpatialMetrics {
strategy: 'linear' | 'quadtree'
queryTime: number
nodesInIndex: number
threshold: number
}
export interface GraphNodeManager {
@@ -86,14 +85,12 @@ export interface GraphNodeManager {
// Performance
performanceMetrics: PerformanceMetrics
spatialMetrics: SpatialMetrics
// Debug
getSpatialIndexDebugInfo(): any | null
}
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
console.log('[useGraphNodeManager] Initializing with graph:', graph)
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
const nodeState = reactive(new Map<string, NodeState>())
@@ -120,21 +117,17 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
adaptiveQuality: false
})
// Spatial indexing for large graphs (auto-enables at 100+ nodes)
const SPATIAL_INDEX_THRESHOLD = 100
// Spatial indexing using QuadTree
const spatialIndex = new QuadTree<string>(
{ x: -10000, y: -10000, width: 20000, height: 20000 },
{ maxDepth: 6, maxItemsPerNode: 4 }
)
let spatialIndexEnabled = false
let lastSpatialQueryTime = 0
// Spatial metrics
const spatialMetrics = reactive<SpatialMetrics>({
strategy: 'linear',
queryTime: 0,
nodesInIndex: 0,
threshold: SPATIAL_INDEX_THRESHOLD
nodesInIndex: 0
})
// FPS tracking state
@@ -146,7 +139,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
frameCount++
const now = performance.now()
const elapsed = now - lastFrameTime
if (elapsed >= 1000) {
performanceMetrics.fps = Math.round((frameCount * 1000) / elapsed)
performanceMetrics.frameTime = elapsed / frameCount
@@ -157,7 +150,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
const startFPSTracking = () => {
if (fpsUpdateInterval) return
const trackFrame = () => {
updateFPS()
fpsUpdateInterval = requestAnimationFrame(trackFrame)
@@ -221,12 +214,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
callback: widget.callback
}
} catch (error) {
console.warn(
'[useGraphNodeManager] Error extracting widget data for',
widget.name,
':',
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
@@ -257,36 +244,35 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
const nodeId = String(node.id)
node.widgets?.forEach(widget => {
node.widgets?.forEach((widget) => {
// Create a new callback that updates the widget value AND the Vue state
const originalCallback = widget.callback
widget.callback = (value: string | number | boolean | object | undefined, ...args: unknown[]) => {
widget.callback = (value: unknown) => {
// 1. Update the widget value in LiteGraph
widget.value = value
// Widget value can be various types, cast appropriately
widget.value = value as string | number | boolean | object | undefined
// 2. Call the original callback if it exists
if (originalCallback) {
originalCallback.call(widget, value as Parameters<typeof originalCallback>[0], ...args)
originalCallback.call(widget, value)
}
// 3. Update Vue state
try {
const currentData = vueNodeData.get(nodeId)
if (currentData?.widgets) {
const updatedWidgets = currentData.widgets.map(w =>
w.name === widget.name
? { ...w, value: value }
: w
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widget.name ? { ...w, value: value } : w
)
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets
})
}
performanceMetrics.callbackUpdateCount++
} catch (error) {
console.warn(`[useGraphNodeManager] Failed to update Vue state for widget ${widget.name}:`, error)
// Ignore widget update errors to prevent cascade failures
}
}
})
@@ -363,7 +349,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
// Remove deleted nodes
for (const [id] of vueNodeData) {
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
vueNodeData.delete(id)
@@ -371,6 +357,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
nodePositions.delete(id)
nodeSizes.delete(id)
lastNodesSnapshot.delete(id)
spatialIndex.remove(id)
}
}
@@ -394,6 +381,15 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
// Add to spatial index
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.insert(id, bounds, id)
}
})
@@ -405,61 +401,28 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
}
// Most performant: Direct position sync without re-setting entire node
// Query visible nodes using spatial index when available
// Query visible nodes using QuadTree spatial index
const getVisibleNodeIds = (viewportBounds: Bounds): Set<string> => {
const startTime = performance.now()
let visibleIds: Set<string>
if (spatialIndexEnabled) {
// Use QuadTree for fast spatial query
const results = spatialIndex.query(viewportBounds)
visibleIds = new Set(results)
} else {
// Use simple linear search for small graphs
visibleIds = new Set<string>()
for (const [id, pos] of nodePositions) {
const size = nodeSizes.get(id)
if (!size) continue
// Simple bounds check
if (!(
pos.x + size.width < viewportBounds.x ||
pos.x > viewportBounds.x + viewportBounds.width ||
pos.y + size.height < viewportBounds.y ||
pos.y > viewportBounds.y + viewportBounds.height
)) {
visibleIds.add(id)
}
}
}
// Use QuadTree for fast spatial query
const results: string[] = spatialIndex.query(viewportBounds)
const visibleIds = new Set(results)
lastSpatialQueryTime = performance.now() - startTime
spatialMetrics.queryTime = lastSpatialQueryTime
return visibleIds
}
const detectChangesInRAF = () => {
const startTime = performance.now()
if (!graph?._nodes) return
let positionUpdates = 0
let sizeUpdates = 0
// Check if we should enable/disable spatial indexing
const nodeCount = graph._nodes.length
const shouldUseSpatialIndex = nodeCount >= SPATIAL_INDEX_THRESHOLD
if (shouldUseSpatialIndex !== spatialIndexEnabled) {
spatialIndexEnabled = shouldUseSpatialIndex
if (!spatialIndexEnabled) {
spatialIndex.clear()
}
spatialMetrics.strategy = spatialIndexEnabled ? 'quadtree' : 'linear'
}
// Update reactive positions and sizes
for (const node of graph._nodes) {
const id = String(node.id)
@@ -489,8 +452,8 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
sizeChanged = true
}
// Update spatial index if enabled and position/size changed
if (spatialIndexEnabled && (posChanged || sizeChanged)) {
// Update spatial index if position/size changed
if (posChanged || sizeChanged) {
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
@@ -506,10 +469,10 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
performanceMetrics.updateTime = endTime - startTime
performanceMetrics.nodeCount = vueNodeData.size
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
state => state.culled
(state) => state.culled
).length
spatialMetrics.nodesInIndex = spatialIndexEnabled ? spatialIndex.size : 0
spatialMetrics.nodesInIndex = spatialIndex.size
if (positionUpdates > 0 || sizeUpdates > 0) {
performanceMetrics.rafUpdateCount++
}
@@ -522,12 +485,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Override callbacks
graph.onNodeAdded = (node: LGraphNode) => {
console.log('[useGraphNodeManager] onNodeAdded:', node.id)
const id = String(node.id)
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Set up widget callbacks BEFORE extracting data
setupNodeWidgetCallbacks(node)
@@ -544,18 +506,16 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
// Add to spatial index if enabled
if (spatialIndexEnabled) {
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.insert(id, bounds, id)
// Add to spatial index
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.insert(id, bounds, id)
if (originalOnNodeAdded) {
void originalOnNodeAdded(node)
}
@@ -563,12 +523,10 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
graph.onNodeRemoved = (node: LGraphNode) => {
const id = String(node.id)
// Remove from spatial index if enabled
if (spatialIndexEnabled) {
spatialIndex.remove(id)
}
// Remove from spatial index
spatialIndex.remove(id)
nodeRefs.delete(id)
vueNodeData.delete(id)
nodeState.delete(id)
@@ -580,7 +538,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Initial sync
syncWithGraph()
// Start FPS tracking
startFPSTracking()
@@ -595,7 +553,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Stop FPS tracking
stopFPSTracking()
@@ -616,6 +574,15 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {
graph.onNodeAdded(node)
}
})
}
return {
vueNodeData: readonly(vueNodeData) as ReadonlyMap<string, VueNodeData>,
nodeState: readonly(nodeState) as ReadonlyMap<string, NodeState>,
@@ -636,6 +603,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
getVisibleNodeIds,
performanceMetrics,
spatialMetrics: readonly(spatialMetrics),
getSpatialIndexDebugInfo: () => spatialIndexEnabled ? spatialIndex.getDebugInfo() : null
getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
}
}