Skip to content

Commit 3e02535

Browse files
Myesteryarjansingh
authored andcommitted
Contextmenu Monkeypatch Standardization (#5977)
This pull request introduces a new extension API for context menu customization, allowing extensions to contribute items to both canvas and node right-click menus. It adds two collection methods to the `ComfyApp` class to aggregate these menu items from all registered extensions, and updates the extension interface accordingly. Comprehensive unit tests are included to verify the correct aggregation behavior and error handling. **Extension API for Context Menus:** * Added optional `getCanvasMenuItems` and `getNodeMenuItems` methods to the `ComfyExtension` interface, enabling extensions to provide context menu items for canvas and node right-click menus (`src/types/comfy.ts`). * Updated type imports to support the new API, including `IContextMenuValue`, `LGraphCanvas`, and `LGraphNode` (`src/types/comfy.ts`, `src/scripts/app.ts`). [[1]](diffhunk://#diff-c29886a1b0c982c6fff3545af0ca8ec269876c2cf3948f867d08c14032c04d66L1-R5) [[2]](diffhunk://#diff-bde0dce9fe2403685d27b0e94a938c3d72824d02d01d1fd6167a0dddc6e585ddR10) **Core Implementation:** * Implemented `collectCanvasMenuItems` and `collectNodeMenuItems` methods in the `ComfyApp` class to gather menu items from all extensions, with robust error handling and logging for extension failures (`src/scripts/app.ts`). **Testing:** * Added a comprehensive test suite for the new context menu extension API, covering aggregation logic, error handling, and integration scenarios (`tests-ui/tests/extensions/contextMenuExtension.test.ts`). This is PR 1 of the 3 PRs in the Contextmenu standardizations. -#5992 -#5993
1 parent 6f1520d commit 3e02535

File tree

3 files changed

+242
-2
lines changed

3 files changed

+242
-2
lines changed

src/scripts/app.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { shallowRef } from 'vue'
77
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
88
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
99
import { st, t } from '@/i18n'
10+
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
1011
import {
1112
LGraph,
1213
LGraphCanvas,
@@ -1772,6 +1773,28 @@ export class ComfyApp {
17721773
useExtensionService().registerExtension(extension)
17731774
}
17741775

1776+
/**
1777+
* Collects context menu items from all extensions for canvas menus
1778+
* @param canvas The canvas instance
1779+
* @returns Array of context menu items from all extensions
1780+
*/
1781+
collectCanvasMenuItems(canvas: LGraphCanvas): IContextMenuValue[] {
1782+
return useExtensionService()
1783+
.invokeExtensions('getCanvasMenuItems', canvas)
1784+
.flat() as IContextMenuValue[]
1785+
}
1786+
1787+
/**
1788+
* Collects context menu items from all extensions for node menus
1789+
* @param node The node being right-clicked
1790+
* @returns Array of context menu items from all extensions
1791+
*/
1792+
collectNodeMenuItems(node: LGraphNode): IContextMenuValue[] {
1793+
return useExtensionService()
1794+
.invokeExtensions('getNodeMenuItems', node)
1795+
.flat() as IContextMenuValue[]
1796+
}
1797+
17751798
/**
17761799
* Refresh combo list on whole nodes
17771800
*/

src/types/comfy.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import type { Positionable } from '@/lib/litegraph/src/interfaces'
2-
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
1+
import type {
2+
IContextMenuValue,
3+
Positionable
4+
} from '@/lib/litegraph/src/interfaces'
5+
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
36
import type { SettingParams } from '@/platform/settings/types'
47
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
58
import type { Keybinding } from '@/schemas/keyBindingSchema'
@@ -106,6 +109,20 @@ export interface ComfyExtension {
106109
*/
107110
getSelectionToolboxCommands?(selectedItem: Positionable): string[]
108111

112+
/**
113+
* Allows the extension to add context menu items to canvas right-click menus
114+
* @param canvas The canvas instance
115+
* @returns An array of context menu items to add
116+
*/
117+
getCanvasMenuItems?(canvas: LGraphCanvas): IContextMenuValue[]
118+
119+
/**
120+
* Allows the extension to add context menu items to node right-click menus
121+
* @param node The node being right-clicked
122+
* @returns An array of context menu items to add
123+
*/
124+
getNodeMenuItems?(node: LGraphNode): IContextMenuValue[]
125+
109126
/**
110127
* Allows the extension to add additional handling to the node before it is registered with **LGraph**
111128
* @param nodeType The node class (not an instance)
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { createTestingPinia } from '@pinia/testing'
2+
import { setActivePinia } from 'pinia'
3+
import { beforeEach, describe, expect, it } from 'vitest'
4+
5+
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
6+
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
7+
import { useExtensionService } from '@/services/extensionService'
8+
import { useExtensionStore } from '@/stores/extensionStore'
9+
import type { ComfyExtension } from '@/types/comfy'
10+
11+
describe('Context Menu Extension API', () => {
12+
let mockCanvas: LGraphCanvas
13+
let mockNode: LGraphNode
14+
let extensionStore: ReturnType<typeof useExtensionStore>
15+
let extensionService: ReturnType<typeof useExtensionService>
16+
17+
// Mock menu items
18+
const canvasMenuItem1: IContextMenuValue = {
19+
content: 'Canvas Item 1',
20+
callback: () => {}
21+
}
22+
const canvasMenuItem2: IContextMenuValue = {
23+
content: 'Canvas Item 2',
24+
callback: () => {}
25+
}
26+
const nodeMenuItem1: IContextMenuValue = {
27+
content: 'Node Item 1',
28+
callback: () => {}
29+
}
30+
const nodeMenuItem2: IContextMenuValue = {
31+
content: 'Node Item 2',
32+
callback: () => {}
33+
}
34+
35+
// Mock extensions
36+
const createCanvasMenuExtension = (
37+
name: string,
38+
items: IContextMenuValue[]
39+
): ComfyExtension => ({
40+
name,
41+
getCanvasMenuItems: () => items
42+
})
43+
44+
const createNodeMenuExtension = (
45+
name: string,
46+
items: IContextMenuValue[]
47+
): ComfyExtension => ({
48+
name,
49+
getNodeMenuItems: () => items
50+
})
51+
52+
beforeEach(() => {
53+
setActivePinia(createTestingPinia({ stubActions: false }))
54+
extensionStore = useExtensionStore()
55+
extensionService = useExtensionService()
56+
57+
mockCanvas = {
58+
graph_mouse: [100, 100],
59+
selectedItems: new Set()
60+
} as unknown as LGraphCanvas
61+
62+
mockNode = {
63+
id: 1,
64+
type: 'TestNode',
65+
pos: [0, 0]
66+
} as unknown as LGraphNode
67+
})
68+
69+
describe('collectCanvasMenuItems', () => {
70+
it('should call getCanvasMenuItems and collect into flat array', () => {
71+
const ext1 = createCanvasMenuExtension('Extension 1', [canvasMenuItem1])
72+
const ext2 = createCanvasMenuExtension('Extension 2', [
73+
canvasMenuItem2,
74+
{ content: 'Item 3', callback: () => {} }
75+
])
76+
77+
extensionStore.registerExtension(ext1)
78+
extensionStore.registerExtension(ext2)
79+
80+
const items = extensionService
81+
.invokeExtensions('getCanvasMenuItems', mockCanvas)
82+
.flat() as IContextMenuValue[]
83+
84+
expect(items).toHaveLength(3)
85+
expect(items[0]).toMatchObject({ content: 'Canvas Item 1' })
86+
expect(items[1]).toMatchObject({ content: 'Canvas Item 2' })
87+
expect(items[2]).toMatchObject({ content: 'Item 3' })
88+
})
89+
90+
it('should support submenus and separators', () => {
91+
const extension = createCanvasMenuExtension('Test Extension', [
92+
{
93+
content: 'Menu with Submenu',
94+
has_submenu: true,
95+
submenu: {
96+
options: [
97+
{ content: 'Submenu Item 1', callback: () => {} },
98+
{ content: 'Submenu Item 2', callback: () => {} }
99+
]
100+
}
101+
},
102+
null as unknown as IContextMenuValue,
103+
{ content: 'After Separator', callback: () => {} }
104+
])
105+
106+
extensionStore.registerExtension(extension)
107+
108+
const items = extensionService
109+
.invokeExtensions('getCanvasMenuItems', mockCanvas)
110+
.flat() as IContextMenuValue[]
111+
112+
expect(items).toHaveLength(3)
113+
expect(items[0].content).toBe('Menu with Submenu')
114+
expect(items[0].submenu?.options).toHaveLength(2)
115+
expect(items[1]).toBeNull()
116+
expect(items[2].content).toBe('After Separator')
117+
})
118+
119+
it('should skip extensions without getCanvasMenuItems', () => {
120+
const canvasExtension = createCanvasMenuExtension('Canvas Ext', [
121+
canvasMenuItem1
122+
])
123+
const extensionWithoutCanvasMenu: ComfyExtension = {
124+
name: 'No Canvas Menu'
125+
}
126+
127+
extensionStore.registerExtension(canvasExtension)
128+
extensionStore.registerExtension(extensionWithoutCanvasMenu)
129+
130+
const items = extensionService
131+
.invokeExtensions('getCanvasMenuItems', mockCanvas)
132+
.flat() as IContextMenuValue[]
133+
134+
expect(items).toHaveLength(1)
135+
expect(items[0].content).toBe('Canvas Item 1')
136+
})
137+
})
138+
139+
describe('collectNodeMenuItems', () => {
140+
it('should call getNodeMenuItems and collect into flat array', () => {
141+
const ext1 = createNodeMenuExtension('Extension 1', [nodeMenuItem1])
142+
const ext2 = createNodeMenuExtension('Extension 2', [
143+
nodeMenuItem2,
144+
{ content: 'Item 3', callback: () => {} }
145+
])
146+
147+
extensionStore.registerExtension(ext1)
148+
extensionStore.registerExtension(ext2)
149+
150+
const items = extensionService
151+
.invokeExtensions('getNodeMenuItems', mockNode)
152+
.flat() as IContextMenuValue[]
153+
154+
expect(items).toHaveLength(3)
155+
expect(items[0]).toMatchObject({ content: 'Node Item 1' })
156+
expect(items[1]).toMatchObject({ content: 'Node Item 2' })
157+
})
158+
159+
it('should support submenus', () => {
160+
const extension = createNodeMenuExtension('Submenu Extension', [
161+
{
162+
content: 'Node Menu with Submenu',
163+
has_submenu: true,
164+
submenu: {
165+
options: [
166+
{ content: 'Node Submenu 1', callback: () => {} },
167+
{ content: 'Node Submenu 2', callback: () => {} }
168+
]
169+
}
170+
}
171+
])
172+
173+
extensionStore.registerExtension(extension)
174+
175+
const items = extensionService
176+
.invokeExtensions('getNodeMenuItems', mockNode)
177+
.flat() as IContextMenuValue[]
178+
179+
expect(items[0].content).toBe('Node Menu with Submenu')
180+
expect(items[0].submenu?.options).toHaveLength(2)
181+
})
182+
183+
it('should skip extensions without getNodeMenuItems', () => {
184+
const nodeExtension = createNodeMenuExtension('Node Ext', [nodeMenuItem1])
185+
const extensionWithoutNodeMenu: ComfyExtension = {
186+
name: 'No Node Menu'
187+
}
188+
189+
extensionStore.registerExtension(nodeExtension)
190+
extensionStore.registerExtension(extensionWithoutNodeMenu)
191+
192+
const items = extensionService
193+
.invokeExtensions('getNodeMenuItems', mockNode)
194+
.flat() as IContextMenuValue[]
195+
196+
expect(items).toHaveLength(1)
197+
expect(items[0].content).toBe('Node Item 1')
198+
})
199+
})
200+
})

0 commit comments

Comments
 (0)