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/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'always'
}
],
'unused-imports/no-unused-imports': 'error',
'vue/no-v-html': 'off',
// Enforce dark-theme: instead of dark: prefix

View File

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

View File

@@ -11,7 +11,7 @@
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
:pt="{
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"
>

View File

@@ -48,7 +48,6 @@ export interface LinkReleaseContextExtended {
links: ConnectingLink[]
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface LiteGraphCanvasEvent extends CustomEvent<CanvasEventDetail> {}
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 { Point } from '@/renderer/core/layout/types'
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
/**
* 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 screen = state.canvasToScreen({ x: 100, y: 50 })
*/
interface TransformState {
/** Convert a screen-space point (CSS pixels) to canvas space. */
screenToCanvas: (p: Point) => Point
/** Convert a canvas-space point to screen space (CSS pixels). */
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
}
interface TransformState
extends Pick<
ReturnType<typeof useTransformState>,
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
> {}
export const TransformStateKey: InjectionKey<TransformState> =
Symbol('transformState')

View File

@@ -54,7 +54,7 @@
:lod-level="lodLevel"
:collapsed="isCollapsed"
@collapse="handleCollapse"
@update:title="handleTitleUpdate"
@update:title="handleHeaderTitleUpdate"
@enter-subgraph="handleEnterSubgraph"
/>
</div>
@@ -101,7 +101,6 @@
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
@slot-click="handleSlotClick"
/>
<!-- Widgets rendered at reduced+ detail -->
@@ -140,15 +139,7 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import {
computed,
inject,
onErrorCaptured,
onMounted,
ref,
toRef,
watch
} from 'vue'
import { computed, inject, onErrorCaptured, onMounted, provide, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -156,13 +147,13 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
@@ -197,23 +188,10 @@ const {
zoomLevel = 1
} = defineProps<LGraphNodeProps>()
const emit = defineEmits<{
'node-click': [
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]
}>()
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeSelect } =
useNodeEventHandlers()
useVueElementTracking(nodeData.id, 'node')
useVueElementTracking(() => nodeData.id, 'node')
const { selectedNodeIds } = storeToRefs(useCanvasStore())
@@ -226,7 +204,7 @@ const isSelected = computed(() => {
})
// Use execution state composable
const { executing, progress } = useNodeExecutionState(nodeData.id)
const { executing, progress } = useNodeExecutionState(() => nodeData.id)
// Direct access to execution store for error state
const executionStore = useExecutionStore()
@@ -245,14 +223,13 @@ const bypassed = computed((): boolean => nodeData.mode === 4)
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
// LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => zoomLevel)
const {
lodLevel,
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
lodCssClass
} = useLOD(zoomRef)
} = useLOD(() => zoomLevel)
// Computed properties for template usage
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
@@ -268,16 +245,18 @@ onErrorCaptured((error) => {
})
// 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 {
handlePointerDown,
handlePointerUp,
handlePointerMove,
isDragging,
dragStyle
} = useNodePointerInteractions(nodeData, (event, nodeData, wasDragging) => {
emit('node-click', event, nodeData, wasDragging)
})
} = useNodePointerInteractions(() => nodeData, handleNodeSelect)
onMounted(() => {
if (size && transformState?.camera) {
@@ -291,17 +270,7 @@ onMounted(() => {
})
// Track collapsed state
const isCollapsed = ref(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
}
}
)
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
// Check if node has custom content (like image outputs)
const hasCustomContent = computed(() => {
@@ -310,12 +279,13 @@ const hasCustomContent = computed(() => {
})
// 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'
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(
nodeData.id,
() => nodeData.id,
{
isMinimalLOD,
isCollapsed
@@ -356,31 +326,11 @@ const outlineClass = computed(() => {
// Event handlers
const handleCollapse = () => {
isCollapsed.value = !isCollapsed.value
// Emit event so parent can sync with LiteGraph if needed
emit('update:collapsed', nodeData.id, isCollapsed.value)
handleNodeCollapse(nodeData.id, !isCollapsed.value)
}
const handleSlotClick = (
event: PointerEvent,
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 handleHeaderTitleUpdate = (newTitle: string) => {
handleNodeTitleUpdate(nodeData.id, newTitle)
}
const handleEnterSubgraph = () => {
@@ -410,15 +360,17 @@ const handleEnterSubgraph = () => {
const nodeOutputs = useNodeOutputStore()
const nodeImageUrls = ref<string[]>([])
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
const nodeOutputLocatorId = computed(() =>
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
)
const nodeImageUrls = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const locatorId = getLocatorIdFromNodeData(nodeData)
// Use root graph for getNodeByLocatorId since it needs to traverse from root
const rootGraph = app.graph?.rootGraph || app.graph
if (!rootGraph) {
nodeImageUrls.value = []
return
return []
}
const node = getNodeByLocatorId(rootGraph, locatorId)
@@ -426,23 +378,13 @@ const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
if (node && newOutputs?.images?.length) {
const urls = nodeOutputs.getNodeImageUrls(node)
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(() =>
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
)
watch(
() => nodeOutputs.nodeOutputs[nodeOutputLocatorId.value],
(newOutputs) => {
onNodeOutputsUpdate(newOutputs)
},
{ deep: true }
)
const nodeContainerRef = ref()
provide('tooltipContainer', nodeContainerRef)
</script>

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,13 @@
* Supports different element types (nodes, slots, widgets, etc.) with
* 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 { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -154,9 +160,10 @@ const resizeObserver = new ResizeObserver((entries) => {
* ```
*/
export function useVueElementTracking(
appIdentifier: string,
appIdentifierMaybe: MaybeRefOrGetter<string>,
trackingType: string
) {
const appIdentifier = toValue(appIdentifierMaybe)
onMounted(() => {
const element = getCurrentInstance()?.proxy?.$el
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 {
ExecutingNodeIdsKey,
NodeProgressStatesKey
} from '@/renderer/core/canvas/injectionKeys'
import type { NodeProgressState } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
/**
* 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
* 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
*/
export const useNodeExecutionState = (nodeId: string) => {
const executingNodeIds = inject(ExecutingNodeIdsKey, ref(new Set<string>()))
const nodeProgressStates = inject(
NodeProgressStatesKey,
ref<Record<string, NodeProgressState>>({})
)
export const useNodeExecutionState = (
nodeIdMaybe: MaybeRefOrGetter<string>
) => {
const nodeId = toValue(nodeIdMaybe)
const { uniqueExecutingNodeIdStrings, nodeProgressStates } =
storeToRefs(useExecutionStore())
const executing = computed(() => {
return executingNodeIds.value.has(nodeId)
return uniqueExecutingNodeIdStrings.value.has(nodeId)
})
const progress = computed(() => {

View File

@@ -5,7 +5,7 @@ import { storeToRefs } from 'pinia'
* Uses customRef for shared write access with Canvas renderer.
* 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 { 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
* 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 { selectedNodeIds } = storeToRefs(useCanvasStore())

View File

@@ -27,7 +27,7 @@
* <NodeSlots v-if="shouldRenderSlots" />
* ```
*/
import { type Ref, computed, readonly } from 'vue'
import { type MaybeRefOrGetter, computed, readonly, toRef } from 'vue'
export enum LODLevel {
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)
* @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
const lodScore = computed(() => {
const zoom = zoomRef.value

View File

@@ -1,16 +1,17 @@
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 { useNodeOutputStore } from '@/stores/imagePreviewStore'
export const useNodePreviewState = (
nodeId: string,
nodeIdMaybe: MaybeRefOrGetter<string>,
options?: {
isMinimalLOD?: Ref<boolean>
isCollapsed?: Ref<boolean>
}
) => {
const nodeId = toValue(nodeIdMaybe)
const workflowStore = useWorkflowStore()
const { nodePreviewImages } = storeToRefs(useNodeOutputStore())

View File

@@ -43,6 +43,57 @@ interface QueuedPrompt {
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', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
@@ -55,29 +106,6 @@ export const useExecutionStore = defineStore('execution', () => {
// This is the progress of all nodes in the currently executing workflow
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 = (
currentState: NodeProgressState | undefined,
newState: NodeProgressState
@@ -139,9 +167,13 @@ export const useExecutionStore = defineStore('execution', () => {
// @deprecated For backward compatibility - stores the primary executing node ID
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
const executingNode = computed<ComfyNode | 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)
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
const executingNodeProgress = computed(() =>
@@ -423,66 +425,25 @@ export const useExecutionStore = defineStore('execution', () => {
return {
isIdle,
clientId,
/**
* The id of the prompt that is currently being executed
*/
activePromptId,
/**
* The queued prompts
*/
queuedPrompts,
/**
* The node errors from the previous execution.
*/
lastNodeErrors,
/**
* The error from the previous execution.
*/
lastExecutionError,
/**
* Local node ID for the most recent execution error.
*/
lastExecutionErrorNodeId,
/**
* The id of the node that is currently being executed (backward compatibility)
*/
executingNodeId,
/**
* The list of all nodes that are currently executing
*/
executingNodeIds,
/**
* The prompt that is currently being executed
*/
activePrompt,
/**
* The total number of nodes to execute
*/
totalNodesToExecute,
/**
* The number of nodes that have been executed
*/
nodesExecuted,
/**
* The progress of the execution
*/
executionProgress,
/**
* The node that is currently being executed (backward compatibility)
*/
executingNode,
/**
* The progress of the executing node (backward compatibility)
*/
executingNodeProgress,
/**
* All node progress states from progress_state events
*/
nodeProgressStates,
nodeLocationProgressStates,
bindExecutionEvents,
unbindExecutionEvents,
storePrompt,
uniqueExecutingNodeIdStrings,
// Raw executing progress data for backward compatibility in ComfyApp.
_executingNodeProgress,
// NodeLocatorId conversion helpers

View File

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

View File

@@ -1,12 +1,13 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
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 { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
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'
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(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
() => ({
@@ -130,7 +139,13 @@ describe('LGraphNode', () => {
it('should call resize tracking composable with node ID', () => {
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', () => {
@@ -179,12 +194,16 @@ describe('LGraphNode', () => {
})
it('should emit node-click event on pointer up', async () => {
const { handleNodeSelect } = useNodeEventHandlers()
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
await wrapper.trigger('pointerup')
expect(wrapper.emitted('node-click')).toHaveLength(1)
expect(wrapper.emitted('node-click')?.[0]).toHaveLength(3)
expect(wrapper.emitted('node-click')?.[0][1]).toEqual(mockNodeData)
expect(handleNodeSelect).toHaveBeenCalledOnce()
expect(handleNodeSelect).toHaveBeenCalledWith(
expect.any(PointerEvent),
mockNodeData,
expect.any(Boolean)
)
})
})