Refactor: Let LGraphNode handle more events itself (#5709)

## Summary

Don't route events up through GraphCanvas if the component itself can
handle the changes

## Changes

- **What**: Reduce the indirect access or action dispatch to
composables/stores.

## Review Focus

The behavior should be either equivalent or a little snappier than
before. Also, the local state in LGraphNode has (almost) all been
removed in favor of reacting to the nodeData prop.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5709-Refactor-Let-LGraphNode-handle-more-events-itself-2756d73d365081e6a88ce6241bceecc0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2025-09-20 22:14:30 -07:00
committed by GitHub
parent 295332dc46
commit c4c0e52e64
19 changed files with 159 additions and 312 deletions

View File

@@ -77,6 +77,12 @@ export default defineConfig([
'@typescript-eslint/prefer-as-const': 'off', '@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error', '@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'always'
}
],
'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-imports': 'error',
'vue/no-v-html': 'off', 'vue/no-v-html': 'off',
// Enforce dark-theme: instead of dark: prefix // Enforce dark-theme: instead of dark: prefix

View File

@@ -53,9 +53,6 @@
" "
:zoom-level="canvasStore.canvas?.ds?.scale || 1" :zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id" :data-node-id="nodeData.id"
@node-click="handleNodeSelect"
@update:collapsed="handleNodeCollapse"
@update:title="handleNodeTitleUpdate"
/> />
</TransformPane> </TransformPane>
@@ -121,8 +118,6 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue' import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue' import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider'
import { UnauthorizedError, api } from '@/scripts/api' import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app' import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker' import { ChangeTracker } from '@/scripts/changeTracker'
@@ -173,7 +168,6 @@ const { shouldRenderVueNodes } = useVueFeatureFlags()
// Vue node system // Vue node system
const vueNodeLifecycle = useVueNodeLifecycle() const vueNodeLifecycle = useVueNodeLifecycle()
const viewportCulling = useViewportCulling() const viewportCulling = useViewportCulling()
const nodeEventHandlers = useNodeEventHandlers()
const handleVueNodeLifecycleReset = async () => { const handleVueNodeLifecycleReset = async () => {
if (shouldRenderVueNodes.value) { if (shouldRenderVueNodes.value) {
@@ -204,12 +198,6 @@ const handleTransformUpdate = () => {
// TODO: Fix paste position sync in separate PR // TODO: Fix paste position sync in separate PR
vueNodeLifecycle.detectChangesInRAF.value() vueNodeLifecycle.detectChangesInRAF.value()
} }
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
// Provide execution state to all Vue nodes
useExecutionStateProvider()
watchEffect(() => { watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated') nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')

View File

@@ -11,7 +11,7 @@
:style="`backgroundColor: ${containerStyles.backgroundColor};`" :style="`backgroundColor: ${containerStyles.backgroundColor};`"
:pt="{ :pt="{
header: 'hidden', header: 'hidden',
content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1' content: 'p-1 h-10 flex flex-row gap-1'
}" }"
@wheel="canvasInteractions.handleWheel" @wheel="canvasInteractions.handleWheel"
> >

View File

@@ -48,7 +48,6 @@ export interface LinkReleaseContextExtended {
links: ConnectingLink[] links: ConnectingLink[]
} }
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface LiteGraphCanvasEvent extends CustomEvent<CanvasEventDetail> {} export interface LiteGraphCanvasEvent extends CustomEvent<CanvasEventDetail> {}
export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> { export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {

View File

@@ -1,18 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
import type { NodeProgressState } from '@/schemas/apiSchema'
/**
* Injection key for providing executing node IDs to Vue node components.
* Contains a reactive Set of currently executing node IDs (as strings).
*/
export const ExecutingNodeIdsKey: InjectionKey<Ref<Set<string>>> =
Symbol('executingNodeIds')
/**
* Injection key for providing node progress states to Vue node components.
* Contains a reactive Record of node IDs to their current progress state.
*/
export const NodeProgressStatesKey: InjectionKey<
Ref<Record<string, NodeProgressState>>
> = Symbol('nodeProgressStates')

View File

@@ -1,6 +1,6 @@
import type { InjectionKey } from 'vue' import type { InjectionKey } from 'vue'
import type { Point } from '@/renderer/core/layout/types' import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
/** /**
* Lightweight, injectable transform state used by layout-aware components. * Lightweight, injectable transform state used by layout-aware components.
@@ -21,29 +21,11 @@ import type { Point } from '@/renderer/core/layout/types'
* const state = inject(TransformStateKey)! * const state = inject(TransformStateKey)!
* const screen = state.canvasToScreen({ x: 100, y: 50 }) * const screen = state.canvasToScreen({ x: 100, y: 50 })
*/ */
interface TransformState { interface TransformState
/** Convert a screen-space point (CSS pixels) to canvas space. */ extends Pick<
screenToCanvas: (p: Point) => Point ReturnType<typeof useTransformState>,
/** Convert a canvas-space point to screen space (CSS pixels). */ 'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
canvasToScreen: (p: Point) => Point > {}
/** Current pan/zoom; `x`/`y` are offsets, `z` is scale. */
camera?: { x: number; y: number; z: number }
/**
* Test whether a node's rectangle intersects the (expanded) viewport.
* Handy for viewport culling and lazy work.
*
* @param nodePos Top-left in canvas space `[x, y]`
* @param nodeSize Size in canvas units `[width, height]`
* @param viewport Screen-space viewport `{ width, height }`
* @param margin Optional fractional margin (e.g. `0.2` = 20%)
*/
isNodeInViewport?: (
nodePos: ArrayLike<number>,
nodeSize: ArrayLike<number>,
viewport: { width: number; height: number },
margin?: number
) => boolean
}
export const TransformStateKey: InjectionKey<TransformState> = export const TransformStateKey: InjectionKey<TransformState> =
Symbol('transformState') Symbol('transformState')

View File

@@ -54,7 +54,7 @@
:lod-level="lodLevel" :lod-level="lodLevel"
:collapsed="isCollapsed" :collapsed="isCollapsed"
@collapse="handleCollapse" @collapse="handleCollapse"
@update:title="handleTitleUpdate" @update:title="handleHeaderTitleUpdate"
@enter-subgraph="handleEnterSubgraph" @enter-subgraph="handleEnterSubgraph"
/> />
</div> </div>
@@ -101,7 +101,6 @@
:node-data="nodeData" :node-data="nodeData"
:readonly="readonly" :readonly="readonly"
:lod-level="lodLevel" :lod-level="lodLevel"
@slot-click="handleSlotClick"
/> />
<!-- Widgets rendered at reduced+ detail --> <!-- Widgets rendered at reduced+ detail -->
@@ -140,15 +139,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { import { computed, inject, onErrorCaptured, onMounted, provide, ref } from 'vue'
computed,
inject,
onErrorCaptured,
onMounted,
ref,
toRef,
watch
} from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -156,13 +147,13 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions' import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState' import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout' import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD' import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState' import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore'
@@ -197,23 +188,10 @@ const {
zoomLevel = 1 zoomLevel = 1
} = defineProps<LGraphNodeProps>() } = defineProps<LGraphNodeProps>()
const emit = defineEmits<{ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeSelect } =
'node-click': [ useNodeEventHandlers()
event: PointerEvent,
nodeData: VueNodeData,
wasDragging: boolean
]
'slot-click': [
event: PointerEvent,
nodeData: VueNodeData,
slotIndex: number,
isInput: boolean
]
'update:collapsed': [nodeId: string, collapsed: boolean]
'update:title': [nodeId: string, newTitle: string]
}>()
useVueElementTracking(nodeData.id, 'node') useVueElementTracking(() => nodeData.id, 'node')
const { selectedNodeIds } = storeToRefs(useCanvasStore()) const { selectedNodeIds } = storeToRefs(useCanvasStore())
@@ -226,7 +204,7 @@ const isSelected = computed(() => {
}) })
// Use execution state composable // Use execution state composable
const { executing, progress } = useNodeExecutionState(nodeData.id) const { executing, progress } = useNodeExecutionState(() => nodeData.id)
// Direct access to execution store for error state // Direct access to execution store for error state
const executionStore = useExecutionStore() const executionStore = useExecutionStore()
@@ -245,14 +223,13 @@ const bypassed = computed((): boolean => nodeData.mode === 4)
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions() const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
// LOD (Level of Detail) system based on zoom level // LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => zoomLevel)
const { const {
lodLevel, lodLevel,
shouldRenderWidgets, shouldRenderWidgets,
shouldRenderSlots, shouldRenderSlots,
shouldRenderContent, shouldRenderContent,
lodCssClass lodCssClass
} = useLOD(zoomRef) } = useLOD(() => zoomLevel)
// Computed properties for template usage // Computed properties for template usage
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL) const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
@@ -268,16 +245,18 @@ onErrorCaptured((error) => {
}) })
// Use layout system for node position and dragging // Use layout system for node position and dragging
const { position: layoutPosition, zIndex, resize } = useNodeLayout(nodeData.id) const {
position: layoutPosition,
zIndex,
resize
} = useNodeLayout(() => nodeData.id)
const { const {
handlePointerDown, handlePointerDown,
handlePointerUp, handlePointerUp,
handlePointerMove, handlePointerMove,
isDragging, isDragging,
dragStyle dragStyle
} = useNodePointerInteractions(nodeData, (event, nodeData, wasDragging) => { } = useNodePointerInteractions(() => nodeData, handleNodeSelect)
emit('node-click', event, nodeData, wasDragging)
})
onMounted(() => { onMounted(() => {
if (size && transformState?.camera) { if (size && transformState?.camera) {
@@ -291,17 +270,7 @@ onMounted(() => {
}) })
// Track collapsed state // Track collapsed state
const isCollapsed = ref(nodeData.flags?.collapsed ?? false) const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
// Watch for external changes to the collapsed state
watch(
() => nodeData.flags?.collapsed,
(newCollapsed: boolean | undefined) => {
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
isCollapsed.value = newCollapsed
}
}
)
// Check if node has custom content (like image outputs) // Check if node has custom content (like image outputs)
const hasCustomContent = computed(() => { const hasCustomContent = computed(() => {
@@ -310,12 +279,13 @@ const hasCustomContent = computed(() => {
}) })
// Computed classes and conditions for better reusability // Computed classes and conditions for better reusability
const separatorClasses = const separatorClasses = cn(
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full' 'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300' )
const progressClasses = cn('h-2 bg-primary-500 transition-all duration-300')
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState( const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
nodeData.id, () => nodeData.id,
{ {
isMinimalLOD, isMinimalLOD,
isCollapsed isCollapsed
@@ -356,31 +326,11 @@ const outlineClass = computed(() => {
// Event handlers // Event handlers
const handleCollapse = () => { const handleCollapse = () => {
isCollapsed.value = !isCollapsed.value handleNodeCollapse(nodeData.id, !isCollapsed.value)
// Emit event so parent can sync with LiteGraph if needed
emit('update:collapsed', nodeData.id, isCollapsed.value)
} }
const handleSlotClick = ( const handleHeaderTitleUpdate = (newTitle: string) => {
event: PointerEvent, handleNodeTitleUpdate(nodeData.id, newTitle)
slotIndex: number,
isInput: boolean
) => {
if (!nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handleSlotClick')
return
}
// Don't handle slot clicks when canvas is in panning mode
if (!shouldHandleNodePointerEvents.value) {
return
}
emit('slot-click', event, nodeData, slotIndex, isInput)
}
const handleTitleUpdate = (newTitle: string) => {
emit('update:title', nodeData.id, newTitle)
} }
const handleEnterSubgraph = () => { const handleEnterSubgraph = () => {
@@ -410,15 +360,17 @@ const handleEnterSubgraph = () => {
const nodeOutputs = useNodeOutputStore() const nodeOutputs = useNodeOutputStore()
const nodeImageUrls = ref<string[]>([]) const nodeOutputLocatorId = computed(() =>
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => { nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
)
const nodeImageUrls = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const locatorId = getLocatorIdFromNodeData(nodeData) const locatorId = getLocatorIdFromNodeData(nodeData)
// Use root graph for getNodeByLocatorId since it needs to traverse from root // Use root graph for getNodeByLocatorId since it needs to traverse from root
const rootGraph = app.graph?.rootGraph || app.graph const rootGraph = app.graph?.rootGraph || app.graph
if (!rootGraph) { if (!rootGraph) {
nodeImageUrls.value = [] return []
return
} }
const node = getNodeByLocatorId(rootGraph, locatorId) const node = getNodeByLocatorId(rootGraph, locatorId)
@@ -426,23 +378,13 @@ const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
if (node && newOutputs?.images?.length) { if (node && newOutputs?.images?.length) {
const urls = nodeOutputs.getNodeImageUrls(node) const urls = nodeOutputs.getNodeImageUrls(node)
if (urls) { if (urls) {
nodeImageUrls.value = urls return urls
} }
} else {
// Clear URLs if no outputs or no images
nodeImageUrls.value = []
} }
} // Clear URLs if no outputs or no images
return []
})
const nodeOutputLocatorId = computed(() => const nodeContainerRef = ref()
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id provide('tooltipContainer', nodeContainerRef)
)
watch(
() => nodeOutputs.nodeOutputs[nodeOutputLocatorId.value],
(newOutputs) => {
onNodeOutputsUpdate(newOutputs)
},
{ deep: true }
)
</script> </script>

View File

@@ -63,7 +63,6 @@ import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { import {
getLocatorIdFromNodeData, getLocatorIdFromNodeData,
@@ -73,7 +72,6 @@ import {
interface NodeHeaderProps { interface NodeHeaderProps {
nodeData?: VueNodeData nodeData?: VueNodeData
readonly?: boolean readonly?: boolean
lodLevel?: LODLevel
collapsed?: boolean collapsed?: boolean
} }

View File

@@ -35,7 +35,6 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph' import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { isSlotObject } from '@/utils/typeGuardUtil' import { isSlotObject } from '@/utils/typeGuardUtil'
import InputSlot from './InputSlot.vue' import InputSlot from './InputSlot.vue'
@@ -44,7 +43,6 @@ import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps { interface NodeSlotsProps {
nodeData?: VueNodeData nodeData?: VueNodeData
readonly?: boolean readonly?: boolean
lodLevel?: LODLevel
} }
const { nodeData = null, readonly } = defineProps<NodeSlotsProps>() const { nodeData = null, readonly } = defineProps<NodeSlotsProps>()

View File

@@ -82,6 +82,7 @@ function useNodeEventHandlersIndividual() {
const currentCollapsed = node.flags?.collapsed ?? false const currentCollapsed = node.flags?.collapsed ?? false
if (currentCollapsed !== collapsed) { if (currentCollapsed !== collapsed) {
node.collapse() node.collapse()
nodeManager.value.scheduleUpdate(nodeId, 'critical')
} }
} }

View File

@@ -8,7 +8,13 @@
* Supports different element types (nodes, slots, widgets, etc.) with * Supports different element types (nodes, slots, widgets, etc.) with
* customizable data attributes and update handlers. * customizable data attributes and update handlers.
*/ */
import { getCurrentInstance, onMounted, onUnmounted } from 'vue' import {
type MaybeRefOrGetter,
getCurrentInstance,
onMounted,
onUnmounted,
toValue
} from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -154,9 +160,10 @@ const resizeObserver = new ResizeObserver((entries) => {
* ``` * ```
*/ */
export function useVueElementTracking( export function useVueElementTracking(
appIdentifier: string, appIdentifierMaybe: MaybeRefOrGetter<string>,
trackingType: string trackingType: string
) { ) {
const appIdentifier = toValue(appIdentifierMaybe)
onMounted(() => { onMounted(() => {
const element = getCurrentInstance()?.proxy?.$el const element = getCurrentInstance()?.proxy?.$el
if (!(element instanceof HTMLElement) || !appIdentifier) return if (!(element instanceof HTMLElement) || !appIdentifier) return

View File

@@ -1,36 +0,0 @@
import { storeToRefs } from 'pinia'
import { computed, provide } from 'vue'
import {
ExecutingNodeIdsKey,
NodeProgressStatesKey
} from '@/renderer/core/canvas/injectionKeys'
import { useExecutionStore } from '@/stores/executionStore'
/**
* Composable for providing execution state to Vue node children
*
* This composable sets up the execution state providers that can be injected
* by child Vue nodes using useNodeExecutionState.
*
* Should be used in the parent component that manages Vue nodes (e.g., GraphCanvas).
*/
export const useExecutionStateProvider = () => {
const executionStore = useExecutionStore()
const { executingNodeIds: storeExecutingNodeIds, nodeProgressStates } =
storeToRefs(executionStore)
// Convert execution store data to the format expected by Vue nodes
const executingNodeIds = computed(
() => new Set(storeExecutingNodeIds.value.map(String))
)
// Provide the execution state to all child Vue nodes
provide(ExecutingNodeIdsKey, executingNodeIds)
provide(NodeProgressStatesKey, nodeProgressStates)
return {
executingNodeIds,
nodeProgressStates
}
}

View File

@@ -1,10 +1,7 @@
import { computed, inject, ref } from 'vue' import { storeToRefs } from 'pinia'
import { type MaybeRefOrGetter, computed, toValue } from 'vue'
import { import { useExecutionStore } from '@/stores/executionStore'
ExecutingNodeIdsKey,
NodeProgressStatesKey
} from '@/renderer/core/canvas/injectionKeys'
import type { NodeProgressState } from '@/schemas/apiSchema'
/** /**
* Composable for managing execution state of Vue-based nodes * Composable for managing execution state of Vue-based nodes
@@ -12,18 +9,18 @@ import type { NodeProgressState } from '@/schemas/apiSchema'
* Provides reactive access to execution state and progress for a specific node * Provides reactive access to execution state and progress for a specific node
* by injecting execution data from the parent GraphCanvas provider. * by injecting execution data from the parent GraphCanvas provider.
* *
* @param nodeId - The ID of the node to track execution state for * @param nodeIdMaybe - The ID of the node to track execution state for
* @returns Object containing reactive execution state and progress * @returns Object containing reactive execution state and progress
*/ */
export const useNodeExecutionState = (nodeId: string) => { export const useNodeExecutionState = (
const executingNodeIds = inject(ExecutingNodeIdsKey, ref(new Set<string>())) nodeIdMaybe: MaybeRefOrGetter<string>
const nodeProgressStates = inject( ) => {
NodeProgressStatesKey, const nodeId = toValue(nodeIdMaybe)
ref<Record<string, NodeProgressState>>({}) const { uniqueExecutingNodeIdStrings, nodeProgressStates } =
) storeToRefs(useExecutionStore())
const executing = computed(() => { const executing = computed(() => {
return executingNodeIds.value.has(nodeId) return uniqueExecutingNodeIdStrings.value.has(nodeId)
}) })
const progress = computed(() => { const progress = computed(() => {

View File

@@ -5,7 +5,7 @@ import { storeToRefs } from 'pinia'
* Uses customRef for shared write access with Canvas renderer. * Uses customRef for shared write access with Canvas renderer.
* Provides dragging functionality and reactive layout state. * Provides dragging functionality and reactive layout state.
*/ */
import { computed, inject } from 'vue' import { type MaybeRefOrGetter, computed, inject, toValue } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
@@ -17,7 +17,8 @@ import { LayoutSource, type Point } from '@/renderer/core/layout/types'
* Composable for individual Vue node components * Composable for individual Vue node components
* Uses customRef for shared write access with Canvas renderer * Uses customRef for shared write access with Canvas renderer
*/ */
export function useNodeLayout(nodeId: string) { export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
const nodeId = toValue(nodeIdMaybe)
const mutations = useLayoutMutations() const mutations = useLayoutMutations()
const { selectedNodeIds } = storeToRefs(useCanvasStore()) const { selectedNodeIds } = storeToRefs(useCanvasStore())

View File

@@ -27,7 +27,7 @@
* <NodeSlots v-if="shouldRenderSlots" /> * <NodeSlots v-if="shouldRenderSlots" />
* ``` * ```
*/ */
import { type Ref, computed, readonly } from 'vue' import { type MaybeRefOrGetter, computed, readonly, toRef } from 'vue'
export enum LODLevel { export enum LODLevel {
MINIMAL = 'minimal', // zoom <= 0.4 MINIMAL = 'minimal', // zoom <= 0.4
@@ -78,7 +78,8 @@ const LOD_CONFIGS: Record<LODLevel, LODConfig> = {
* @param zoomRef - Reactive reference to current zoom level (camera.z) * @param zoomRef - Reactive reference to current zoom level (camera.z)
* @returns LOD state and configuration * @returns LOD state and configuration
*/ */
export function useLOD(zoomRef: Ref<number>) { export function useLOD(zoomRefMaybe: MaybeRefOrGetter<number>) {
const zoomRef = toRef(zoomRefMaybe)
// Continuous LOD score (0-1) for smooth transitions // Continuous LOD score (0-1) for smooth transitions
const lodScore = computed(() => { const lodScore = computed(() => {
const zoom = zoomRef.value const zoom = zoomRef.value

View File

@@ -1,16 +1,17 @@
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { type Ref, computed } from 'vue' import { type MaybeRefOrGetter, type Ref, computed, toValue } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore'
export const useNodePreviewState = ( export const useNodePreviewState = (
nodeId: string, nodeIdMaybe: MaybeRefOrGetter<string>,
options?: { options?: {
isMinimalLOD?: Ref<boolean> isMinimalLOD?: Ref<boolean>
isCollapsed?: Ref<boolean> isCollapsed?: Ref<boolean>
} }
) => { ) => {
const nodeId = toValue(nodeIdMaybe)
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const { nodePreviewImages } = storeToRefs(useNodeOutputStore()) const { nodePreviewImages } = storeToRefs(useNodeOutputStore())

View File

@@ -43,6 +43,57 @@ interface QueuedPrompt {
workflow?: ComfyWorkflow workflow?: ComfyWorkflow
} }
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
const node = graph.getNodeById(id)
if (node?.isSubgraphNode()) return node.subgraph
}
/**
* Recursively get the subgraph objects for the given subgraph instance IDs
* @param currentGraph The current graph
* @param subgraphNodeIds The instance IDs
* @param subgraphs The subgraphs
* @returns The subgraphs that correspond to each of the instance IDs.
*/
function getSubgraphsFromInstanceIds(
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] {
// Last segment is the node portion; nothing to do.
if (subgraphNodeIds.length === 1) return subgraphs
const currentPart = subgraphNodeIds.shift()
if (currentPart === undefined) return subgraphs
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
/**
* Convert execution context node IDs to NodeLocatorIds
* @param nodeId The node ID from execution context (could be execution ID)
* @returns The NodeLocatorId
*/
function executionIdToNodeLocatorId(nodeId: string | number): NodeLocatorId {
const nodeIdStr = String(nodeId)
if (!nodeIdStr.includes(':')) {
// It's a top-level node ID
return nodeIdStr
}
// It's an execution node ID
const parts = nodeIdStr.split(':')
const localNodeId = parts[parts.length - 1]
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
return nodeLocatorId
}
export const useExecutionStore = defineStore('execution', () => { export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
@@ -55,29 +106,6 @@ export const useExecutionStore = defineStore('execution', () => {
// This is the progress of all nodes in the currently executing workflow // This is the progress of all nodes in the currently executing workflow
const nodeProgressStates = ref<Record<string, NodeProgressState>>({}) const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
/**
* Convert execution context node IDs to NodeLocatorIds
* @param nodeId The node ID from execution context (could be execution ID)
* @returns The NodeLocatorId
*/
const executionIdToNodeLocatorId = (
nodeId: string | number
): NodeLocatorId => {
const nodeIdStr = String(nodeId)
if (!nodeIdStr.includes(':')) {
// It's a top-level node ID
return nodeIdStr
}
// It's an execution node ID
const parts = nodeIdStr.split(':')
const localNodeId = parts[parts.length - 1]
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
return nodeLocatorId
}
const mergeExecutionProgressStates = ( const mergeExecutionProgressStates = (
currentState: NodeProgressState | undefined, currentState: NodeProgressState | undefined,
newState: NodeProgressState newState: NodeProgressState
@@ -139,9 +167,13 @@ export const useExecutionStore = defineStore('execution', () => {
// @deprecated For backward compatibility - stores the primary executing node ID // @deprecated For backward compatibility - stores the primary executing node ID
const executingNodeId = computed<NodeId | null>(() => { const executingNodeId = computed<NodeId | null>(() => {
return executingNodeIds.value.length > 0 ? executingNodeIds.value[0] : null return executingNodeIds.value[0] ?? null
}) })
const uniqueExecutingNodeIdStrings = computed(
() => new Set(executingNodeIds.value.map(String))
)
// For backward compatibility - returns the primary executing node // For backward compatibility - returns the primary executing node
const executingNode = computed<ComfyNode | null>(() => { const executingNode = computed<ComfyNode | null>(() => {
if (!executingNodeId.value) return null if (!executingNodeId.value) return null
@@ -159,36 +191,6 @@ export const useExecutionStore = defineStore('execution', () => {
) )
}) })
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
const node = graph.getNodeById(id)
if (node?.isSubgraphNode()) return node.subgraph
}
/**
* Recursively get the subgraph objects for the given subgraph instance IDs
* @param currentGraph The current graph
* @param subgraphNodeIds The instance IDs
* @param subgraphs The subgraphs
* @returns The subgraphs that correspond to each of the instance IDs.
*/
const getSubgraphsFromInstanceIds = (
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] => {
// Last segment is the node portion; nothing to do.
if (subgraphNodeIds.length === 1) return subgraphs
const currentPart = subgraphNodeIds.shift()
if (currentPart === undefined) return subgraphs
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
// This is the progress of the currently executing node (for backward compatibility) // This is the progress of the currently executing node (for backward compatibility)
const _executingNodeProgress = ref<ProgressWsMessage | null>(null) const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
const executingNodeProgress = computed(() => const executingNodeProgress = computed(() =>
@@ -423,66 +425,25 @@ export const useExecutionStore = defineStore('execution', () => {
return { return {
isIdle, isIdle,
clientId, clientId,
/**
* The id of the prompt that is currently being executed
*/
activePromptId, activePromptId,
/**
* The queued prompts
*/
queuedPrompts, queuedPrompts,
/**
* The node errors from the previous execution.
*/
lastNodeErrors, lastNodeErrors,
/**
* The error from the previous execution.
*/
lastExecutionError, lastExecutionError,
/**
* Local node ID for the most recent execution error.
*/
lastExecutionErrorNodeId, lastExecutionErrorNodeId,
/**
* The id of the node that is currently being executed (backward compatibility)
*/
executingNodeId, executingNodeId,
/**
* The list of all nodes that are currently executing
*/
executingNodeIds, executingNodeIds,
/**
* The prompt that is currently being executed
*/
activePrompt, activePrompt,
/**
* The total number of nodes to execute
*/
totalNodesToExecute, totalNodesToExecute,
/**
* The number of nodes that have been executed
*/
nodesExecuted, nodesExecuted,
/**
* The progress of the execution
*/
executionProgress, executionProgress,
/**
* The node that is currently being executed (backward compatibility)
*/
executingNode, executingNode,
/**
* The progress of the executing node (backward compatibility)
*/
executingNodeProgress, executingNodeProgress,
/**
* All node progress states from progress_state events
*/
nodeProgressStates, nodeProgressStates,
nodeLocationProgressStates, nodeLocationProgressStates,
bindExecutionEvents, bindExecutionEvents,
unbindExecutionEvents, unbindExecutionEvents,
storePrompt, storePrompt,
uniqueExecutingNodeIdStrings,
// Raw executing progress data for backward compatibility in ComfyApp. // Raw executing progress data for backward compatibility in ComfyApp.
_executingNodeProgress, _executingNodeProgress,
// NodeLocatorId conversion helpers // NodeLocatorId conversion helpers

View File

@@ -82,7 +82,7 @@ declare module '@/lib/litegraph/src/litegraph' {
} }
// Add interface augmentations into the class itself // Add interface augmentations into the class itself
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface BaseWidget extends IBaseWidget {} interface BaseWidget extends IBaseWidget {}
interface LGraphNode { interface LGraphNode {

View File

@@ -1,12 +1,13 @@
import { createTestingPinia } from '@pinia/testing' import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue' import { computed, toValue } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers' import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
const mockData = vi.hoisted(() => ({ const mockData = vi.hoisted(() => ({
@@ -25,6 +26,14 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => {
} }
}) })
vi.mock(
'@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
() => {
const handleNodeSelect = vi.fn()
return { useNodeEventHandlers: () => ({ handleNodeSelect }) }
}
)
vi.mock( vi.mock(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking', '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
() => ({ () => ({
@@ -130,7 +139,13 @@ describe('LGraphNode', () => {
it('should call resize tracking composable with node ID', () => { it('should call resize tracking composable with node ID', () => {
mountLGraphNode({ nodeData: mockNodeData }) mountLGraphNode({ nodeData: mockNodeData })
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node') expect(useVueElementTracking).toHaveBeenCalledWith(
expect.any(Function),
'node'
)
const idArg = vi.mocked(useVueElementTracking).mock.calls[0]?.[0]
const id = toValue(idArg)
expect(id).toEqual('test-node-123')
}) })
it('should render with data-node-id attribute', () => { it('should render with data-node-id attribute', () => {
@@ -179,12 +194,16 @@ describe('LGraphNode', () => {
}) })
it('should emit node-click event on pointer up', async () => { it('should emit node-click event on pointer up', async () => {
const { handleNodeSelect } = useNodeEventHandlers()
const wrapper = mountLGraphNode({ nodeData: mockNodeData }) const wrapper = mountLGraphNode({ nodeData: mockNodeData })
await wrapper.trigger('pointerup') await wrapper.trigger('pointerup')
expect(wrapper.emitted('node-click')).toHaveLength(1) expect(handleNodeSelect).toHaveBeenCalledOnce()
expect(wrapper.emitted('node-click')?.[0]).toHaveLength(3) expect(handleNodeSelect).toHaveBeenCalledWith(
expect(wrapper.emitted('node-click')?.[0][1]).toEqual(mockNodeData) expect.any(PointerEvent),
mockNodeData,
expect.any(Boolean)
)
}) })
}) })