Compare commits

...

1 Commits

Author SHA1 Message Date
Terry Jia
56da748234 feat: add node event subscription API (on/off/emit) 2026-04-13 20:49:49 -04:00
17 changed files with 754 additions and 27 deletions

View File

@@ -58,7 +58,9 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js'
'.agents/checks/eslint.strict.config.js',
// Public API shim for custom nodes, consumed by comfyAPIPlugin at build time
'src/scripts/nodeEvents.ts'
],
vite: {
config: ['vite?(.*).config.mts']

View File

@@ -1,4 +1,5 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -18,12 +19,9 @@ useExtensionService().registerExtension({
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 350)])
const onExecuted = node.onExecuted
node.onExecuted = function (output: ImageCompareOutput) {
onExecuted?.call(this, output)
const { a_images: aImages, b_images: bImages } = output
node.on(NodeEvent.EXECUTED, ({ output }) => {
const { a_images: aImages, b_images: bImages } =
output as ImageCompareOutput
const rand = app.getRandParam()
const toUrl = (record: Record<string, string>) => {
@@ -42,6 +40,6 @@ useExtensionService().registerExtension({
widget.value = { beforeImages, afterImages }
widget.callback?.(widget.value)
}
}
})
}
})

View File

@@ -13,6 +13,7 @@ import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -475,8 +476,6 @@ useExtensionService().registerExtension({
await nextTick()
const onExecuted = node.onExecuted
useLoad3d(node).waitForLoad3d((load3d) => {
const config = new Load3DConfiguration(load3d, node.properties)
@@ -502,10 +501,8 @@ useExtensionService().registerExtension({
config.configure(settings)
}
node.onExecuted = function (output: Load3dPreviewOutput) {
onExecuted?.call(this, output)
const result = output.result
node.on(NodeEvent.EXECUTED, ({ output }) => {
const result = (output as Load3dPreviewOutput).result
const filePath = result?.[0]
if (!filePath) {
@@ -533,7 +530,7 @@ useExtensionService().registerExtension({
if (bgImagePath) {
load3d.setBackgroundImage(bgImagePath)
}
}
})
}
})
}

View File

@@ -5,6 +5,7 @@ import { useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
@@ -81,12 +82,8 @@ useExtensionService().registerExtension({
await nextTick()
const onExecuted = node.onExecuted
node.onExecuted = function (output: SaveMeshOutput) {
onExecuted?.call(this, output)
const fileInfo = output['3d']?.[0]
node.on(NodeEvent.EXECUTED, ({ output }) => {
const fileInfo = (output as SaveMeshOutput)['3d']?.[0]
if (!fileInfo) return
@@ -119,6 +116,6 @@ useExtensionService().registerExtension({
}
}
})
}
})
}
})

View File

@@ -1,5 +1,5 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph, NodeEvent } from '@/lib/litegraph/src/litegraph'
import type {
INodeInputSlot,
INodeOutputSlot,
@@ -315,6 +315,12 @@ export class PrimitiveNode extends LGraphNode {
{} as CanvasPointerEvent
)
this.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
this.emit(NodeEvent.WIDGET_CHANGED, {
name: widget.name,
value: newValue,
oldValue,
widget
})
}
})
}

View File

@@ -29,6 +29,7 @@ import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import type { NodeId } from './LGraphNode'
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
import { LLink } from './LLink'
import type { LinkId, SerialisedLLinkArray } from './LLink'
import { MapProxyHandler } from './MapProxyHandler'
@@ -394,6 +395,8 @@ export class LGraph
if (this._nodes) {
for (const _node of this._nodes) {
_node.onRemoved?.()
_node.emit?.(NodeEvent.REMOVED)
_node._removeAllListeners?.()
this.onNodeRemoved?.(_node)
}
}
@@ -1004,6 +1007,7 @@ export class LGraph
this._nodes_by_id[node.id] = node
node.onAdded?.(this)
node.emit?.(NodeEvent.ADDED, { graph: this })
if (this.config.align_to_grid) node.alignToGrid()
@@ -1100,6 +1104,8 @@ export class LGraph
if (!hasRemainingReferences) {
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
innerNode.emit?.(NodeEvent.REMOVED)
innerNode._removeAllListeners?.()
innerNode.graph?.onNodeRemoved?.(innerNode)
})
this.rootGraph.subgraphs.delete(node.subgraph.id)
@@ -1108,6 +1114,8 @@ export class LGraph
// callback
node.onRemoved?.()
node.emit?.(NodeEvent.REMOVED)
node._removeAllListeners?.()
node.graph = null
this._version++

