[fix] Fix widget value synchronization between Vue and LiteGraph

- Widget callbacks now explicitly set widget.value (fixes empty LiteGraph callbacks)
- Created useWidgetValue composable for consistent widget patterns
- Updated all widget components to use proper props/emit pattern
- Callbacks are set up before data extraction to ensure proper state sync
This commit is contained in:
bymyself
2025-07-03 17:14:02 -07:00
parent cd3296f49b
commit 122170fc0d
7 changed files with 349 additions and 23 deletions

View File

@@ -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
}
</script>

View File

@@ -3,7 +3,12 @@
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<InputText v-model="value" v-bind="filteredProps" :disabled="readonly" />
<InputText
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</div>
</template>
@@ -16,14 +21,25 @@ import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<string>({ required: true })
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
const props = defineProps<{
widget: SimplifiedWidget<string>
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)
)

View File

@@ -4,10 +4,11 @@
widget.name
}}</label>
<Select
v-model="value"
v-model="localValue"
:options="selectOptions"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</div>
</template>
@@ -21,14 +22,26 @@ import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<any>({ required: true })
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
const props = defineProps<{
widget: SimplifiedWidget<any>
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)
)

View File

@@ -3,7 +3,12 @@
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Slider v-model="value" v-bind="filteredProps" :disabled="readonly" />
<Slider
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</div>
</template>
@@ -16,14 +21,25 @@ import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<number>({ required: true })
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
const props = defineProps<{
widget: SimplifiedWidget<number>
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)
)

View File

@@ -3,7 +3,12 @@
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<ToggleSwitch v-model="value" v-bind="filteredProps" :disabled="readonly" />
<ToggleSwitch
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</div>
</template>
@@ -16,14 +21,25 @@ import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<boolean>({ required: true })
import { useBooleanWidgetValue } from '@/composables/graph/useWidgetValue'
const props = defineProps<{
widget: SimplifiedWidget<boolean>
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)
)

View File

@@ -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<string, VueNodeData>
@@ -73,8 +80,15 @@ export interface GraphNodeManager {
forceSync(): void
detectChangesInRAF(): void
// Spatial queries
getVisibleNodeIds(viewportBounds: Bounds): Set<string>
// 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<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
})
// 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<typeof originalCallback>[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<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)
}
}
}
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
}
}

View File

@@ -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<T, U = T> {
/** The widget configuration from LiteGraph */
widget: SimplifiedWidget<T>
/** 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<T, U = T> {
/** Local value for immediate UI updates */
localValue: Ref<T>
/** 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<T, U = T>({
widget,
modelValue,
defaultValue,
emit,
transform
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
// Local value for immediate UI updates
const localValue = ref<T>(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<T>,
onChange
}
}
/**
* Type-specific helper for string widgets
*/
export function useStringWidgetValue(
widget: SimplifiedWidget<string>,
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<number>,
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<boolean>,
modelValue: boolean,
emit: (event: 'update:modelValue', value: boolean) => void
) {
return useWidgetValue({
widget,
modelValue,
defaultValue: false,
emit,
transform: (value: boolean) => Boolean(value)
})
}