mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-27 11:29:53 +00:00
This pull request refactors how context menu items are contributed by extensions in the LiteGraph-based canvas. The legacy monkey-patching approach for adding context menu options is replaced by a new, explicit API (`getCanvasMenuItems` and `getNodeMenuItems`) for extensions. A compatibility layer is added to support legacy extensions and warn developers about deprecated usage. The changes improve maintainability, extension interoperability, and migration to the new context menu system. ### Context Menu System Refactor * Introduced a new API for extensions to contribute context menu items via `getCanvasMenuItems` and `getNodeMenuItems` methods, replacing legacy monkey-patching of `LGraphCanvas.prototype.getCanvasMenuOptions`. Major extension files (`groupNode.ts`, `groupOptions.ts`, `nodeTemplates.ts`) now use this new API. [[1]](diffhunk://#diff-b29f141b89433027e7bb7cde57fad84f9e97ffbe5c58040d3e0fdb7905022917L1779-R1771) [[2]](diffhunk://#diff-91169f3a27ff8974d5c8fc3346bd99c07bdfb5399984484630125fdd647ff02fL232-R239) [[3]](diffhunk://#diff-04c18583d2dbfc013888e4c02fd432c250acbcecdef82bf7f6d9fd888e632a6eL447-R458) * Added a compatibility layer (`legacyMenuCompat` in `contextMenuCompat.ts`) to detect and warn when legacy monkey-patching is used, and to extract legacy-added menu items for backward compatibility. [[1]](diffhunk://#diff-2b724cb107c04e290369fb927e2ae9fad03be9e617a7d4de2487deab89d0d018R2-R45) [[2]](diffhunk://#diff-d3a8284ec16ae3f9512e33abe44ae653ed1aa45c9926485ef6270cc8d2b94ae6R1-R115) ### Extension Migration * Refactored core extensions (`groupNode`, `groupOptions`, and `nodeTemplates`) to implement the new context menu API, moving menu item logic out of monkey-patched methods and into explicit extension methods. [[1]](diffhunk://#diff-b29f141b89433027e7bb7cde57fad84f9e97ffbe5c58040d3e0fdb7905022917L1633-L1683) [[2]](diffhunk://#diff-91169f3a27ff8974d5c8fc3346bd99c07bdfb5399984484630125fdd647ff02fL19-R77) [[3]](diffhunk://#diff-04c18583d2dbfc013888e4c02fd432c250acbcecdef82bf7f6d9fd888e632a6eL366-R373) ### Type and Import Cleanup * Updated imports for context menu types (`IContextMenuValue`) across affected files for consistency with the new API. [[1]](diffhunk://#diff-b29f141b89433027e7bb7cde57fad84f9e97ffbe5c58040d3e0fdb7905022917R4-L7) [[2]](diffhunk://#diff-91169f3a27ff8974d5c8fc3346bd99c07bdfb5399984484630125fdd647ff02fL1-R11) [[3]](diffhunk://#diff-04c18583d2dbfc013888e4c02fd432c250acbcecdef82bf7f6d9fd888e632a6eL2-R6) [[4]](diffhunk://#diff-bde0dce9fe2403685d27b0e94a938c3d72824d02d01d1fd6167a0dddc6e585ddR10) ### Backward Compatibility and Migration Guidance * The compatibility layer logs a deprecation warning to the console when legacy monkey-patching is detected, helping developers migrate to the new API. --- These changes collectively modernize the context menu extension mechanism, improve code clarity, and provide a migration path for legacy extensions. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5993-Contextmenu-extension-migration-2876d73d3650813fae07c1141679637a) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
232 lines
7.3 KiB
TypeScript
232 lines
7.3 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
|
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import { useExtensionService } from '@/services/extensionService'
|
|
import { useExtensionStore } from '@/stores/extensionStore'
|
|
import type { ComfyExtension } from '@/types/comfy'
|
|
|
|
describe('Context Menu Extension API', () => {
|
|
let mockCanvas: LGraphCanvas
|
|
let mockNode: LGraphNode
|
|
let extensionStore: ReturnType<typeof useExtensionStore>
|
|
let extensionService: ReturnType<typeof useExtensionService>
|
|
|
|
// Mock menu items
|
|
const canvasMenuItem1: IContextMenuValue = {
|
|
content: 'Canvas Item 1',
|
|
callback: () => {}
|
|
}
|
|
const canvasMenuItem2: IContextMenuValue = {
|
|
content: 'Canvas Item 2',
|
|
callback: () => {}
|
|
}
|
|
const nodeMenuItem1: IContextMenuValue = {
|
|
content: 'Node Item 1',
|
|
callback: () => {}
|
|
}
|
|
const nodeMenuItem2: IContextMenuValue = {
|
|
content: 'Node Item 2',
|
|
callback: () => {}
|
|
}
|
|
|
|
// Mock extensions
|
|
const createCanvasMenuExtension = (
|
|
name: string,
|
|
items: IContextMenuValue[]
|
|
): ComfyExtension => ({
|
|
name,
|
|
getCanvasMenuItems: () => items
|
|
})
|
|
|
|
const createNodeMenuExtension = (
|
|
name: string,
|
|
items: IContextMenuValue[]
|
|
): ComfyExtension => ({
|
|
name,
|
|
getNodeMenuItems: () => items
|
|
})
|
|
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
extensionStore = useExtensionStore()
|
|
extensionService = useExtensionService()
|
|
|
|
mockCanvas = {
|
|
graph_mouse: [100, 100],
|
|
selectedItems: new Set()
|
|
} as unknown as LGraphCanvas
|
|
|
|
mockNode = {
|
|
id: 1,
|
|
type: 'TestNode',
|
|
pos: [0, 0]
|
|
} as unknown as LGraphNode
|
|
})
|
|
|
|
describe('collectCanvasMenuItems', () => {
|
|
it('should call getCanvasMenuItems and collect into flat array', () => {
|
|
const ext1 = createCanvasMenuExtension('Extension 1', [canvasMenuItem1])
|
|
const ext2 = createCanvasMenuExtension('Extension 2', [
|
|
canvasMenuItem2,
|
|
{ content: 'Item 3', callback: () => {} }
|
|
])
|
|
|
|
extensionStore.registerExtension(ext1)
|
|
extensionStore.registerExtension(ext2)
|
|
|
|
const items: IContextMenuValue[] = extensionService
|
|
.invokeExtensions('getCanvasMenuItems', mockCanvas)
|
|
.flat()
|
|
|
|
expect(items).toHaveLength(3)
|
|
expect(items[0]).toMatchObject({ content: 'Canvas Item 1' })
|
|
expect(items[1]).toMatchObject({ content: 'Canvas Item 2' })
|
|
expect(items[2]).toMatchObject({ content: 'Item 3' })
|
|
})
|
|
|
|
it('should support submenus and separators', () => {
|
|
const extension = createCanvasMenuExtension('Test Extension', [
|
|
{
|
|
content: 'Menu with Submenu',
|
|
has_submenu: true,
|
|
submenu: {
|
|
options: [
|
|
{ content: 'Submenu Item 1', callback: () => {} },
|
|
{ content: 'Submenu Item 2', callback: () => {} }
|
|
]
|
|
}
|
|
},
|
|
null as unknown as IContextMenuValue,
|
|
{ content: 'After Separator', callback: () => {} }
|
|
])
|
|
|
|
extensionStore.registerExtension(extension)
|
|
|
|
const items: IContextMenuValue[] = extensionService
|
|
.invokeExtensions('getCanvasMenuItems', mockCanvas)
|
|
.flat()
|
|
|
|
expect(items).toHaveLength(3)
|
|
expect(items[0].content).toBe('Menu with Submenu')
|
|
expect(items[0].submenu?.options).toHaveLength(2)
|
|
expect(items[1]).toBeNull()
|
|
expect(items[2].content).toBe('After Separator')
|
|
})
|
|
|
|
it('should skip extensions without getCanvasMenuItems', () => {
|
|
const canvasExtension = createCanvasMenuExtension('Canvas Ext', [
|
|
canvasMenuItem1
|
|
])
|
|
const extensionWithoutCanvasMenu: ComfyExtension = {
|
|
name: 'No Canvas Menu'
|
|
}
|
|
|
|
extensionStore.registerExtension(canvasExtension)
|
|
extensionStore.registerExtension(extensionWithoutCanvasMenu)
|
|
|
|
const items: IContextMenuValue[] = extensionService
|
|
.invokeExtensions('getCanvasMenuItems', mockCanvas)
|
|
.flat()
|
|
|
|
expect(items).toHaveLength(1)
|
|
expect(items[0].content).toBe('Canvas Item 1')
|
|
})
|
|
|
|
it('should not duplicate menu items when collected multiple times', () => {
|
|
const extension = createCanvasMenuExtension('Test Extension', [
|
|
canvasMenuItem1,
|
|
canvasMenuItem2
|
|
])
|
|
|
|
extensionStore.registerExtension(extension)
|
|
|
|
// Collect items multiple times (simulating repeated menu opens)
|
|
const items1: IContextMenuValue[] = extensionService
|
|
.invokeExtensions('getCanvasMenuItems', mockCanvas)
|
|
.flat()
|
|
|
|
const items2: IContextMenuValue[] = extensionService
|
|
.invokeExtensions('getCanvasMenuItems', mockCanvas)
|
|
.flat()
|
|
|
|
// Both collections should have the same items (no duplication)
|
|
expect(items1).toHaveLength(2)
|
|
expect(items2).toHaveLength(2)
|
|
|
|
// Verify items are unique by checking their content
|
|
const contents1 = items1.map((item) => item.content)
|
|
const uniqueContents1 = new Set(contents1)
|
|
expect(uniqueContents1.size).toBe(contents1.length)
|
|
|
|
const contents2 = items2.map((item) => item.content)
|
|
const uniqueContents2 = new Set(contents2)
|
|
expect(uniqueContents2.size).toBe(contents2.length)
|
|
})
|
|
})
|
|
|
|
describe('collectNodeMenuItems', () => {
|
|
it('should call getNodeMenuItems and collect into flat array', () => {
|
|
const ext1 = createNodeMenuExtension('Extension 1', [nodeMenuItem1])
|
|
const ext2 = createNodeMenuExtension('Extension 2', [
|
|
nodeMenuItem2,
|
|
{ content: 'Item 3', callback: () => {} }
|
|
])
|
|
|
|
extensionStore.registerExtension(ext1)
|
|
extensionStore.registerExtension(ext2)
|
|
|
|
const items: IContextMenuValue[] = extensionService
|
|
.invokeExtensions('getNodeMenuItems', mockNode)
|
|
.flat()
|
|
|
|
expect(items).toHaveLength(3)
|
|
expect(items[0]).toMatchObject({ content: 'Node Item 1' })
|
|
expect(items[1]).toMatchObject({ content: 'Node Item 2' })
|
|
})
|
|
|
|
it('should support submenus', () => {
|
|
const extension = createNodeMenuExtension('Submenu Extension', [
|
|
{
|
|
content: 'Node Menu with Submenu',
|
|
has_submenu: true,
|
|
submenu: {
|
|
options: [
|
|
{ content: 'Node Submenu 1', callback: () => {} },
|
|
{ content: 'Node Submenu 2', callback: () => {} }
|
|
]
|
|
}
|
|
}
|
|
])
|
|
|
|
extensionStore.registerExtension(extension)
|
|
|
|
const items: IContextMenuValue[] = extensionService
|
|
.invokeExtensions('getNodeMenuItems', mockNode)
|
|
.flat()
|
|
|
|
expect(items[0].content).toBe('Node Menu with Submenu')
|
|
expect(items[0].submenu?.options).toHaveLength(2)
|
|
})
|
|
|
|
it('should skip extensions without getNodeMenuItems', () => {
|
|
const nodeExtension = createNodeMenuExtension('Node Ext', [nodeMenuItem1])
|
|
const extensionWithoutNodeMenu: ComfyExtension = {
|
|
name: 'No Node Menu'
|
|
}
|
|
|
|
extensionStore.registerExtension(nodeExtension)
|
|
extensionStore.registerExtension(extensionWithoutNodeMenu)
|
|
|
|
const items: IContextMenuValue[] = extensionService
|
|
.invokeExtensions('getNodeMenuItems', mockNode)
|
|
.flat()
|
|
|
|
expect(items).toHaveLength(1)
|
|
expect(items[0].content).toBe('Node Item 1')
|
|
})
|
|
})
|
|
})
|