diff --git a/src/components/graph/vueNodes/NodeWidgets.vue b/src/components/graph/vueNodes/NodeWidgets.vue
index 30e766da2..aa60ea0c9 100644
--- a/src/components/graph/vueNodes/NodeWidgets.vue
+++ b/src/components/graph/vueNodes/NodeWidgets.vue
@@ -123,12 +123,11 @@ const simplifiedWidget = (widget: SafeWidgetData): SimplifiedWidget => {
// Handle widget value updates
const handleWidgetUpdate = (widget: SafeWidgetData, value: unknown) => {
- widget.value = value
-
+ // Call LiteGraph callback to update the authoritative state
+ // The callback will trigger the chained callback in useGraphNodeManager
+ // which will update the Vue state automatically
if (widget.callback) {
widget.callback(value)
}
-
- // TODO: Implement proper widget change handling to sync back to LiteGraph
}
diff --git a/src/components/graph/vueWidgets/WidgetInputText.vue b/src/components/graph/vueWidgets/WidgetInputText.vue
index 462957335..0c648d927 100644
--- a/src/components/graph/vueWidgets/WidgetInputText.vue
+++ b/src/components/graph/vueWidgets/WidgetInputText.vue
@@ -3,7 +3,12 @@
-
+
@@ -16,14 +21,25 @@ import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
-
-const value = defineModel({ required: true })
+import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
const props = defineProps<{
widget: SimplifiedWidget
+ modelValue: string
readonly?: boolean
}>()
+const emit = defineEmits<{
+ 'update:modelValue': [value: string]
+}>()
+
+// Use the composable for consistent widget value handling
+const { localValue, onChange } = useStringWidgetValue(
+ props.widget,
+ props.modelValue,
+ emit
+)
+
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
diff --git a/src/components/graph/vueWidgets/WidgetSelect.vue b/src/components/graph/vueWidgets/WidgetSelect.vue
index 2639c2e64..fd6ad317c 100644
--- a/src/components/graph/vueWidgets/WidgetSelect.vue
+++ b/src/components/graph/vueWidgets/WidgetSelect.vue
@@ -4,10 +4,11 @@
widget.name
}}
@@ -21,14 +22,26 @@ import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
-
-const value = defineModel({ required: true })
+import { useWidgetValue } from '@/composables/graph/useWidgetValue'
const props = defineProps<{
widget: SimplifiedWidget
+ modelValue: any
readonly?: boolean
}>()
+const emit = defineEmits<{
+ 'update:modelValue': [value: any]
+}>()
+
+// Use the composable for consistent widget value handling
+const { localValue, onChange } = useWidgetValue({
+ widget: props.widget,
+ modelValue: props.modelValue,
+ defaultValue: props.widget.options?.values?.[0] || '',
+ emit
+})
+
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
diff --git a/src/components/graph/vueWidgets/WidgetSlider.vue b/src/components/graph/vueWidgets/WidgetSlider.vue
index e7f2ece3e..f34ad04e2 100644
--- a/src/components/graph/vueWidgets/WidgetSlider.vue
+++ b/src/components/graph/vueWidgets/WidgetSlider.vue
@@ -3,7 +3,12 @@
-
+
@@ -16,14 +21,25 @@ import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
-
-const value = defineModel({ required: true })
+import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
const props = defineProps<{
widget: SimplifiedWidget
+ modelValue: number
readonly?: boolean
}>()
+const emit = defineEmits<{
+ 'update:modelValue': [value: number]
+}>()
+
+// Use the composable for consistent widget value handling
+const { localValue, onChange } = useNumberWidgetValue(
+ props.widget,
+ props.modelValue,
+ emit
+)
+
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
diff --git a/src/components/graph/vueWidgets/WidgetToggleSwitch.vue b/src/components/graph/vueWidgets/WidgetToggleSwitch.vue
index e74203b7c..1d2733fec 100644
--- a/src/components/graph/vueWidgets/WidgetToggleSwitch.vue
+++ b/src/components/graph/vueWidgets/WidgetToggleSwitch.vue
@@ -3,7 +3,12 @@
-
+
@@ -16,14 +21,25 @@ import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
-
-const value = defineModel({ required: true })
+import { useBooleanWidgetValue } from '@/composables/graph/useWidgetValue'
const props = defineProps<{
widget: SimplifiedWidget
+ modelValue: boolean
readonly?: boolean
}>()
+const emit = defineEmits<{
+ 'update:modelValue': [value: boolean]
+}>()
+
+// Use the composable for consistent widget value handling
+const { localValue, onChange } = useBooleanWidgetValue(
+ props.widget,
+ props.modelValue,
+ emit
+)
+
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts
index 31970f487..99fd4536a 100644
--- a/src/composables/graph/useGraphNodeManager.ts
+++ b/src/composables/graph/useGraphNodeManager.ts
@@ -4,7 +4,7 @@
*/
import type { LGraph, LGraphNode } from '@comfyorg/litegraph'
import { nextTick, reactive, readonly } from 'vue'
-import { useChainCallback } from '@/composables/functional/useChainCallback'
+import { QuadTree, type Bounds } from '@/utils/spatial/QuadTree'
export interface NodeState {
visible: boolean
@@ -51,6 +51,13 @@ export interface VueNodeData {
outputs?: unknown[]
}
+export interface SpatialMetrics {
+ strategy: 'linear' | 'quadtree'
+ queryTime: number
+ nodesInIndex: number
+ threshold: number
+}
+
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap
@@ -73,8 +80,15 @@ export interface GraphNodeManager {
forceSync(): void
detectChangesInRAF(): void
+ // Spatial queries
+ getVisibleNodeIds(viewportBounds: Bounds): Set
+
// Performance
performanceMetrics: PerformanceMetrics
+ spatialMetrics: SpatialMetrics
+
+ // Debug
+ getSpatialIndexDebugInfo(): any | null
}
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
@@ -106,6 +120,23 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
adaptiveQuality: false
})
+ // Spatial indexing for large graphs (auto-enables at 100+ nodes)
+ const SPATIAL_INDEX_THRESHOLD = 100
+ const spatialIndex = new QuadTree(
+ { x: -10000, y: -10000, width: 20000, height: 20000 },
+ { maxDepth: 6, maxItemsPerNode: 4 }
+ )
+ let spatialIndexEnabled = false
+ let lastSpatialQueryTime = 0
+
+ // Spatial metrics
+ const spatialMetrics = reactive({
+ strategy: 'linear',
+ queryTime: 0,
+ nodesInIndex: 0,
+ threshold: SPATIAL_INDEX_THRESHOLD
+ })
+
// FPS tracking state
let lastFrameTime = performance.now()
let frameCount = 0
@@ -228,13 +259,24 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
const nodeId = String(node.id)
node.widgets?.forEach(widget => {
- widget.callback = useChainCallback(widget.callback, () => {
+ // 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[]) => {
+ // 1. Update the widget value in LiteGraph
+ widget.value = value
+
+ // 2. Call the original callback if it exists
+ if (originalCallback) {
+ originalCallback.call(widget, value as Parameters[0], ...args)
+ }
+
+ // 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: widget.value }
+ ? { ...w, value: value }
: w
)
vueNodeData.set(nodeId, {
@@ -246,7 +288,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
} catch (error) {
console.warn(`[useGraphNodeManager] Failed to update Vue state for widget ${widget.name}:`, error)
}
- })
+ }
})
}
@@ -363,6 +405,41 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
}
// Most performant: Direct position sync without re-setting entire node
+ // Query visible nodes using spatial index when available
+ const getVisibleNodeIds = (viewportBounds: Bounds): Set => {
+ const startTime = performance.now()
+
+ let visibleIds: Set
+
+ 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()
+ 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)
+ }
+ }
+ }
+
+ lastSpatialQueryTime = performance.now() - startTime
+ spatialMetrics.queryTime = lastSpatialQueryTime
+
+ return visibleIds
+ }
+
const detectChangesInRAF = () => {
const startTime = performance.now()
@@ -371,12 +448,27 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
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)
const currentPos = nodePositions.get(id)
const currentSize = nodeSizes.get(id)
+ let posChanged = false
+ let sizeChanged = false
+
if (
!currentPos ||
currentPos.x !== node.pos[0] ||
@@ -384,6 +476,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
) {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
positionUpdates++
+ posChanged = true
}
if (
@@ -393,6 +486,18 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
) {
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
sizeUpdates++
+ sizeChanged = true
+ }
+
+ // Update spatial index if enabled and position/size changed
+ if (spatialIndexEnabled && (posChanged || sizeChanged)) {
+ const bounds: Bounds = {
+ x: node.pos[0],
+ y: node.pos[1],
+ width: node.size[0],
+ height: node.size[1]
+ }
+ spatialIndex.update(id, bounds)
}
}
@@ -403,6 +508,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
state => state.culled
).length
+ spatialMetrics.nodesInIndex = spatialIndexEnabled ? spatialIndex.size : 0
if (positionUpdates > 0 || sizeUpdates > 0) {
performanceMetrics.rafUpdateCount++
@@ -421,8 +527,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Store non-reactive reference to original node
nodeRefs.set(id, node)
+
+ // Set up widget callbacks BEFORE extracting data
+ setupNodeWidgetCallbacks(node)
- // Extract safe data for Vue
+ // Extract safe data for Vue (now with proper callbacks)
vueNodeData.set(id, extractVueNodeData(node))
// Set up reactive tracking
@@ -436,7 +545,16 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
- setupNodeWidgetCallbacks(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)
+ }
if (originalOnNodeAdded) {
void originalOnNodeAdded(node)
@@ -445,6 +563,12 @@ 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)
+ }
+
nodeRefs.delete(id)
vueNodeData.delete(id)
nodeState.delete(id)
@@ -485,6 +609,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
pendingUpdates.clear()
criticalUpdates.clear()
lowPriorityUpdates.clear()
+ spatialIndex.clear()
}
}
@@ -508,6 +633,9 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
scheduleUpdate,
forceSync: syncWithGraph,
detectChangesInRAF,
- performanceMetrics
+ getVisibleNodeIds,
+ performanceMetrics,
+ spatialMetrics: readonly(spatialMetrics),
+ getSpatialIndexDebugInfo: () => spatialIndexEnabled ? spatialIndex.getDebugInfo() : null
}
}
diff --git a/src/composables/graph/useWidgetValue.ts b/src/composables/graph/useWidgetValue.ts
new file mode 100644
index 000000000..3a61952f5
--- /dev/null
+++ b/src/composables/graph/useWidgetValue.ts
@@ -0,0 +1,138 @@
+/**
+ * Composable for managing widget value synchronization between Vue and LiteGraph
+ * Provides consistent pattern for immediate UI updates and LiteGraph callbacks
+ */
+import { ref, watch, type Ref } from 'vue'
+import type { SimplifiedWidget } from '@/types/simplifiedWidget'
+
+export interface UseWidgetValueOptions {
+ /** The widget configuration from LiteGraph */
+ widget: SimplifiedWidget
+ /** The current value from parent component */
+ modelValue: T
+ /** Default value if modelValue is null/undefined */
+ defaultValue: T
+ /** Emit function from component setup */
+ emit: (event: 'update:modelValue', value: T) => void
+ /** Optional value transformer before sending to LiteGraph */
+ transform?: (value: U) => T
+}
+
+export interface UseWidgetValueReturn {
+ /** Local value for immediate UI updates */
+ localValue: Ref
+ /** Handler for user interactions */
+ onChange: (newValue: U) => void
+}
+
+/**
+ * Manages widget value synchronization with LiteGraph
+ *
+ * @example
+ * ```vue
+ * const { localValue, onChange } = useWidgetValue({
+ * widget: props.widget,
+ * modelValue: props.modelValue,
+ * defaultValue: ''
+ * })
+ * ```
+ */
+export function useWidgetValue({
+ widget,
+ modelValue,
+ defaultValue,
+ emit,
+ transform
+}: UseWidgetValueOptions): UseWidgetValueReturn {
+ // Local value for immediate UI updates
+ const localValue = ref(modelValue ?? defaultValue)
+
+ // Handle user changes
+ const onChange = (newValue: U) => {
+ // Handle different PrimeVue component signatures
+ let processedValue: T
+ if (transform) {
+ processedValue = transform(newValue)
+ } else {
+ processedValue = newValue as unknown as T
+ }
+
+ // 1. Update local state for immediate UI feedback
+ localValue.value = processedValue
+
+ // 2. Emit to parent component
+ emit('update:modelValue', processedValue)
+
+ // 3. Call LiteGraph callback to update authoritative state
+ if (widget.callback) {
+ widget.callback(processedValue)
+ }
+ }
+
+ // Watch for external updates from LiteGraph
+ watch(() => modelValue, (newValue) => {
+ localValue.value = newValue ?? defaultValue
+ })
+
+ return {
+ localValue: localValue as Ref,
+ onChange
+ }
+}
+
+/**
+ * Type-specific helper for string widgets
+ */
+export function useStringWidgetValue(
+ widget: SimplifiedWidget,
+ modelValue: string,
+ emit: (event: 'update:modelValue', value: string) => void
+) {
+ return useWidgetValue({
+ widget,
+ modelValue,
+ defaultValue: '',
+ emit,
+ transform: (value: string | undefined) => String(value || '') // Handle undefined from PrimeVue
+ })
+}
+
+/**
+ * Type-specific helper for number widgets
+ */
+export function useNumberWidgetValue(
+ widget: SimplifiedWidget,
+ modelValue: number,
+ emit: (event: 'update:modelValue', value: number) => void
+) {
+ return useWidgetValue({
+ widget,
+ modelValue,
+ defaultValue: 0,
+ emit,
+ transform: (value: number | number[]) => {
+ // Handle PrimeVue Slider which can emit number | number[]
+ if (Array.isArray(value)) {
+ return value[0] || 0
+ }
+ return Number(value) || 0
+ }
+ })
+}
+
+/**
+ * Type-specific helper for boolean widgets
+ */
+export function useBooleanWidgetValue(
+ widget: SimplifiedWidget,
+ modelValue: boolean,
+ emit: (event: 'update:modelValue', value: boolean) => void
+) {
+ return useWidgetValue({
+ widget,
+ modelValue,
+ defaultValue: false,
+ emit,
+ transform: (value: boolean) => Boolean(value)
+ })
+}
\ No newline at end of file