Files
ComfyUI_frontend/src/composables/useContextMenuTranslation.ts
Johnpaul Chiwetelu c414635ead [feat] Add context menu converter infrastructure (#7113)
## Summary
- Add `contextMenuConverter.ts` with utilities for converting LiteGraph
context menu items to Vue menu format
- Improve `contextMenuCompat.ts` with set-based diffing for more
reliable legacy extension detection
- Extend `MenuOption`/`SubMenuOption` types with `source`, `disabled`,
`isColorPicker`, and `category` type fields
- Add unit tests for converter functions

## Context
This is foundational work for migrating the node context menu from a
custom Popover-based component to PrimeVue ContextMenu.

The converter provides:
- Menu ordering and section grouping (core items first, then extensions)
- Deduplication with preference for Vue-native items over LiteGraph
items
- Extension categorization with labeled section
- Support for disabled states and color picker submenus

## Test plan
- [x] Unit tests pass for `buildStructuredMenu` (9 tests)
- [x] Unit tests pass for `convertContextMenuToOptions` (7 tests)
- [x] Typecheck passes
- [x] Lint passes
- [x] Knip passes (no unused exports)

## Related
This is PR 1 of 2 for the node context menu migration. PR 2 will wire up
the UI component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7113-feat-Add-context-menu-converter-infrastructure-2be6d73d3650816ca6c9d2cf50f10159)
by [Unito](https://www.unito.io)
2025-12-14 21:01:12 -07:00

178 lines
5.2 KiB
TypeScript

import { st, te } from '@/i18n'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import type {
IContextMenuOptions,
IContextMenuValue,
INodeInputSlot,
IWidget
} from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { normalizeI18nKey } from '@/utils/formatUtil'
/**
* Add translation for litegraph context menu.
*/
export const useContextMenuTranslation = () => {
// Install compatibility layer BEFORE any extensions load
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
const { getCanvasMenuOptions } = LGraphCanvas.prototype
const getCanvasCenterMenuOptions = function (
this: LGraphCanvas,
...args: Parameters<typeof getCanvasMenuOptions>
) {
const res: (IContextMenuValue | null)[] = getCanvasMenuOptions.apply(
this,
args
)
// Add items from new extension API
const newApiItems = app.collectCanvasMenuItems(this)
for (const item of newApiItems) {
res.push(item)
}
// Add legacy monkey-patched items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
this,
...args
)
for (const item of legacyItems) {
res.push(item)
}
// Translate all items
for (const item of res) {
if (item?.content) {
item.content = st(`contextMenu.${item.content}`, item.content)
}
}
return res
}
LGraphCanvas.prototype.getCanvasMenuOptions = getCanvasCenterMenuOptions
legacyMenuCompat.registerWrapper(
'getCanvasMenuOptions',
getCanvasCenterMenuOptions,
getCanvasMenuOptions,
LGraphCanvas.prototype
)
// Install compatibility layer for getNodeMenuOptions
legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions')
// Wrap getNodeMenuOptions to add new API items
const nodeMenuFn = LGraphCanvas.prototype.getNodeMenuOptions
const getNodeMenuOptionsWithExtensions = function (
this: LGraphCanvas,
...args: Parameters<typeof nodeMenuFn>
) {
const res = nodeMenuFn.apply(this, args) as (IContextMenuValue | null)[]
// Add items from new extension API
const node = args[0]
const newApiItems = app.collectNodeMenuItems(node)
for (const item of newApiItems) {
res.push(item)
}
// Add legacy monkey-patched items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getNodeMenuOptions',
this,
...args
)
for (const item of legacyItems) {
res.push(item)
}
return res
}
LGraphCanvas.prototype.getNodeMenuOptions = getNodeMenuOptionsWithExtensions
legacyMenuCompat.registerWrapper(
'getNodeMenuOptions',
getNodeMenuOptionsWithExtensions,
nodeMenuFn,
LGraphCanvas.prototype
)
function translateMenus(
values: readonly (IContextMenuValue | string | null)[] | undefined,
options: IContextMenuOptions
) {
if (!values) return
const reInput = /Convert (.*) to input/
const reWidget = /Convert (.*) to widget/
const cvt = st('contextMenu.Convert ', 'Convert ')
const tinp = st('contextMenu. to input', ' to input')
const twgt = st('contextMenu. to widget', ' to widget')
for (const value of values) {
if (typeof value === 'string') continue
translateMenus(value?.submenu?.options, options)
if (!value?.content) {
continue
}
if (te(`contextMenu.${value.content}`)) {
value.content = st(`contextMenu.${value.content}`, value.content)
}
// for capture translation text of input and widget
const extraInfo: any = options.extra || options.parentMenu?.options?.extra
// widgets and inputs
const matchInput = value.content?.match(reInput)
if (matchInput) {
let match = matchInput[1]
extraInfo?.inputs?.find((i: INodeInputSlot) => {
if (i.name != match) return false
match = i.label ? i.label : i.name
})
extraInfo?.widgets?.find((i: IWidget) => {
if (i.name != match) return false
match = i.label ? i.label : i.name
})
value.content = cvt + match + tinp
continue
}
const matchWidget = value.content?.match(reWidget)
if (matchWidget) {
let match = matchWidget[1]
extraInfo?.inputs?.find((i: INodeInputSlot) => {
if (i.name != match) return false
match = i.label ? i.label : i.name
})
extraInfo?.widgets?.find((i: IWidget) => {
if (i.name != match) return false
match = i.label ? i.label : i.name
})
value.content = cvt + match + twgt
continue
}
}
}
const OriginalContextMenu = LiteGraph.ContextMenu
function ContextMenu(
values: (IContextMenuValue | string)[],
options: IContextMenuOptions
) {
if (options.title) {
options.title = st(
`nodeDefs.${normalizeI18nKey(options.title)}.display_name`,
options.title
)
}
translateMenus(values, options)
const ctx = new OriginalContextMenu(values, options)
return ctx
}
LiteGraph.ContextMenu = ContextMenu as unknown as typeof LiteGraph.ContextMenu
LiteGraph.ContextMenu.prototype = OriginalContextMenu.prototype
}