[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)
This commit is contained in:
Johnpaul Chiwetelu
2025-12-15 05:01:12 +01:00
committed by GitHub
parent e96593fe4c
commit c414635ead
7 changed files with 967 additions and 76 deletions

View File

@@ -0,0 +1,190 @@
import { describe, it, expect } from 'vitest'
import type { MenuOption } from './useMoreOptionsMenu'
import {
buildStructuredMenu,
convertContextMenuToOptions
} from './contextMenuConverter'
describe('contextMenuConverter', () => {
describe('buildStructuredMenu', () => {
it('should order core items before extension items', () => {
const options: MenuOption[] = [
{ label: 'Custom Extension Item', source: 'litegraph' },
{ label: 'Copy', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Core items (Rename, Copy) should come before extension items
const renameIndex = result.findIndex((opt) => opt.label === 'Rename')
const copyIndex = result.findIndex((opt) => opt.label === 'Copy')
const extensionIndex = result.findIndex(
(opt) => opt.label === 'Custom Extension Item'
)
expect(renameIndex).toBeLessThan(extensionIndex)
expect(copyIndex).toBeLessThan(extensionIndex)
})
it('should add Extensions category label before extension items', () => {
const options: MenuOption[] = [
{ label: 'Copy', source: 'vue' },
{ label: 'My Custom Extension', source: 'litegraph' }
]
const result = buildStructuredMenu(options)
const extensionsLabel = result.find(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
expect(extensionsLabel).toBeDefined()
expect(extensionsLabel?.disabled).toBe(true)
})
it('should place Delete at the very end', () => {
const options: MenuOption[] = [
{ label: 'Delete', action: () => {}, source: 'vue' },
{ label: 'Copy', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
const lastNonDivider = [...result]
.reverse()
.find((opt) => opt.type !== 'divider')
expect(lastNonDivider?.label).toBe('Delete')
})
it('should deduplicate items with same label, preferring vue source', () => {
const options: MenuOption[] = [
{ label: 'Copy', action: () => {}, source: 'litegraph' },
{ label: 'Copy', action: () => {}, source: 'vue' }
]
const result = buildStructuredMenu(options)
const copyItems = result.filter((opt) => opt.label === 'Copy')
expect(copyItems).toHaveLength(1)
expect(copyItems[0].source).toBe('vue')
})
it('should preserve dividers between sections', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },
{ label: 'Copy', source: 'vue' },
{ label: 'Pin', source: 'vue' }
]
const result = buildStructuredMenu(options)
const dividers = result.filter((opt) => opt.type === 'divider')
expect(dividers.length).toBeGreaterThan(0)
})
it('should handle empty input', () => {
const result = buildStructuredMenu([])
expect(result).toEqual([])
})
it('should handle only dividers', () => {
const options: MenuOption[] = [{ type: 'divider' }, { type: 'divider' }]
const result = buildStructuredMenu(options)
// Should be empty since dividers are filtered initially
expect(result).toEqual([])
})
it('should recognize Remove as equivalent to Delete', () => {
const options: MenuOption[] = [
{ label: 'Remove', action: () => {}, source: 'vue' },
{ label: 'Copy', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Remove should be placed at the end like Delete
const lastNonDivider = [...result]
.reverse()
.find((opt) => opt.type !== 'divider')
expect(lastNonDivider?.label).toBe('Remove')
})
it('should group core items in correct section order', () => {
const options: MenuOption[] = [
{ label: 'Color', source: 'vue' },
{ label: 'Node Info', source: 'vue' },
{ label: 'Pin', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Get indices of items (excluding dividers and categories)
const getIndex = (label: string) =>
result.findIndex((opt) => opt.label === label)
// Rename (section 1) should come before Pin (section 2)
expect(getIndex('Rename')).toBeLessThan(getIndex('Pin'))
// Pin (section 2) should come before Node Info (section 4)
expect(getIndex('Pin')).toBeLessThan(getIndex('Node Info'))
// Node Info (section 4) should come before or with Color (section 4)
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
})
describe('convertContextMenuToOptions', () => {
it('should convert empty array to empty result', () => {
const result = convertContextMenuToOptions([])
expect(result).toEqual([])
})
it('should convert null items to dividers', () => {
const result = convertContextMenuToOptions([null], undefined, false)
expect(result).toHaveLength(1)
expect(result[0].type).toBe('divider')
})
it('should skip blacklisted items like Properties', () => {
const items = [{ content: 'Properties', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result.find((opt) => opt.label === 'Properties')).toBeUndefined()
})
it('should convert basic menu items with content', () => {
const items = [{ content: 'Test Item', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result).toHaveLength(1)
expect(result[0].label).toBe('Test Item')
})
it('should mark items as litegraph source', () => {
const items = [{ content: 'Test Item', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result[0].source).toBe('litegraph')
})
it('should pass through disabled state', () => {
const items = [{ content: 'Disabled Item', disabled: true }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result[0].disabled).toBe(true)
})
it('should apply structuring by default', () => {
const items = [
{ content: 'Copy', callback: () => {} },
{ content: 'Custom Extension', callback: () => {} }
]
const result = convertContextMenuToOptions(items)
// With structuring, there should be Extensions category
const hasExtensionsCategory = result.some(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
expect(hasExtensionsCategory).toBe(true)
})
})
})

View File

@@ -0,0 +1,620 @@
import { default as DOMPurify } from 'dompurify'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
IContextMenuValue,
LGraphNode,
IContextMenuOptions,
ContextMenu
} from '@/lib/litegraph/src/litegraph'
import type { MenuOption, SubMenuOption } from './useMoreOptionsMenu'
import type { ContextMenuDivElement } from '@/lib/litegraph/src/interfaces'
/**
* Hard blacklist - items that should NEVER be included
*/
const HARD_BLACKLIST = new Set([
'Properties', // Never include Properties submenu
'Colors', // Use singular "Color" instead
'Shapes', // Use singular "Shape" instead
'Title',
'Mode',
'Properties Panel',
'Copy (Clipspace)'
])
/**
* Core menu items - items that should appear in the main menu, not under Extensions
* Includes both LiteGraph base menu items and ComfyUI built-in functionality
*/
const CORE_MENU_ITEMS = new Set([
// Basic operations
'Rename',
'Copy',
'Duplicate',
'Clone',
// Node state operations
'Run Branch',
'Pin',
'Unpin',
'Bypass',
'Remove Bypass',
'Mute',
// Structure operations
'Convert to Subgraph',
'Frame selection',
'Minimize Node',
'Expand',
'Collapse',
// Info and adjustments
'Node Info',
'Resize',
'Title',
'Properties Panel',
'Adjust Size',
// Visual
'Color',
'Colors',
'Shape',
'Shapes',
'Mode',
// Built-in node operations (node-specific)
'Open Image',
'Copy Image',
'Save Image',
'Open in Mask Editor',
'Edit Subgraph Widgets',
'Unpack Subgraph',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Selection and alignment
'Align Selected To',
'Distribute Nodes',
// Deletion
'Delete',
'Remove',
// LiteGraph base items
'Show Advanced',
'Hide Advanced'
])
/**
* Normalize menu item label for duplicate detection
* Handles variations like Colors/Color, Shapes/Shape, Pin/Unpin, Remove/Delete
*/
function normalizeLabel(label: string): string {
return label
.toLowerCase()
.replace(/^un/, '') // Remove 'un' prefix (Unpin -> Pin)
.trim()
}
/**
* Check if a similar menu item already exists in the results
* Returns true if an item with the same normalized label exists
*/
function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
const normalizedLabel = normalizeLabel(label)
// Map of equivalent items
const equivalents: Record<string, string[]> = {
color: ['color', 'colors'],
shape: ['shape', 'shapes'],
pin: ['pin', 'unpin'],
delete: ['remove', 'delete'],
duplicate: ['clone', 'duplicate']
}
return existingItems.some((item) => {
if (!item.label) return false
const existingNormalized = normalizeLabel(item.label)
// Check direct match
if (existingNormalized === normalizedLabel) return true
// Check if they're in the same equivalence group
for (const values of Object.values(equivalents)) {
if (
values.includes(normalizedLabel) &&
values.includes(existingNormalized)
) {
return true
}
}
return false
})
}
/**
* Check if a menu item is a core menu item (not an extension)
* Core items include LiteGraph base items and ComfyUI built-in functionality
*/
function isCoreMenuItem(label: string): boolean {
return CORE_MENU_ITEMS.has(label)
}
/**
* Filter out duplicate menu items based on label
* Gives precedence to Vue hardcoded options over LiteGraph options
*/
function removeDuplicateMenuOptions(options: MenuOption[]): MenuOption[] {
// Group items by label
const itemsByLabel = new Map<string, MenuOption[]>()
const itemsWithoutLabel: MenuOption[] = []
for (const opt of options) {
// Always keep dividers and category items
if (opt.type === 'divider' || opt.type === 'category') {
itemsWithoutLabel.push(opt)
continue
}
// Items without labels are kept as-is
if (!opt.label) {
itemsWithoutLabel.push(opt)
continue
}
// Group by label
if (!itemsByLabel.has(opt.label)) {
itemsByLabel.set(opt.label, [])
}
itemsByLabel.get(opt.label)!.push(opt)
}
// Select best item for each label (prefer vue over litegraph)
const result: MenuOption[] = []
const seenLabels = new Set<string>()
for (const opt of options) {
// Add non-labeled items in original order
if (opt.type === 'divider' || opt.type === 'category' || !opt.label) {
if (itemsWithoutLabel.includes(opt)) {
result.push(opt)
const idx = itemsWithoutLabel.indexOf(opt)
itemsWithoutLabel.splice(idx, 1)
}
continue
}
// Skip if we already processed this label
if (seenLabels.has(opt.label)) {
continue
}
seenLabels.add(opt.label)
// Get all items with this label
const duplicates = itemsByLabel.get(opt.label)!
// If only one item, add it
if (duplicates.length === 1) {
result.push(duplicates[0])
continue
}
// Multiple items: prefer vue source over litegraph
const vueItem = duplicates.find((item) => item.source === 'vue')
if (vueItem) {
result.push(vueItem)
} else {
// No vue item, just take the first one
result.push(duplicates[0])
}
}
return result
}
/**
* Order groups for menu items - defines the display order of sections
*/
const MENU_ORDER: string[] = [
// Section 1: Basic operations
'Rename',
'Copy',
'Duplicate',
// Section 2: Node actions
'Run Branch',
'Pin',
'Unpin',
'Bypass',
'Remove Bypass',
'Mute',
// Section 3: Structure operations
'Convert to Subgraph',
'Frame selection',
'Minimize Node',
'Expand',
'Collapse',
'Resize',
'Clone',
// Section 4: Node properties
'Node Info',
'Color',
// Section 5: Node-specific operations
'Open in Mask Editor',
'Open Image',
'Copy Image',
'Save Image',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Fallback for other core items
'Convert to Group Node (Deprecated)'
]
/**
* Get the order index for a menu item (lower = earlier in menu)
*/
function getMenuItemOrder(label: string): number {
const index = MENU_ORDER.indexOf(label)
return index === -1 ? 999 : index
}
/**
* Build structured menu with core items first, then extensions under a labeled section
* Ensures Delete always appears at the bottom
*/
export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
// First, remove duplicates (giving precedence to Vue hardcoded options)
const deduplicated = removeDuplicateMenuOptions(options)
const coreItemsMap = new Map<string, MenuOption>()
const extensionItems: MenuOption[] = []
let deleteItem: MenuOption | undefined
// Separate items into core and extension categories
for (const option of deduplicated) {
// Skip dividers for now - we'll add them between sections later
if (option.type === 'divider') {
continue
}
// Skip category labels (they'll be added separately)
if (option.type === 'category') {
continue
}
// Check if this is the Delete/Remove item - save it for the end
const isDeleteItem = option.label === 'Delete' || option.label === 'Remove'
if (isDeleteItem && !option.hasSubmenu) {
deleteItem = option
continue
}
// Categorize based on label
if (option.label && isCoreMenuItem(option.label)) {
coreItemsMap.set(option.label, option)
} else {
extensionItems.push(option)
}
}
// Build ordered core items based on MENU_ORDER
const orderedCoreItems: MenuOption[] = []
const coreLabels = Array.from(coreItemsMap.keys())
coreLabels.sort((a, b) => getMenuItemOrder(a) - getMenuItemOrder(b))
// Section boundaries based on MENU_ORDER indices
// Section 1: 0-2 (Rename, Copy, Duplicate)
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
// Section 3: 9-15 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse, Resize, Clone)
// Section 4: 16-17 (Node Info, Color)
// Section 5: 18+ (Image operations and fallback items)
const getSectionNumber = (index: number): number => {
if (index <= 2) return 1
if (index <= 8) return 2
if (index <= 15) return 3
if (index <= 17) return 4
return 5
}
let lastSection = 0
for (const label of coreLabels) {
const item = coreItemsMap.get(label)!
const itemIndex = getMenuItemOrder(label)
const currentSection = getSectionNumber(itemIndex)
// Add divider when moving to a new section
if (lastSection > 0 && currentSection !== lastSection) {
orderedCoreItems.push({ type: 'divider' })
}
orderedCoreItems.push(item)
lastSection = currentSection
}
// Build the final menu structure
const result: MenuOption[] = []
// Add ordered core items with their dividers
result.push(...orderedCoreItems)
// Add extensions section if there are extension items
if (extensionItems.length > 0) {
// Add divider before Extensions section
result.push({ type: 'divider' })
// Add non-clickable Extensions label
result.push({
label: 'Extensions',
type: 'category',
disabled: true
})
// Add extension items
result.push(...extensionItems)
}
// Add Delete at the bottom if it exists
if (deleteItem) {
result.push({ type: 'divider' })
result.push(deleteItem)
}
return result
}
/**
* Convert LiteGraph IContextMenuValue items to Vue MenuOption format
* Used to bridge LiteGraph context menus into Vue node menus
* @param items - The LiteGraph menu items to convert
* @param node - The node context (optional)
* @param applyStructuring - Whether to apply menu structuring (core/extensions separation). Defaults to true.
*/
export function convertContextMenuToOptions(
items: (IContextMenuValue | null)[],
node?: LGraphNode,
applyStructuring: boolean = true
): MenuOption[] {
const result: MenuOption[] = []
for (const item of items) {
// Null items are separators in LiteGraph
if (item === null) {
result.push({ type: 'divider' })
continue
}
// Skip items without content (shouldn't happen, but be safe)
if (!item.content) {
continue
}
// Skip hard blacklisted items
if (HARD_BLACKLIST.has(item.content)) {
continue
}
// Skip if a similar item already exists in results
if (isDuplicateItem(item.content, result)) {
continue
}
const option: MenuOption = {
label: item.content,
source: 'litegraph'
}
// Pass through disabled state
if (item.disabled) {
option.disabled = true
}
// Handle submenus
if (item.has_submenu) {
// Static submenu with pre-defined options
if (item.submenu?.options) {
option.hasSubmenu = true
option.submenu = convertSubmenuToOptions(item.submenu.options)
}
// Dynamic submenu - callback creates it on-demand
else if (item.callback && !item.disabled) {
option.hasSubmenu = true
// Intercept the callback to capture dynamic submenu items
const capturedSubmenu = captureDynamicSubmenu(item, node)
if (capturedSubmenu) {
option.submenu = capturedSubmenu
} else {
console.warn(
'[ContextMenuConverter] Failed to capture submenu for:',
item.content
)
}
}
}
// Handle callback (only if not disabled and not a submenu)
else if (item.callback && !item.disabled) {
// Wrap the callback to match the () => void signature
option.action = () => {
try {
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
undefined,
undefined,
item
)
} catch (error) {
console.error('Error executing context menu callback:', error)
}
}
}
result.push(option)
}
// Apply structured menu with core items and extensions section (if requested)
if (applyStructuring) {
return buildStructuredMenu(result)
}
return result
}
/**
* Capture submenu items from a dynamic submenu callback
* Intercepts ContextMenu constructor to extract items without creating HTML menu
*/
function captureDynamicSubmenu(
item: IContextMenuValue,
node?: LGraphNode
): SubMenuOption[] | undefined {
let capturedItems: readonly (IContextMenuValue | string | null)[] | undefined
let capturedOptions: IContextMenuOptions | undefined
// Store original ContextMenu constructor
const OriginalContextMenu = LiteGraph.ContextMenu
try {
// Mock ContextMenu constructor to capture submenu items and options
LiteGraph.ContextMenu = function (
items: readonly (IContextMenuValue | string | null)[],
options?: IContextMenuOptions
) {
// Capture both items and options
capturedItems = items
capturedOptions = options
// Return a minimal mock object to prevent errors
return {
close: () => {},
root: document.createElement('div')
} as unknown as ContextMenu
} as unknown as typeof ContextMenu
// Execute the callback to trigger submenu creation
try {
// Create a mock MouseEvent for the callback
const mockEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: 0,
clientY: 0
})
// Create a mock parent menu
const mockMenu = {
close: () => {},
root: document.createElement('div')
} as unknown as ContextMenu
// Call the callback which should trigger ContextMenu constructor
// Callback signature varies, but typically: (value, options, event, menu, node)
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
mockEvent,
mockMenu,
node // Pass the node context for callbacks that need it
)
} catch (error) {
console.warn(
'[ContextMenuConverter] Error executing callback for:',
item.content,
error
)
}
} finally {
// Always restore original constructor
LiteGraph.ContextMenu = OriginalContextMenu
}
// Convert captured items to Vue submenu format
if (capturedItems) {
const converted = convertSubmenuToOptions(capturedItems, capturedOptions)
return converted
}
console.warn('[ContextMenuConverter] No items captured for:', item.content)
return undefined
}
/**
* Convert LiteGraph submenu items to Vue SubMenuOption format
*/
function convertSubmenuToOptions(
items: readonly (IContextMenuValue | string | null)[],
options?: IContextMenuOptions
): SubMenuOption[] {
const result: SubMenuOption[] = []
for (const item of items) {
// Skip null separators
if (item === null) {
continue
}
// Handle string items (simple labels like in Mode/Shapes menus)
if (typeof item === 'string') {
const subOption: SubMenuOption = {
label: item,
action: () => {
try {
// Call the options callback with the string value
if (options?.callback) {
void options.callback.call(
null,
item,
options,
undefined,
undefined,
options.extra
)
}
} catch (error) {
console.error('Error executing string item callback:', error)
}
}
}
result.push(subOption)
continue
}
// Handle object items
if (!item.content) {
continue
}
// Extract text content from HTML if present
const content = stripHtmlTags(item.content)
const subOption: SubMenuOption = {
label: content,
action: () => {
try {
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
undefined,
undefined,
item
)
} catch (error) {
console.error('Error executing submenu callback:', error)
}
}
}
// Pass through disabled state
if (item.disabled) {
subOption.disabled = true
}
result.push(subOption)
}
return result
}
/**
* Strip HTML tags from content string safely
* LiteGraph menu items often include HTML for styling
*/
function stripHtmlTags(html: string): string {
// Use DOMPurify to sanitize and strip all HTML tags
const sanitized = DOMPurify.sanitize(html, { ALLOWED_TAGS: [] })
const result = sanitized.trim()
return result || html.replace(/<[^>]*>/g, '').trim() || html
}

View File

@@ -15,10 +15,13 @@ export interface MenuOption {
icon?: string
shortcut?: string
hasSubmenu?: boolean
type?: 'divider'
type?: 'divider' | 'category'
action?: () => void
submenu?: SubMenuOption[]
badge?: BadgeVariant
disabled?: boolean
source?: 'litegraph' | 'vue'
isColorPicker?: boolean
}
export interface SubMenuOption {
@@ -26,6 +29,7 @@ export interface SubMenuOption {
icon?: string
action: () => void
color?: string
disabled?: boolean
}
export enum BadgeVariant {

View File

@@ -22,7 +22,10 @@ export const useContextMenuTranslation = () => {
this: LGraphCanvas,
...args: Parameters<typeof getCanvasMenuOptions>
) {
const res: IContextMenuValue[] = getCanvasMenuOptions.apply(this, args)
const res: (IContextMenuValue | null)[] = getCanvasMenuOptions.apply(
this,
args
)
// Add items from new extension API
const newApiItems = app.collectCanvasMenuItems(this)
@@ -58,13 +61,16 @@ export const useContextMenuTranslation = () => {
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)
const res = nodeMenuFn.apply(this, args) as (IContextMenuValue | null)[]
// Add items from new extension API
const node = args[0]
@@ -73,11 +79,28 @@ export const useContextMenuTranslation = () => {
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

View File

@@ -710,8 +710,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
getMenuOptions?(): IContextMenuValue<string>[]
getExtraMenuOptions?(
canvas: LGraphCanvas,
options: IContextMenuValue<string>[]
): IContextMenuValue<string>[]
options: (IContextMenuValue<string> | null)[]
): (IContextMenuValue<string> | null)[]
static active_node: LGraphNode
/** called before modifying the graph */
onBeforeChange?(graph: LGraph): void
@@ -8019,8 +8019,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
getCanvasMenuOptions(): IContextMenuValue[] {
let options: IContextMenuValue<string>[]
getCanvasMenuOptions(): (IContextMenuValue | null)[] {
let options: (IContextMenuValue<string> | null)[]
if (this.getMenuOptions) {
options = this.getMenuOptions()
} else {

View File

@@ -7,7 +7,9 @@ import type { IContextMenuValue } from './interfaces'
*/
const ENABLE_LEGACY_SUPPORT = true
type ContextMenuValueProvider = (...args: unknown[]) => IContextMenuValue[]
type ContextMenuValueProvider = (
...args: unknown[]
) => (IContextMenuValue | null)[]
class LegacyMenuCompat {
private originalMethods = new Map<string, ContextMenuValueProvider>()
@@ -37,16 +39,22 @@ class LegacyMenuCompat {
* @param preWrapperFn The method that existed before the wrapper
* @param prototype The prototype to verify wrapper installation
*/
registerWrapper(
methodName: keyof LGraphCanvas,
wrapperFn: ContextMenuValueProvider,
preWrapperFn: ContextMenuValueProvider,
registerWrapper<K extends keyof LGraphCanvas>(
methodName: K,
wrapperFn: LGraphCanvas[K],
preWrapperFn: LGraphCanvas[K],
prototype?: LGraphCanvas
) {
this.wrapperMethods.set(methodName, wrapperFn)
this.preWrapperMethods.set(methodName, preWrapperFn)
this.wrapperMethods.set(
methodName as string,
wrapperFn as unknown as ContextMenuValueProvider
)
this.preWrapperMethods.set(
methodName as string,
preWrapperFn as unknown as ContextMenuValueProvider
)
const isInstalled = prototype && prototype[methodName] === wrapperFn
this.wrapperInstalled.set(methodName, !!isInstalled)
this.wrapperInstalled.set(methodName as string, !!isInstalled)
}
/**
@@ -54,11 +62,17 @@ class LegacyMenuCompat {
* @param prototype The prototype to install on
* @param methodName The method name to track
*/
install(prototype: LGraphCanvas, methodName: keyof LGraphCanvas) {
install<K extends keyof LGraphCanvas>(
prototype: LGraphCanvas,
methodName: K
) {
if (!ENABLE_LEGACY_SUPPORT) return
const originalMethod = prototype[methodName]
this.originalMethods.set(methodName, originalMethod)
this.originalMethods.set(
methodName as string,
originalMethod as unknown as ContextMenuValueProvider
)
let currentImpl = originalMethod
@@ -66,13 +80,13 @@ class LegacyMenuCompat {
get() {
return currentImpl
},
set: (newImpl: ContextMenuValueProvider) => {
const fnKey = `${methodName}:${newImpl.toString().slice(0, 100)}`
set: (newImpl: LGraphCanvas[K]) => {
const fnKey = `${methodName as string}:${newImpl.toString().slice(0, 100)}`
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
this.hasWarned.add(fnKey)
console.warn(
`%c[DEPRECATED]%c Monkey-patching ${methodName} is deprecated. (Extension: "${this.currentExtension}")\n` +
`%c[DEPRECATED]%c Monkey-patching ${methodName as string} is deprecated. (Extension: "${this.currentExtension}")\n` +
`Please use the new context menu API instead.\n\n` +
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
'color: orange; font-weight: bold',
@@ -85,7 +99,15 @@ class LegacyMenuCompat {
}
/**
* Extract items that were added by legacy monkey patches
* Extract items that were added by legacy monkey patches.
*
* Uses set-based diffing by reference to reliably detect additions regardless
* of item reordering or replacement. Items present in patchedItems but not in
* originalItems (by reference equality) are considered additions.
*
* Note: If a monkey patch removes items (patchedItems has fewer unique items
* than originalItems), a warning is logged but we still return any new items.
*
* @param methodName The method name that was monkey-patched
* @param context The context to call methods with
* @param args Arguments to pass to the methods
@@ -95,7 +117,7 @@ class LegacyMenuCompat {
methodName: keyof LGraphCanvas,
context: LGraphCanvas,
...args: unknown[]
): IContextMenuValue[] {
): (IContextMenuValue | null)[] {
if (!ENABLE_LEGACY_SUPPORT) return []
if (this.isExtracting) return []
@@ -106,7 +128,7 @@ class LegacyMenuCompat {
this.isExtracting = true
const originalItems = originalMethod.apply(context, args) as
| IContextMenuValue[]
| (IContextMenuValue | null)[]
| undefined
if (!originalItems) return []
@@ -127,15 +149,26 @@ class LegacyMenuCompat {
const methodToCall = shouldSkipWrapper ? preWrapperMethod : currentMethod
const patchedItems = methodToCall.apply(context, args) as
| IContextMenuValue[]
| (IContextMenuValue | null)[]
| undefined
if (!patchedItems) return []
if (patchedItems.length > originalItems.length) {
return patchedItems.slice(originalItems.length) as IContextMenuValue[]
// Use set-based diff to detect additions by reference
const originalSet = new Set<IContextMenuValue | null>(originalItems)
const addedItems = patchedItems.filter((item) => !originalSet.has(item))
// Warn if items were removed (patched has fewer original items than expected)
const retainedOriginalCount = patchedItems.filter((item) =>
originalSet.has(item)
).length
if (retainedOriginalCount < originalItems.length) {
console.warn(
`[Context Menu Compat] Monkey patch for ${methodName} removed ${originalItems.length - retainedOriginalCount} original menu item(s). ` +
`This may cause unexpected behavior.`
)
}
return []
return addedItems
} catch (e) {
console.error('[Context Menu Compat] Failed to extract legacy items:', e)
return []

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
describe('contextMenuCompat', () => {
@@ -98,13 +99,15 @@ describe('contextMenuCompat', () => {
})
describe('extractLegacyItems', () => {
// Cache base items to ensure reference equality for set-based diffing
const baseItem1 = { content: 'Item 1', callback: () => {} }
const baseItem2 = { content: 'Item 2', callback: () => {} }
beforeEach(() => {
// Setup a mock original method
// Setup a mock original method that returns cached items
// This ensures reference equality when set-based diffing compares items
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [
{ content: 'Item 1', callback: () => {} },
{ content: 'Item 2', callback: () => {} }
]
return [baseItem1, baseItem2]
}
// Install compatibility layer
@@ -114,12 +117,13 @@ describe('contextMenuCompat', () => {
it('should extract items added by monkey patches', () => {
// Monkey-patch to add items
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Custom Item 1', callback: () => {} })
items.push({ content: 'Custom Item 2', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Custom Item 1', callback: () => {} })
items.push({ content: 'Custom Item 2', callback: () => {} })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
@@ -142,8 +146,11 @@ describe('contextMenuCompat', () => {
expect(legacyItems).toHaveLength(0)
})
it('should return empty array when patched method returns same count', () => {
// Monkey-patch that replaces items but keeps same count
it('should detect replaced items as additions and warn about removed items', () => {
const warnSpy = vi.spyOn(console, 'warn')
// Monkey-patch that replaces items with different ones (same count)
// With set-based diffing, these are detected as new items since they're different references
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [
{ content: 'Replaced 1', callback: () => {} },
@@ -156,7 +163,13 @@ describe('contextMenuCompat', () => {
mockCanvas
)
expect(legacyItems).toHaveLength(0)
// Set-based diffing detects the replaced items as additions
expect(legacyItems).toHaveLength(2)
expect(legacyItems[0]).toMatchObject({ content: 'Replaced 1' })
expect(legacyItems[1]).toMatchObject({ content: 'Replaced 2' })
// Should warn about removed original items
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('removed'))
})
it('should handle errors gracefully', () => {
@@ -181,29 +194,36 @@ describe('contextMenuCompat', () => {
})
describe('integration', () => {
// Cache base items to ensure reference equality for set-based diffing
const integrationBaseItem = { content: 'Base Item', callback: () => {} }
const integrationBaseItem1 = { content: 'Base Item 1', callback: () => {} }
const integrationBaseItem2 = { content: 'Base Item 2', callback: () => {} }
it('should work with multiple extensions patching', () => {
// Setup base method
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [{ content: 'Base Item', callback: () => {} }]
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// First extension patches
const original1 = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original1 as any).apply(this, args)
items.push({ content: 'Extension 1 Item', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original1.apply(this)
items.push({ content: 'Extension 1 Item', callback: () => {} })
return items
}
// Second extension patches
const original2 = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original2 as any).apply(this, args)
items.push({ content: 'Extension 2 Item', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original2.apply(this)
items.push({ content: 'Extension 2 Item', callback: () => {} })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
@@ -218,24 +238,22 @@ describe('contextMenuCompat', () => {
})
it('should extract legacy items only once even when called multiple times', () => {
// Setup base method
// Setup base method with cached items
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [
{ content: 'Base Item 1', callback: () => {} },
{ content: 'Base Item 2', callback: () => {} }
]
return [integrationBaseItem1, integrationBaseItem2]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Simulate legacy extension monkey-patching the prototype
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Legacy Item 1', callback: () => {} })
items.push({ content: 'Legacy Item 2', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Legacy Item 1', callback: () => {} })
items.push({ content: 'Legacy Item 2', callback: () => {} })
return items
}
// Extract legacy items multiple times (simulating repeated menu opens)
const legacyItems1 = legacyMenuCompat.extractLegacyItems(
@@ -268,17 +286,19 @@ describe('contextMenuCompat', () => {
})
it('should not extract items from registered wrapper methods', () => {
// Setup base method
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [{ content: 'Base Item', callback: () => {} }]
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Create a wrapper that adds new API items (simulating useContextMenuTranslation)
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
const wrapperMethod = function (this: LGraphCanvas) {
const items = (originalMethod as any).apply(this, [])
const wrapperMethod = function (
this: LGraphCanvas
): (IContextMenuValue | null)[] {
const items = originalMethod.apply(this)
// Add new API items
items.push({ content: 'New API Item 1', callback: () => {} })
items.push({ content: 'New API Item 2', callback: () => {} })
@@ -306,16 +326,16 @@ describe('contextMenuCompat', () => {
})
it('should extract legacy items even when a wrapper is registered but not active', () => {
// Setup base method
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [{ content: 'Base Item', callback: () => {} }]
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Register a wrapper (but don't set it as the current method)
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
const wrapperMethod = function () {
const wrapperMethod = function (): (IContextMenuValue | null)[] {
return [{ content: 'Wrapper Item', callback: () => {} }]
}
legacyMenuCompat.registerWrapper(
@@ -327,11 +347,12 @@ describe('contextMenuCompat', () => {
// Monkey-patch with a different function (legacy extension)
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Legacy Item', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Legacy Item', callback: () => {} })
return items
}
// Extract legacy items - should return the legacy item because current method is NOT the wrapper
const legacyItems = legacyMenuCompat.extractLegacyItems(