diff --git a/browser_tests/tests/selectionToolboxSubmenus.spec.ts b/browser_tests/tests/selectionToolboxSubmenus.spec.ts index db63261528..ec5954e9b9 100644 --- a/browser_tests/tests/selectionToolboxSubmenus.spec.ts +++ b/browser_tests/tests/selectionToolboxSubmenus.spec.ts @@ -87,7 +87,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => { const initialShape = await nodeRef.getProperty('shape') await openMoreOptions(comfyPage) - await comfyPage.page.getByText('Shape', { exact: true }).click() + await comfyPage.page.getByText('Shape', { exact: true }).hover() await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({ timeout: 5000 }) @@ -141,10 +141,12 @@ test.describe('Selection Toolbox - More Options Submenus', () => { await expect( comfyPage.page.getByText('Rename', { exact: true }) ).toBeVisible({ timeout: 5000 }) + await comfyPage.page.waitForTimeout(500) await comfyPage.page .locator('#graph-canvas') .click({ position: { x: 0, y: 50 }, force: true }) + await comfyPage.nextFrame() await expect( comfyPage.page.getByText('Rename', { exact: true }) diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 125418f3bd..56cdba0033 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -83,7 +83,6 @@ @@ -111,7 +110,6 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue' import NodeTooltip from '@/components/graph/NodeTooltip.vue' import SelectionToolbox from '@/components/graph/SelectionToolbox.vue' import TitleEditor from '@/components/graph/TitleEditor.vue' -import NodeOptions from '@/components/graph/selectionToolbox/NodeOptions.vue' import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue' import SideToolbar from '@/components/sidebar/SideToolbar.vue' import TopbarBadges from '@/components/topbar/TopbarBadges.vue' diff --git a/src/components/graph/NodeContextMenu.vue b/src/components/graph/NodeContextMenu.vue new file mode 100644 index 0000000000..f81a677d87 --- /dev/null +++ b/src/components/graph/NodeContextMenu.vue @@ -0,0 +1,179 @@ + + + diff --git a/src/components/graph/SelectionToolbox.test.ts b/src/components/graph/SelectionToolbox.test.ts index e8e689bb25..0938a3fe3f 100644 --- a/src/components/graph/SelectionToolbox.test.ts +++ b/src/components/graph/SelectionToolbox.test.ts @@ -136,6 +136,7 @@ describe('SelectionToolbox', () => { '
', props: ['pt', 'style', 'class'] }, + NodeContextMenu: { template: '
' }, InfoButton: { template: '
' }, ColorPickerButton: { template: diff --git a/src/components/graph/SelectionToolbox.vue b/src/components/graph/SelectionToolbox.vue index 06626ab1e1..a56a82cf93 100644 --- a/src/components/graph/SelectionToolbox.vue +++ b/src/components/graph/SelectionToolbox.vue @@ -42,6 +42,7 @@
+ diff --git a/src/components/graph/selectionToolbox/NodeOptions.vue b/src/components/graph/selectionToolbox/NodeOptions.vue deleted file mode 100644 index 7bd18cfac9..0000000000 --- a/src/components/graph/selectionToolbox/NodeOptions.vue +++ /dev/null @@ -1,322 +0,0 @@ - - - diff --git a/src/components/input/SearchBox.vue b/src/components/input/SearchBox.vue index 1565bfcb31..adfeb3c367 100644 --- a/src/components/input/SearchBox.vue +++ b/src/components/input/SearchBox.vue @@ -90,4 +90,8 @@ const wrapperStyle = computed(() => { return cn(baseClasses, 'rounded-lg', sizeClasses) }) + +defineExpose({ + focusInput +}) diff --git a/src/composables/canvas/useSelectionToolboxPosition.ts b/src/composables/canvas/useSelectionToolboxPosition.ts index 0d15b3a129..734628bfb9 100644 --- a/src/composables/canvas/useSelectionToolboxPosition.ts +++ b/src/composables/canvas/useSelectionToolboxPosition.ts @@ -21,10 +21,10 @@ import { computeUnionBounds } from '@/utils/mathUtil' */ // Shared signals for auxiliary UI (e.g., MoreOptions) to coordinate hide/restore -export const moreOptionsOpen = ref(false) -export const forceCloseMoreOptionsSignal = ref(0) -export const restoreMoreOptionsSignal = ref(0) -export const moreOptionsRestorePending = ref(false) +const moreOptionsOpen = ref(false) +const forceCloseMoreOptionsSignal = ref(0) +const restoreMoreOptionsSignal = ref(0) +const moreOptionsRestorePending = ref(false) let moreOptionsWasOpenBeforeDrag = false let moreOptionsSelectionSignature: string | null = null diff --git a/src/composables/graph/contextMenuConverter.ts b/src/composables/graph/contextMenuConverter.ts new file mode 100644 index 0000000000..8b4d28b692 --- /dev/null +++ b/src/composables/graph/contextMenuConverter.ts @@ -0,0 +1,610 @@ +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph' + +import type { MenuOption, SubMenuOption } from './useMoreOptionsMenu' + +/** + * 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(/s$/, '') // Remove trailing 's' (Colors -> Color, Shapes -> Shape) + .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 = { + 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() + 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() + + 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 = [ + // 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)' +] as const + +/** + * Get the order index for a menu item (lower = earlier in menu) + */ +function getMenuItemOrder(label: string): number { + const index = MENU_ORDER.indexOf(label as any) + 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() + 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?: any, + 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 any, + item.value, + {}, + null as any, + null as any, + item as any + ) + } 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?: any +): SubMenuOption[] | undefined { + let capturedItems: readonly (IContextMenuValue | string | null)[] | undefined + let capturedOptions: any + + // 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?: any + ) { + // Capture both items and options + capturedItems = items + capturedOptions = options + // Return a minimal mock object to prevent errors + return { close: () => {}, root: document.createElement('div') } as any + } as any + + // 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 any + + // Call the callback which should trigger ContextMenu constructor + // Callback signature varies, but typically: (value, options, event, menu, node) + void item.callback?.call( + item as any, + item.value, + {}, + mockEvent as any, + 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?: any +): 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, + null, + null, + 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 any, + item.value, + {}, + null as any, + null as any, + item as any + ) + } 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 + * LiteGraph menu items often include HTML for styling + */ +function stripHtmlTags(html: string): string { + // Create a temporary element to parse HTML + const temp = document.createElement('div') + temp.innerHTML = html + return temp.textContent || temp.innerText || html +} diff --git a/src/composables/graph/useMoreOptionsMenu.ts b/src/composables/graph/useMoreOptionsMenu.ts index 45ea6eb1f2..5c9b5d1f62 100644 --- a/src/composables/graph/useMoreOptionsMenu.ts +++ b/src/composables/graph/useMoreOptionsMenu.ts @@ -2,8 +2,13 @@ import { computed, ref } from 'vue' import type { Ref } from 'vue' import type { LGraphGroup } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { isLGraphGroup } from '@/utils/litegraphUtil' +import { + buildStructuredMenu, + convertContextMenuToOptions +} from './contextMenuConverter' import { useGroupMenuOptions } from './useGroupMenuOptions' import { useImageMenuOptions } from './useImageMenuOptions' import { useNodeMenuOptions } from './useNodeMenuOptions' @@ -15,10 +20,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 +34,7 @@ export interface SubMenuOption { icon?: string action: () => void color?: string + disabled?: boolean } export enum BadgeVariant { @@ -74,6 +83,19 @@ export function registerNodeOptionsInstance( nodeOptionsInstance = instance } +/** + * Mark menu options as coming from Vue hardcoded menu + */ +function markAsVueOptions(options: MenuOption[]): MenuOption[] { + return options.map((opt) => { + // Don't mark dividers or category labels + if (opt.type === 'divider' || opt.type === 'category') { + return opt + } + return { ...opt, source: 'vue' } + }) +} + /** * Composable for managing the More Options menu configuration * Refactored to use smaller, focused composables for better maintainability @@ -91,10 +113,11 @@ export function useMoreOptionsMenu() { computeSelectionFlags } = useSelectionState() + const canvasStore = useCanvasStore() + const { getImageMenuOptions } = useImageMenuOptions() const { getNodeInfoOption, - getAdjustSizeOption, getNodeVisualOptions, getPinOption, getBypassOption, @@ -102,16 +125,13 @@ export function useMoreOptionsMenu() { } = useNodeMenuOptions() const { getFitGroupToNodesOption, - getGroupShapeOptions, getGroupColorOptions, getGroupModeOptions } = useGroupMenuOptions() const { getBasicSelectionOptions, getSubgraphOptions, - getMultipleNodesOptions, - getDeleteOption, - getAlignmentOptions + getMultipleNodesOptions } = useSelectionMenuOptions() const hasSubgraphs = hasSubgraphsComputed @@ -138,80 +158,109 @@ export function useMoreOptionsMenu() { ? selectedGroups[0] : null const hasSubgraphsSelected = hasSubgraphs.value + + // For single node selection, also get LiteGraph menu items to merge + const litegraphOptions: MenuOption[] = [] + if ( + selectedNodes.value.length === 1 && + !groupContext && + canvasStore.canvas + ) { + try { + const node = selectedNodes.value[0] + const rawItems = canvasStore.canvas.getNodeMenuOptions(node) + // Don't apply structuring yet - we'll do it after merging with Vue options + litegraphOptions.push( + ...convertContextMenuToOptions(rawItems, node, false) + ) + } catch (error) { + console.error('Error getting LiteGraph menu items:', error) + } + } + const options: MenuOption[] = [] // Section 1: Basic selection operations (Rename, Copy, Duplicate) - options.push(...getBasicSelectionOptions()) + const basicOps = getBasicSelectionOptions() + options.push(...basicOps) options.push({ type: 'divider' }) - // Section 2: Node Info & Size Adjustment - if (nodeDef.value) { - options.push(getNodeInfoOption(showNodeHelp)) + // Section 2: Node actions (Run Branch, Pin, Bypass, Mute) + if (hasOutputNodesSelected.value) { + const runBranch = getRunBranchOption() + options.push(runBranch) } - - if (groupContext) { - options.push(getFitGroupToNodesOption(groupContext)) - } else { - options.push(getAdjustSizeOption()) + if (!groupContext) { + const pin = getPinOption(states, bump) + const bypass = getBypassOption(states, bump) + options.push(pin) + options.push(bypass) } - - // Section 3: Collapse/Shape/Color if (groupContext) { - // Group context: Shape, Color, Divider - options.push(getGroupShapeOptions(groupContext, bump)) - options.push(getGroupColorOptions(groupContext, bump)) - options.push({ type: 'divider' }) - } else { - // Node context: Expand/Minimize, Shape, Color, Divider - options.push(...getNodeVisualOptions(states, bump)) - options.push({ type: 'divider' }) + const groupModes = getGroupModeOptions(groupContext, bump) + options.push(...groupModes) } - - // Section 4: Image operations (if image node) - if (hasImageNode.value && selectedNodes.value.length > 0) { - options.push(...getImageMenuOptions(selectedNodes.value[0])) - } - - // Section 5: Subgraph operations - options.push(...getSubgraphOptions(hasSubgraphsSelected)) - - // Section 6: Multiple nodes operations - if (hasMultipleNodes.value) { - options.push(...getMultipleNodesOptions()) - } - - // Section 7: Divider options.push({ type: 'divider' }) - // Section 8: Pin/Unpin (non-group only) - if (!groupContext) { - options.push(getPinOption(states, bump)) - } - - // Section 9: Alignment (if multiple nodes) + // Section 3: Structure operations (Convert to Subgraph, Frame selection, Minimize Node) + const subgraphOps = getSubgraphOptions(hasSubgraphsSelected) + options.push(...subgraphOps) if (hasMultipleNodes.value) { - options.push(...getAlignmentOptions()) + const multiOps = getMultipleNodesOptions() + options.push(...multiOps) } - - // Section 10: Mode operations if (groupContext) { - // Group mode operations - options.push(...getGroupModeOptions(groupContext, bump)) + const fitGroup = getFitGroupToNodesOption(groupContext) + options.push(fitGroup) } else { - // Bypass option for nodes - options.push(getBypassOption(states, bump)) + // Add minimize/expand option only + const visualOptions = getNodeVisualOptions(states, bump) + if (visualOptions.length > 0) { + options.push(visualOptions[0]) // Minimize/Expand + } } + options.push({ type: 'divider' }) - // Section 11: Run Branch (if output nodes) - if (hasOutputNodesSelected.value) { - options.push(getRunBranchOption()) + // Section 4: Node properties (Node Info, Color) + if (nodeDef.value) { + const nodeInfo = getNodeInfoOption(showNodeHelp) + options.push(nodeInfo) + } + if (groupContext) { + const groupColor = getGroupColorOptions(groupContext, bump) + options.push(groupColor) + } else { + // Add shape and color options + const visualOptions = getNodeVisualOptions(states, bump) + if (visualOptions.length > 1) { + options.push(visualOptions[1]) // Shape (index 1) + } + if (visualOptions.length > 2) { + options.push(visualOptions[2]) // Color (index 2) + } } - - // Section 12: Final divider and Delete options.push({ type: 'divider' }) - options.push(getDeleteOption()) - return options + // Section 5: Node-specific options (image operations) + if (hasImageNode.value && selectedNodes.value.length > 0) { + const imageOps = getImageMenuOptions(selectedNodes.value[0]) + options.push(...imageOps) + options.push({ type: 'divider' }) + } + // Section 6 & 7: Extensions and Delete are handled by buildStructuredMenu + + // Mark all Vue options with source + const markedVueOptions = markAsVueOptions(options) + // For single node selection, merge LiteGraph options with Vue options + // Vue options will take precedence during deduplication in buildStructuredMenu + if (litegraphOptions.length > 0) { + // Merge: LiteGraph options first, then Vue options (Vue will win in dedup) + const merged = [...litegraphOptions, ...markedVueOptions] + return buildStructuredMenu(merged) + } + // For other cases, structure the Vue options + const result = buildStructuredMenu(markedVueOptions) + return result }) // Computed property to get only menu items with submenus diff --git a/src/composables/graph/useNodeMenuOptions.ts b/src/composables/graph/useNodeMenuOptions.ts index c1d291a4d8..7e8cdfcf4e 100644 --- a/src/composables/graph/useNodeMenuOptions.ts +++ b/src/composables/graph/useNodeMenuOptions.ts @@ -73,6 +73,7 @@ export function useNodeMenuOptions() { icon: 'icon-[lucide--palette]', hasSubmenu: true, submenu: colorSubmenu.value, + isColorPicker: true, action: () => {} } ] @@ -96,7 +97,7 @@ export function useNodeMenuOptions() { label: states.bypassed ? t('contextMenu.Remove Bypass') : t('contextMenu.Bypass'), - icon: states.bypassed ? 'icon-[lucide--zap-off]' : 'icon-[lucide--ban]', + icon: 'icon-[lucide--redo-dot]', shortcut: 'Ctrl+B', action: () => { toggleNodeBypass() diff --git a/src/composables/graph/useSubmenuPositioning.ts b/src/composables/graph/useSubmenuPositioning.ts deleted file mode 100644 index 2dda2bd1cf..0000000000 --- a/src/composables/graph/useSubmenuPositioning.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { nextTick } from 'vue' - -import type { MenuOption } from './useMoreOptionsMenu' - -/** - * Composable for handling submenu positioning logic - */ -export function useSubmenuPositioning() { - /** - * Toggle submenu visibility with proper positioning - * @param option - Menu option with submenu - * @param event - Click event - * @param submenu - PrimeVue Popover reference - * @param currentSubmenu - Currently open submenu name - * @param menuOptionsWithSubmenu - All menu options with submenus - * @param submenuRefs - References to all submenu popovers - */ - const toggleSubmenu = async ( - option: MenuOption, - event: Event, - submenu: any, // Component instance with show/hide methods - currentSubmenu: { value: string | null }, - menuOptionsWithSubmenu: MenuOption[], - submenuRefs: Record // Component instances - ): Promise => { - if (!option.label || !option.hasSubmenu) return - - // Check if this submenu is currently open - const isCurrentlyOpen = currentSubmenu.value === option.label - - // Hide all submenus first - menuOptionsWithSubmenu.forEach((opt) => { - const sm = submenuRefs[`submenu-${opt.label}`] - if (sm) { - sm.hide() - } - }) - currentSubmenu.value = null - - // If it wasn't open before, show it now - if (!isCurrentlyOpen) { - currentSubmenu.value = option.label - await nextTick() - - const menuItem = event.currentTarget as HTMLElement - const menuItemRect = menuItem.getBoundingClientRect() - - // Find the parent popover content element that contains this menu item - const mainPopoverContent = menuItem.closest( - '[data-pc-section="content"]' - ) as HTMLElement - - if (mainPopoverContent) { - const mainPopoverRect = mainPopoverContent.getBoundingClientRect() - - // Create a temporary positioned element as the target - const tempTarget = createPositionedTarget( - mainPopoverRect.right + 8, - menuItemRect.top, - `submenu-target-${option.label}` - ) - - // Create event using the temp target - const tempEvent = createMouseEvent( - mainPopoverRect.right + 8, - menuItemRect.top - ) - - // Show submenu relative to temp target - submenu.show(tempEvent, tempTarget) - - // Clean up temp target after a delay - cleanupTempTarget(tempTarget, 100) - } else { - // Fallback: position to the right of the menu item - const tempTarget = createPositionedTarget( - menuItemRect.right + 8, - menuItemRect.top, - `submenu-fallback-target-${option.label}` - ) - - // Create event using the temp target - const tempEvent = createMouseEvent( - menuItemRect.right + 8, - menuItemRect.top - ) - - // Show submenu relative to temp target - submenu.show(tempEvent, tempTarget) - - // Clean up temp target after a delay - cleanupTempTarget(tempTarget, 100) - } - } - } - - /** - * Create a temporary positioned DOM element for submenu targeting - */ - const createPositionedTarget = ( - left: number, - top: number, - id: string - ): HTMLElement => { - const tempTarget = document.createElement('div') - tempTarget.style.position = 'absolute' - tempTarget.style.left = `${left}px` - tempTarget.style.top = `${top}px` - tempTarget.style.width = '1px' - tempTarget.style.height = '1px' - tempTarget.style.pointerEvents = 'none' - tempTarget.style.visibility = 'hidden' - tempTarget.id = id - - document.body.appendChild(tempTarget) - return tempTarget - } - - /** - * Create a mouse event with specific coordinates - */ - const createMouseEvent = (clientX: number, clientY: number): MouseEvent => { - return new MouseEvent('click', { - bubbles: true, - cancelable: true, - clientX, - clientY - }) - } - - /** - * Clean up temporary target element after delay - */ - const cleanupTempTarget = (target: HTMLElement, delay: number): void => { - setTimeout(() => { - if (target.parentNode) { - target.parentNode.removeChild(target) - } - }, delay) - } - - /** - * Hide all submenus - */ - const hideAllSubmenus = ( - menuOptionsWithSubmenu: MenuOption[], - submenuRefs: Record, // Component instances - currentSubmenu: { value: string | null } - ): void => { - menuOptionsWithSubmenu.forEach((option) => { - const submenu = submenuRefs[`submenu-${option.label}`] - if (submenu) { - submenu.hide() - } - }) - currentSubmenu.value = null - } - - return { - toggleSubmenu, - hideAllSubmenus - } -} diff --git a/src/composables/useContextMenuTranslation.ts b/src/composables/useContextMenuTranslation.ts index cfcf5c809f..a85cccdc07 100644 --- a/src/composables/useContextMenuTranslation.ts +++ b/src/composables/useContextMenuTranslation.ts @@ -22,7 +22,10 @@ export const useContextMenuTranslation = () => { this: LGraphCanvas, ...args: Parameters ) { - 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 ) { - 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 diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index d1d2062c94..73c2436940 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -712,8 +712,8 @@ export class LGraphCanvas getMenuOptions?(): IContextMenuValue[] getExtraMenuOptions?( canvas: LGraphCanvas, - options: IContextMenuValue[] - ): IContextMenuValue[] + options: (IContextMenuValue | null)[] + ): (IContextMenuValue | null)[] static active_node: LGraphNode /** called before modifying the graph */ onBeforeChange?(graph: LGraph): void @@ -8020,8 +8020,8 @@ export class LGraphCanvas } } - getCanvasMenuOptions(): IContextMenuValue[] { - let options: IContextMenuValue[] + getCanvasMenuOptions(): (IContextMenuValue | null)[] { + let options: (IContextMenuValue | null)[] if (this.getMenuOptions) { options = this.getMenuOptions() } else { diff --git a/src/lib/litegraph/src/contextMenuCompat.ts b/src/lib/litegraph/src/contextMenuCompat.ts index 5d2cd09ad5..38865bf206 100644 --- a/src/lib/litegraph/src/contextMenuCompat.ts +++ b/src/lib/litegraph/src/contextMenuCompat.ts @@ -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() @@ -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( + 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( + 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(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 [] diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 1d2e143657..a6b8c98a2a 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -126,6 +126,7 @@ "search": "Search", "searchPlaceholder": "Search...", "noResultsFound": "No Results Found", + "noResults": "No Results", "searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.", "noTasksFound": "No Tasks Found", "noTasksFoundMessage": "There are no tasks in the queue.", @@ -438,7 +439,8 @@ "Horizontal": "Horizontal", "Vertical": "Vertical", "new": "new", - "deprecated": "deprecated" + "deprecated": "deprecated", + "Extensions": "Extensions" }, "icon": { "bookmark": "Bookmark",