mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +00:00
[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:
committed by
GitHub
parent
e96593fe4c
commit
c414635ead
190
src/composables/graph/contextMenuConverter.test.ts
Normal file
190
src/composables/graph/contextMenuConverter.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
620
src/composables/graph/contextMenuConverter.ts
Normal file
620
src/composables/graph/contextMenuConverter.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -15,10 +15,13 @@ export interface MenuOption {
|
|||||||
icon?: string
|
icon?: string
|
||||||
shortcut?: string
|
shortcut?: string
|
||||||
hasSubmenu?: boolean
|
hasSubmenu?: boolean
|
||||||
type?: 'divider'
|
type?: 'divider' | 'category'
|
||||||
action?: () => void
|
action?: () => void
|
||||||
submenu?: SubMenuOption[]
|
submenu?: SubMenuOption[]
|
||||||
badge?: BadgeVariant
|
badge?: BadgeVariant
|
||||||
|
disabled?: boolean
|
||||||
|
source?: 'litegraph' | 'vue'
|
||||||
|
isColorPicker?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubMenuOption {
|
export interface SubMenuOption {
|
||||||
@@ -26,6 +29,7 @@ export interface SubMenuOption {
|
|||||||
icon?: string
|
icon?: string
|
||||||
action: () => void
|
action: () => void
|
||||||
color?: string
|
color?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum BadgeVariant {
|
export enum BadgeVariant {
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export const useContextMenuTranslation = () => {
|
|||||||
this: LGraphCanvas,
|
this: LGraphCanvas,
|
||||||
...args: Parameters<typeof getCanvasMenuOptions>
|
...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
|
// Add items from new extension API
|
||||||
const newApiItems = app.collectCanvasMenuItems(this)
|
const newApiItems = app.collectCanvasMenuItems(this)
|
||||||
@@ -58,13 +61,16 @@ export const useContextMenuTranslation = () => {
|
|||||||
LGraphCanvas.prototype
|
LGraphCanvas.prototype
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Install compatibility layer for getNodeMenuOptions
|
||||||
|
legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions')
|
||||||
|
|
||||||
// Wrap getNodeMenuOptions to add new API items
|
// Wrap getNodeMenuOptions to add new API items
|
||||||
const nodeMenuFn = LGraphCanvas.prototype.getNodeMenuOptions
|
const nodeMenuFn = LGraphCanvas.prototype.getNodeMenuOptions
|
||||||
const getNodeMenuOptionsWithExtensions = function (
|
const getNodeMenuOptionsWithExtensions = function (
|
||||||
this: LGraphCanvas,
|
this: LGraphCanvas,
|
||||||
...args: Parameters<typeof nodeMenuFn>
|
...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
|
// Add items from new extension API
|
||||||
const node = args[0]
|
const node = args[0]
|
||||||
@@ -73,11 +79,28 @@ export const useContextMenuTranslation = () => {
|
|||||||
res.push(item)
|
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
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
LGraphCanvas.prototype.getNodeMenuOptions = getNodeMenuOptionsWithExtensions
|
LGraphCanvas.prototype.getNodeMenuOptions = getNodeMenuOptionsWithExtensions
|
||||||
|
|
||||||
|
legacyMenuCompat.registerWrapper(
|
||||||
|
'getNodeMenuOptions',
|
||||||
|
getNodeMenuOptionsWithExtensions,
|
||||||
|
nodeMenuFn,
|
||||||
|
LGraphCanvas.prototype
|
||||||
|
)
|
||||||
|
|
||||||
function translateMenus(
|
function translateMenus(
|
||||||
values: readonly (IContextMenuValue | string | null)[] | undefined,
|
values: readonly (IContextMenuValue | string | null)[] | undefined,
|
||||||
options: IContextMenuOptions
|
options: IContextMenuOptions
|
||||||
|
|||||||
@@ -710,8 +710,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
getMenuOptions?(): IContextMenuValue<string>[]
|
getMenuOptions?(): IContextMenuValue<string>[]
|
||||||
getExtraMenuOptions?(
|
getExtraMenuOptions?(
|
||||||
canvas: LGraphCanvas,
|
canvas: LGraphCanvas,
|
||||||
options: IContextMenuValue<string>[]
|
options: (IContextMenuValue<string> | null)[]
|
||||||
): IContextMenuValue<string>[]
|
): (IContextMenuValue<string> | null)[]
|
||||||
static active_node: LGraphNode
|
static active_node: LGraphNode
|
||||||
/** called before modifying the graph */
|
/** called before modifying the graph */
|
||||||
onBeforeChange?(graph: LGraph): void
|
onBeforeChange?(graph: LGraph): void
|
||||||
@@ -8019,8 +8019,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getCanvasMenuOptions(): IContextMenuValue[] {
|
getCanvasMenuOptions(): (IContextMenuValue | null)[] {
|
||||||
let options: IContextMenuValue<string>[]
|
let options: (IContextMenuValue<string> | null)[]
|
||||||
if (this.getMenuOptions) {
|
if (this.getMenuOptions) {
|
||||||
options = this.getMenuOptions()
|
options = this.getMenuOptions()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { IContextMenuValue } from './interfaces'
|
|||||||
*/
|
*/
|
||||||
const ENABLE_LEGACY_SUPPORT = true
|
const ENABLE_LEGACY_SUPPORT = true
|
||||||
|
|
||||||
type ContextMenuValueProvider = (...args: unknown[]) => IContextMenuValue[]
|
type ContextMenuValueProvider = (
|
||||||
|
...args: unknown[]
|
||||||
|
) => (IContextMenuValue | null)[]
|
||||||
|
|
||||||
class LegacyMenuCompat {
|
class LegacyMenuCompat {
|
||||||
private originalMethods = new Map<string, ContextMenuValueProvider>()
|
private originalMethods = new Map<string, ContextMenuValueProvider>()
|
||||||
@@ -37,16 +39,22 @@ class LegacyMenuCompat {
|
|||||||
* @param preWrapperFn The method that existed before the wrapper
|
* @param preWrapperFn The method that existed before the wrapper
|
||||||
* @param prototype The prototype to verify wrapper installation
|
* @param prototype The prototype to verify wrapper installation
|
||||||
*/
|
*/
|
||||||
registerWrapper(
|
registerWrapper<K extends keyof LGraphCanvas>(
|
||||||
methodName: keyof LGraphCanvas,
|
methodName: K,
|
||||||
wrapperFn: ContextMenuValueProvider,
|
wrapperFn: LGraphCanvas[K],
|
||||||
preWrapperFn: ContextMenuValueProvider,
|
preWrapperFn: LGraphCanvas[K],
|
||||||
prototype?: LGraphCanvas
|
prototype?: LGraphCanvas
|
||||||
) {
|
) {
|
||||||
this.wrapperMethods.set(methodName, wrapperFn)
|
this.wrapperMethods.set(
|
||||||
this.preWrapperMethods.set(methodName, preWrapperFn)
|
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
|
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 prototype The prototype to install on
|
||||||
* @param methodName The method name to track
|
* @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
|
if (!ENABLE_LEGACY_SUPPORT) return
|
||||||
|
|
||||||
const originalMethod = prototype[methodName]
|
const originalMethod = prototype[methodName]
|
||||||
this.originalMethods.set(methodName, originalMethod)
|
this.originalMethods.set(
|
||||||
|
methodName as string,
|
||||||
|
originalMethod as unknown as ContextMenuValueProvider
|
||||||
|
)
|
||||||
|
|
||||||
let currentImpl = originalMethod
|
let currentImpl = originalMethod
|
||||||
|
|
||||||
@@ -66,13 +80,13 @@ class LegacyMenuCompat {
|
|||||||
get() {
|
get() {
|
||||||
return currentImpl
|
return currentImpl
|
||||||
},
|
},
|
||||||
set: (newImpl: ContextMenuValueProvider) => {
|
set: (newImpl: LGraphCanvas[K]) => {
|
||||||
const fnKey = `${methodName}:${newImpl.toString().slice(0, 100)}`
|
const fnKey = `${methodName as string}:${newImpl.toString().slice(0, 100)}`
|
||||||
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
|
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
|
||||||
this.hasWarned.add(fnKey)
|
this.hasWarned.add(fnKey)
|
||||||
|
|
||||||
console.warn(
|
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` +
|
`Please use the new context menu API instead.\n\n` +
|
||||||
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
|
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
|
||||||
'color: orange; font-weight: bold',
|
'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 methodName The method name that was monkey-patched
|
||||||
* @param context The context to call methods with
|
* @param context The context to call methods with
|
||||||
* @param args Arguments to pass to the methods
|
* @param args Arguments to pass to the methods
|
||||||
@@ -95,7 +117,7 @@ class LegacyMenuCompat {
|
|||||||
methodName: keyof LGraphCanvas,
|
methodName: keyof LGraphCanvas,
|
||||||
context: LGraphCanvas,
|
context: LGraphCanvas,
|
||||||
...args: unknown[]
|
...args: unknown[]
|
||||||
): IContextMenuValue[] {
|
): (IContextMenuValue | null)[] {
|
||||||
if (!ENABLE_LEGACY_SUPPORT) return []
|
if (!ENABLE_LEGACY_SUPPORT) return []
|
||||||
if (this.isExtracting) return []
|
if (this.isExtracting) return []
|
||||||
|
|
||||||
@@ -106,7 +128,7 @@ class LegacyMenuCompat {
|
|||||||
this.isExtracting = true
|
this.isExtracting = true
|
||||||
|
|
||||||
const originalItems = originalMethod.apply(context, args) as
|
const originalItems = originalMethod.apply(context, args) as
|
||||||
| IContextMenuValue[]
|
| (IContextMenuValue | null)[]
|
||||||
| undefined
|
| undefined
|
||||||
if (!originalItems) return []
|
if (!originalItems) return []
|
||||||
|
|
||||||
@@ -127,15 +149,26 @@ class LegacyMenuCompat {
|
|||||||
const methodToCall = shouldSkipWrapper ? preWrapperMethod : currentMethod
|
const methodToCall = shouldSkipWrapper ? preWrapperMethod : currentMethod
|
||||||
|
|
||||||
const patchedItems = methodToCall.apply(context, args) as
|
const patchedItems = methodToCall.apply(context, args) as
|
||||||
| IContextMenuValue[]
|
| (IContextMenuValue | null)[]
|
||||||
| undefined
|
| undefined
|
||||||
if (!patchedItems) return []
|
if (!patchedItems) return []
|
||||||
|
|
||||||
if (patchedItems.length > originalItems.length) {
|
// Use set-based diff to detect additions by reference
|
||||||
return patchedItems.slice(originalItems.length) as IContextMenuValue[]
|
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) {
|
} catch (e) {
|
||||||
console.error('[Context Menu Compat] Failed to extract legacy items:', e)
|
console.error('[Context Menu Compat] Failed to extract legacy items:', e)
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
||||||
|
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
|
||||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||||
|
|
||||||
describe('contextMenuCompat', () => {
|
describe('contextMenuCompat', () => {
|
||||||
@@ -98,13 +99,15 @@ describe('contextMenuCompat', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('extractLegacyItems', () => {
|
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(() => {
|
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 () {
|
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||||
return [
|
return [baseItem1, baseItem2]
|
||||||
{ content: 'Item 1', callback: () => {} },
|
|
||||||
{ content: 'Item 2', callback: () => {} }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install compatibility layer
|
// Install compatibility layer
|
||||||
@@ -114,12 +117,13 @@ describe('contextMenuCompat', () => {
|
|||||||
it('should extract items added by monkey patches', () => {
|
it('should extract items added by monkey patches', () => {
|
||||||
// Monkey-patch to add items
|
// Monkey-patch to add items
|
||||||
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
LGraphCanvas.prototype.getCanvasMenuOptions =
|
||||||
const items = (original as any).apply(this, args)
|
function (): (IContextMenuValue | null)[] {
|
||||||
items.push({ content: 'Custom Item 1', callback: () => {} })
|
const items = original.apply(this)
|
||||||
items.push({ content: 'Custom Item 2', callback: () => {} })
|
items.push({ content: 'Custom Item 1', callback: () => {} })
|
||||||
return items
|
items.push({ content: 'Custom Item 2', callback: () => {} })
|
||||||
}
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// Extract legacy items
|
// Extract legacy items
|
||||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||||
@@ -142,8 +146,11 @@ describe('contextMenuCompat', () => {
|
|||||||
expect(legacyItems).toHaveLength(0)
|
expect(legacyItems).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return empty array when patched method returns same count', () => {
|
it('should detect replaced items as additions and warn about removed items', () => {
|
||||||
// Monkey-patch that replaces items but keeps same count
|
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 () {
|
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||||
return [
|
return [
|
||||||
{ content: 'Replaced 1', callback: () => {} },
|
{ content: 'Replaced 1', callback: () => {} },
|
||||||
@@ -156,7 +163,13 @@ describe('contextMenuCompat', () => {
|
|||||||
mockCanvas
|
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', () => {
|
it('should handle errors gracefully', () => {
|
||||||
@@ -181,29 +194,36 @@ describe('contextMenuCompat', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('integration', () => {
|
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', () => {
|
it('should work with multiple extensions patching', () => {
|
||||||
// Setup base method
|
// Setup base method with cached item
|
||||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||||
return [{ content: 'Base Item', callback: () => {} }]
|
return [integrationBaseItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||||
|
|
||||||
// First extension patches
|
// First extension patches
|
||||||
const original1 = LGraphCanvas.prototype.getCanvasMenuOptions
|
const original1 = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
LGraphCanvas.prototype.getCanvasMenuOptions =
|
||||||
const items = (original1 as any).apply(this, args)
|
function (): (IContextMenuValue | null)[] {
|
||||||
items.push({ content: 'Extension 1 Item', callback: () => {} })
|
const items = original1.apply(this)
|
||||||
return items
|
items.push({ content: 'Extension 1 Item', callback: () => {} })
|
||||||
}
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// Second extension patches
|
// Second extension patches
|
||||||
const original2 = LGraphCanvas.prototype.getCanvasMenuOptions
|
const original2 = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
LGraphCanvas.prototype.getCanvasMenuOptions =
|
||||||
const items = (original2 as any).apply(this, args)
|
function (): (IContextMenuValue | null)[] {
|
||||||
items.push({ content: 'Extension 2 Item', callback: () => {} })
|
const items = original2.apply(this)
|
||||||
return items
|
items.push({ content: 'Extension 2 Item', callback: () => {} })
|
||||||
}
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// Extract legacy items
|
// Extract legacy items
|
||||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||||
@@ -218,24 +238,22 @@ describe('contextMenuCompat', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should extract legacy items only once even when called multiple times', () => {
|
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 () {
|
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||||
return [
|
return [integrationBaseItem1, integrationBaseItem2]
|
||||||
{ content: 'Base Item 1', callback: () => {} },
|
|
||||||
{ content: 'Base Item 2', callback: () => {} }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||||
|
|
||||||
// Simulate legacy extension monkey-patching the prototype
|
// Simulate legacy extension monkey-patching the prototype
|
||||||
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
LGraphCanvas.prototype.getCanvasMenuOptions =
|
||||||
const items = (original as any).apply(this, args)
|
function (): (IContextMenuValue | null)[] {
|
||||||
items.push({ content: 'Legacy Item 1', callback: () => {} })
|
const items = original.apply(this)
|
||||||
items.push({ content: 'Legacy Item 2', callback: () => {} })
|
items.push({ content: 'Legacy Item 1', callback: () => {} })
|
||||||
return items
|
items.push({ content: 'Legacy Item 2', callback: () => {} })
|
||||||
}
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// Extract legacy items multiple times (simulating repeated menu opens)
|
// Extract legacy items multiple times (simulating repeated menu opens)
|
||||||
const legacyItems1 = legacyMenuCompat.extractLegacyItems(
|
const legacyItems1 = legacyMenuCompat.extractLegacyItems(
|
||||||
@@ -268,17 +286,19 @@ describe('contextMenuCompat', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not extract items from registered wrapper methods', () => {
|
it('should not extract items from registered wrapper methods', () => {
|
||||||
// Setup base method
|
// Setup base method with cached item
|
||||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||||
return [{ content: 'Base Item', callback: () => {} }]
|
return [integrationBaseItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||||
|
|
||||||
// Create a wrapper that adds new API items (simulating useContextMenuTranslation)
|
// Create a wrapper that adds new API items (simulating useContextMenuTranslation)
|
||||||
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
|
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||||
const wrapperMethod = function (this: LGraphCanvas) {
|
const wrapperMethod = function (
|
||||||
const items = (originalMethod as any).apply(this, [])
|
this: LGraphCanvas
|
||||||
|
): (IContextMenuValue | null)[] {
|
||||||
|
const items = originalMethod.apply(this)
|
||||||
// Add new API items
|
// Add new API items
|
||||||
items.push({ content: 'New API Item 1', callback: () => {} })
|
items.push({ content: 'New API Item 1', callback: () => {} })
|
||||||
items.push({ content: 'New API Item 2', 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', () => {
|
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 () {
|
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||||
return [{ content: 'Base Item', callback: () => {} }]
|
return [integrationBaseItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||||
|
|
||||||
// Register a wrapper (but don't set it as the current method)
|
// Register a wrapper (but don't set it as the current method)
|
||||||
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
|
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||||
const wrapperMethod = function () {
|
const wrapperMethod = function (): (IContextMenuValue | null)[] {
|
||||||
return [{ content: 'Wrapper Item', callback: () => {} }]
|
return [{ content: 'Wrapper Item', callback: () => {} }]
|
||||||
}
|
}
|
||||||
legacyMenuCompat.registerWrapper(
|
legacyMenuCompat.registerWrapper(
|
||||||
@@ -327,11 +347,12 @@ describe('contextMenuCompat', () => {
|
|||||||
|
|
||||||
// Monkey-patch with a different function (legacy extension)
|
// Monkey-patch with a different function (legacy extension)
|
||||||
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
LGraphCanvas.prototype.getCanvasMenuOptions =
|
||||||
const items = (original as any).apply(this, args)
|
function (): (IContextMenuValue | null)[] {
|
||||||
items.push({ content: 'Legacy Item', callback: () => {} })
|
const items = original.apply(this)
|
||||||
return items
|
items.push({ content: 'Legacy Item', callback: () => {} })
|
||||||
}
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// Extract legacy items - should return the legacy item because current method is NOT the wrapper
|
// Extract legacy items - should return the legacy item because current method is NOT the wrapper
|
||||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||||
|
|||||||
Reference in New Issue
Block a user