View File

@@ -21,6 +21,7 @@ import type { LGraph } from './LGraph'
import { LGraphGroup } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import type { NodeId, NodeProperty } from './LGraphNode'
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
import { Reroute } from './Reroute'
@@ -3080,6 +3081,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// value changed
if (oldValue != widget.value) {
node.onWidgetChanged?.(widget.name, widget.value, oldValue, widget)
node.emit(NodeEvent.WIDGET_CHANGED, {
name: widget.name,
value: widget.value,
oldValue,
widget
})
if (!node.graph) throw new NullGraphError()
node.graph._version++
}

View File

@@ -87,6 +87,10 @@ import type {
TWidgetType,
TWidgetValue
} from './types/widgets'
import type { INodeEventEmitter } from './infrastructure/NodeEventEmitter'
import { applyNodeEventEmitter } from './infrastructure/NodeEventEmitter'
import type { LGraphNodeEventMap } from './infrastructure/LGraphNodeEventMap'
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { distributeSpace } from './utils/spaceDistribution'
@@ -229,9 +233,15 @@ export interface LGraphNode {
* @param type a type for the node
*/
export interface LGraphNode extends INodeEventEmitter<LGraphNodeEventMap> {}
export class LGraphNode
implements NodeLike, Positionable, IPinnable, IColorable
{
/** @internal Lazily-created instance event listener map. */
declare _eventListeners?: Map<string, Set<(detail: unknown) => void>>
/** @internal Class-level event listener map (static). */
static _classEventListeners?: Map<string, Set<(detail: unknown) => void>>
// Static properties used by dynamic child classes
static title?: string
static MAX_CONSOLE?: number
@@ -877,6 +887,13 @@ export class LGraphNode
? this.graph._links.get(input.link)
: null
this.onConnectionsChange?.(NodeSlotType.INPUT, i, true, link, input)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.INPUT,
index: i,
isConnected: true,
link,
inputOrOutput: input
})
this.onInputAdded?.(input)
}
@@ -890,6 +907,13 @@ export class LGraphNode
for (const linkId of output.links) {
const link = this.graph ? this.graph._links.get(linkId) : null
this.onConnectionsChange?.(NodeSlotType.OUTPUT, i, true, link, output)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.OUTPUT,
index: i,
isConnected: true,
link,
inputOrOutput: output
})
}
this.onOutputAdded?.(output)
}
@@ -935,6 +959,7 @@ export class LGraphNode
}
this.onConfigure?.(info)
this.emit(NodeEvent.CONFIGURED, { serialisedNode: info })
}
/**
@@ -1594,6 +1619,7 @@ export class LGraphNode
setSize(size: Size): void {
this.size = size
this.onResize?.(this.size)
this.emit(NodeEvent.RESIZE, { size: this.size })
}
/**
@@ -2996,6 +3022,13 @@ export class LGraphNode
link,
output
)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.OUTPUT,
index: outputIndex,
isConnected: true,
link,
inputOrOutput: output
})
inputNode.onConnectionsChange?.(
NodeSlotType.INPUT,
@@ -3004,6 +3037,13 @@ export class LGraphNode
link,
input
)
inputNode.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.INPUT,
index: inputIndex,
isConnected: true,
link,
inputOrOutput: input
})
this.setDirtyCanvas(false, true)
graph.afterChange()
@@ -3145,6 +3185,13 @@ export class LGraphNode
link_info,
input
)
target.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.INPUT,
index: link_info.target_slot,
isConnected: false,
link: link_info,
inputOrOutput: input
})
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
@@ -3152,6 +3199,13 @@ export class LGraphNode
link_info,
output
)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.OUTPUT,
index: slot,
isConnected: false,
link: link_info,
inputOrOutput: output
})
break
}
@@ -3197,6 +3251,13 @@ export class LGraphNode
link_info,
input
)
target.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.INPUT,
index: link_info.target_slot,
isConnected: false,
link: link_info,
inputOrOutput: input
})
}
// remove the link from the links pool
link_info.disconnect(graph, 'input')
@@ -3208,6 +3269,13 @@ export class LGraphNode
link_info,
output
)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.OUTPUT,
index: slot,
isConnected: false,
link: link_info,
inputOrOutput: output
})
}
output.links = null
}
@@ -3310,6 +3378,13 @@ export class LGraphNode
link_info,
input
)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.INPUT,
index: slot,
isConnected: false,
link: link_info,
inputOrOutput: input
})
target_node.onConnectionsChange?.(
NodeSlotType.OUTPUT,
i,
@@ -3317,6 +3392,13 @@ export class LGraphNode
link_info,
output
)
target_node.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.OUTPUT,
index: i,
isConnected: false,
link: link_info,
inputOrOutput: output
})
}
}
@@ -4283,3 +4365,5 @@ export class LGraphNode
ctx.fillStyle = originalFillStyle
}
}
applyNodeEventEmitter(LGraphNode)

View File

@@ -5,6 +5,7 @@ import { LGraph } from './LGraph'
import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
import { LLink } from './LLink'
import { Reroute } from './Reroute'
import { InputIndicators } from './canvas/InputIndicators'
@@ -571,6 +572,7 @@ export class LiteGraphGlobal {
// callback
node.onNodeCreated?.()
node.emit?.(NodeEvent.NODE_CREATED)
return node
}

View File

@@ -0,0 +1,43 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type {
INodeInputSlot,
INodeOutputSlot,
ISlotType,
Size
} from '@/lib/litegraph/src/interfaces'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export const NodeEvent = {
NODE_CREATED: 'node-created',
CONFIGURED: 'configured',
CONNECTIONS_CHANGE: 'connections-change',
WIDGET_CHANGED: 'widget-changed',
ADDED: 'added',
REMOVED: 'removed',
RESIZE: 'resize',
EXECUTED: 'executed'
} as const
export type LGraphNodeEventMap = {
[NodeEvent.NODE_CREATED]: never
[NodeEvent.CONFIGURED]: { serialisedNode: ISerialisedNode }
[NodeEvent.CONNECTIONS_CHANGE]: {
type: ISlotType
index: number
isConnected: boolean
link: LLink | null | undefined
inputOrOutput: INodeInputSlot | INodeOutputSlot
}
[NodeEvent.WIDGET_CHANGED]: {
name: string
value: unknown
oldValue: unknown
widget: IBaseWidget
}
[NodeEvent.ADDED]: { graph: LGraph }
[NodeEvent.REMOVED]: never
[NodeEvent.RESIZE]: { size: Size }
[NodeEvent.EXECUTED]: { output: Record<string, unknown> }
}

View File

@@ -0,0 +1,399 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import {
LGraph,
LGraphNode,
LiteGraph,
NodeEvent,
onAllNodeEvents,
offAllNodeEvents
} from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/litegraph'
function createGraph(): LGraph {
return new LGraph()
}
function createConnectedNodes(graph: LGraph) {
const source = new LGraphNode('Source')
source.addOutput('out', 'number')
const target = new LGraphNode('Target')
target.addInput('in', 'number')
graph.add(source)
graph.add(target)
return { source, target }
}
describe('NodeEventEmitter', () => {
let origLiteGraph: typeof LiteGraph
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
origLiteGraph = Object.assign({}, LiteGraph)
// @ts-expect-error Intended: Force remove an otherwise readonly non-optional property
delete origLiteGraph.Classes
Object.assign(LiteGraph, {
NODE_TITLE_HEIGHT: 20,
NODE_SLOT_HEIGHT: 15,
NODE_TEXT_SIZE: 14,
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0.5)',
DEFAULT_GROUP_FONT_SIZE: 24,
isValidConnection: vi.fn().mockReturnValue(true)
})
})
afterEach(() => {
Object.assign(LiteGraph, origLiteGraph)
})
describe('instance-level on/off/emit', () => {
test('fires listener when event is emitted', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.NODE_CREATED, listener)
node.emit(NodeEvent.NODE_CREATED)
expect(listener).toHaveBeenCalledOnce()
})
test('passes detail payload to listener', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.EXECUTED, listener)
node.emit(NodeEvent.EXECUTED, { output: { text: ['hello'] } })
expect(listener).toHaveBeenCalledWith({ output: { text: ['hello'] } })
})
test('supports multiple listeners on the same event', () => {
const node = new LGraphNode('Test')
const listener1 = vi.fn()
const listener2 = vi.fn()
node.on(NodeEvent.EXECUTED, listener1)
node.on(NodeEvent.EXECUTED, listener2)
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener1).toHaveBeenCalledOnce()
expect(listener2).toHaveBeenCalledOnce()
})
test('removes listener with off()', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.EXECUTED, listener)
node.off(NodeEvent.EXECUTED, listener)
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
test('on() returns an unsubscribe function', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
const unsub = node.on(NodeEvent.EXECUTED, listener)
unsub()
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
test('does not fire listeners for other event types', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.EXECUTED, listener)
node.emit(NodeEvent.NODE_CREATED)
expect(listener).not.toHaveBeenCalled()
})
test('isolates listeners between node instances', () => {
const node1 = new LGraphNode('A')
const node2 = new LGraphNode('B')
const listener = vi.fn()
node1.on(NodeEvent.EXECUTED, listener)
node2.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
})
describe('once()', () => {
test('fires listener only once', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.once(NodeEvent.EXECUTED, listener)
node.emit(NodeEvent.EXECUTED, { output: { a: 1 } })
node.emit(NodeEvent.EXECUTED, { output: { a: 2 } })
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({ output: { a: 1 } })
})
test('returns an unsubscribe function that cancels before firing', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
const unsub = node.once(NodeEvent.EXECUTED, listener)
unsub()
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
test('does not interfere with other listeners', () => {
const node = new LGraphNode('Test')
const onceListener = vi.fn()
const permanentListener = vi.fn()
node.once(NodeEvent.EXECUTED, onceListener)
node.on(NodeEvent.EXECUTED, permanentListener)
node.emit(NodeEvent.EXECUTED, { output: {} })
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(onceListener).toHaveBeenCalledOnce()
expect(permanentListener).toHaveBeenCalledTimes(2)
})
})
describe('class-level onAllNodeEvents', () => {
afterEach(() => {
LGraphNode._classEventListeners?.clear()
})
test('fires class-level listener on any instance emit', () => {
const listener = vi.fn()
onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
const node = new LGraphNode('Test')
node.emit(NodeEvent.EXECUTED, { output: { data: 1 } })
expect(listener).toHaveBeenCalledWith({ output: { data: 1 } })
})
test('fires class-level listener with node as this', () => {
let capturedThis: unknown
const listener = function (this: unknown) {
capturedThis = this
}
onAllNodeEvents(LGraphNode, NodeEvent.NODE_CREATED, listener)
const node = new LGraphNode('Test')
node.emit(NodeEvent.NODE_CREATED)
expect(capturedThis).toBe(node)
})
test('fires both instance and class listeners', () => {
const instanceListener = vi.fn()
const classListener = vi.fn()
onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, classListener)
const node = new LGraphNode('Test')
node.on(NodeEvent.EXECUTED, instanceListener)
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(instanceListener).toHaveBeenCalledOnce()
expect(classListener).toHaveBeenCalledOnce()
})
test('removes class-level listener with offAllNodeEvents', () => {
const listener = vi.fn()
onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
offAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
const node = new LGraphNode('Test')
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
test('onAllNodeEvents returns an unsubscribe function', () => {
const listener = vi.fn()
const unsub = onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
unsub()
const node = new LGraphNode('Test')
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
})
describe('error isolation', () => {
test('continues calling listeners if one throws', () => {
const node = new LGraphNode('Test')
const errorListener = vi.fn(() => {
throw new Error('boom')
})
const normalListener = vi.fn()
vi.spyOn(console, 'error').mockImplementation(() => {})
node.on(NodeEvent.EXECUTED, errorListener)
node.on(NodeEvent.EXECUTED, normalListener)
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(errorListener).toHaveBeenCalledOnce()
expect(normalListener).toHaveBeenCalledOnce()
expect(console.error).toHaveBeenCalledOnce()
})
})
describe('automatic cleanup on node removal', () => {
test('clears all listeners when node is removed from graph', () => {
const graph = createGraph()
const node = new LGraphNode('Test')
graph.add(node)
const listener = vi.fn()
node.on(NodeEvent.EXECUTED, listener)
graph.remove(node)
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
test('clears listeners on graph.clear()', () => {
const graph = createGraph()
const node = new LGraphNode('Test')
graph.add(node)
const listener = vi.fn()
node.on(NodeEvent.EXECUTED, listener)
graph.clear()
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
})
describe('lifecycle event integration', () => {
test('emits added when node is added to graph', () => {
const graph = createGraph()
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.ADDED, listener)
graph.add(node)
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({ graph })
})
test('emits removed when node is removed from graph', () => {
const graph = createGraph()
const node = new LGraphNode('Test')
graph.add(node)
const listener = vi.fn()
node.on(NodeEvent.REMOVED, listener)
graph.remove(node)
expect(listener).toHaveBeenCalledOnce()
})
test('emits configured after node.configure()', () => {
const node = new LGraphNode('Test')
const graph = createGraph()
graph.add(node)
const listener = vi.fn()
node.on(NodeEvent.CONFIGURED, listener)
const serialised: ISerialisedNode = {
id: node.id,
type: 'Test',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0
}
node.configure(serialised)
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({
serialisedNode: serialised
})
})
test('emits resize when setSize is called', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.RESIZE, listener)
node.setSize([200, 300])
expect(listener).toHaveBeenCalledOnce()
const detail = listener.mock.calls[0][0]
expect(detail.size[0]).toBe(200)
expect(detail.size[1]).toBe(300)
})
})
describe('connections-change event', () => {
test('emits on both nodes when connected', () => {
const graph = createGraph()
const { source, target } = createConnectedNodes(graph)
const sourceListener = vi.fn()
const targetListener = vi.fn()
source.on(NodeEvent.CONNECTIONS_CHANGE, sourceListener)
target.on(NodeEvent.CONNECTIONS_CHANGE, targetListener)
source.connect(0, target, 0)
expect(sourceListener).toHaveBeenCalledWith(
expect.objectContaining({
type: 2, // NodeSlotType.OUTPUT
index: 0,
isConnected: true
})
)
expect(targetListener).toHaveBeenCalledWith(
expect.objectContaining({
type: 1, // NodeSlotType.INPUT
index: 0,
isConnected: true
})
)
})
test('emits on both nodes when disconnected', () => {
const graph = createGraph()
const { source, target } = createConnectedNodes(graph)
source.connect(0, target, 0)
const sourceListener = vi.fn()
const targetListener = vi.fn()
source.on(NodeEvent.CONNECTIONS_CHANGE, sourceListener)
target.on(NodeEvent.CONNECTIONS_CHANGE, targetListener)
target.disconnectInput(0)
expect(sourceListener).toHaveBeenCalledWith(
expect.objectContaining({
isConnected: false
})
)
expect(targetListener).toHaveBeenCalledWith(
expect.objectContaining({
isConnected: false
})
)
})
})
})

View File

@@ -0,0 +1,143 @@
type Listener<T = unknown> = (detail: T) => void
type ListenerMap = Map<string, Set<Listener>>
export type Unsubscribe = () => void
interface NodeWithEvents {
_eventListeners?: ListenerMap
constructor: { _classEventListeners?: ListenerMap }
}
type EventMapConstraint = { [K: string]: unknown }
export interface INodeEventEmitter<EventMap extends EventMapConstraint> {
on<K extends keyof EventMap & string>(
type: K,
listener: Listener<EventMap[K]>
): Unsubscribe
once<K extends keyof EventMap & string>(
type: K,
listener: Listener<EventMap[K]>
): Unsubscribe
off<K extends keyof EventMap & string>(
type: K,
listener: Listener<EventMap[K]>
): void
emit<K extends keyof EventMap & string>(
type: K,
...args: EventMap[K] extends never ? [] : [detail: EventMap[K]]
): void
_removeAllListeners?(): void
}
function addListener(
node: NodeWithEvents,
type: string,
listener: Listener
): Unsubscribe {
if (!node._eventListeners) node._eventListeners = new Map()
let listeners = node._eventListeners.get(type)
if (!listeners) {
listeners = new Set()
node._eventListeners.set(type, listeners)
}
listeners.add(listener)
return () => node._eventListeners?.get(type)?.delete(listener)
}
function nodeOn(
this: NodeWithEvents,
type: string,
listener: Listener
): Unsubscribe {
return addListener(this, type, listener)
}
function nodeOnce(
this: NodeWithEvents,
type: string,
listener: Listener
): Unsubscribe {
const wrapper: Listener = (detail) => {
this._eventListeners?.get(type)?.delete(wrapper)
listener(detail)
}
return addListener(this, type, wrapper)
}
function nodeOff(this: NodeWithEvents, type: string, listener: Listener) {
this._eventListeners?.get(type)?.delete(listener)
}
function nodeEmit(this: NodeWithEvents, type: string, detail?: unknown) {
const listeners = this._eventListeners?.get(type)
if (listeners) {
for (const listener of listeners) {
try {
listener(detail)
} catch (e) {
console.error(
`[ComfyUI] Error in node event listener for "${type}":`,
e
)
}
}
}
const classListeners = this.constructor._classEventListeners?.get(type)
if (classListeners) {
for (const listener of classListeners) {
try {
listener.call(this, detail)
} catch (e) {
console.error(
`[ComfyUI] Error in class event listener for "${type}":`,
e
)
}
}
}
}
function nodeRemoveAllListeners(this: NodeWithEvents) {
this._eventListeners?.clear()
}
export function applyNodeEventEmitter(target: { prototype: object }): void {
Object.assign(target.prototype, {
on: nodeOn,
once: nodeOnce,
off: nodeOff,
emit: nodeEmit,
_removeAllListeners: nodeRemoveAllListeners
})
}
export function onAllNodeEvents(
nodeClass: { _classEventListeners?: ListenerMap },
type: string,
listener: Listener
): Unsubscribe {
if (!nodeClass._classEventListeners)
nodeClass._classEventListeners = new Map()
let listeners = nodeClass._classEventListeners.get(type)
if (!listeners) {
listeners = new Set()
nodeClass._classEventListeners.set(type, listeners)
}
listeners.add(listener)
return () => nodeClass._classEventListeners?.get(type)?.delete(listener)
}
export function offAllNodeEvents(
nodeClass: { _classEventListeners?: ListenerMap },
type: string,
listener: Listener
): void {
nodeClass._classEventListeners?.get(type)?.delete(listener)
}

View File

@@ -86,6 +86,13 @@ export { ContextMenu } from './ContextMenu'
export { DragAndScale } from './DragAndScale'
export { NodeEvent } from './infrastructure/LGraphNodeEventMap'
export type { LGraphNodeEventMap } from './infrastructure/LGraphNodeEventMap'
export {
onAllNodeEvents,
offAllNodeEvents
} from './infrastructure/NodeEventEmitter'
export type { Unsubscribe } from './infrastructure/NodeEventEmitter'
export { Rectangle } from './infrastructure/Rectangle'
export type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
export type {

View File

@@ -11,6 +11,7 @@ import type {
Size
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
@@ -436,6 +437,12 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this.callback?.(this.value, canvas, node, pos, e)
node.onWidgetChanged?.(this.name ?? '', v, oldValue, this)
node.emit(NodeEvent.WIDGET_CHANGED, {
name: this.name ?? '',
value: v,
oldValue,
widget: this
})
if (node.graph) node.graph._version++
}

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
import { isComboWidget, NodeEvent } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
@@ -137,6 +137,12 @@ function createAssetBrowserWidget(
defaultValue,
onValueChange: (widget, newValue, oldValue) => {
node.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
node.emit(NodeEvent.WIDGET_CHANGED, {
name: widget.name,
value: newValue,
oldValue,
widget
})
}
})
}

View File

@@ -16,7 +16,8 @@ import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
LiteGraph,
NodeEvent
} from '@/lib/litegraph/src/litegraph'
import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
@@ -738,8 +739,9 @@ export class ComfyApp {
})
const node = getNodeByExecutionId(this.rootGraph, executionId)
if (node && node.onExecuted) {
node.onExecuted(detail.output)
if (node) {
node.onExecuted?.(detail.output)
node.emit(NodeEvent.EXECUTED, { output: detail.output })
}
})

19
src/scripts/nodeEvents.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Public API for node event subscriptions.
*
* Exposed via window.comfyAPI.nodeEvents for custom node extensions.
*
* Usage:
* const { NodeEvent, onAllNodeEvents, offAllNodeEvents } = window.comfyAPI.nodeEvents
*
* // In beforeRegisterNodeDef:
* onAllNodeEvents(nodeType, NodeEvent.EXECUTED, function(detail) {
* // `this` is the node instance
* console.log('Node executed:', detail.output)
* })
*/
export { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
export {
onAllNodeEvents,
offAllNodeEvents
} from '@/lib/litegraph/src/infrastructure/NodeEventEmitter'