diff --git a/src/components/graph/selectionToolbox/MenuOptionItem.vue b/src/components/graph/selectionToolbox/MenuOptionItem.vue index ead8fb1837..432e8e2390 100644 --- a/src/components/graph/selectionToolbox/MenuOptionItem.vue +++ b/src/components/graph/selectionToolbox/MenuOptionItem.vue @@ -1,16 +1,27 @@ diff --git a/src/composables/graph/contextMenuConverter.ts b/src/composables/graph/contextMenuConverter.ts new file mode 100644 index 0000000000..f6d76db775 --- /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 MaskEditor', + '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 MaskEditor', + '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..944cff8012 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,12 @@ 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' } export interface SubMenuOption { @@ -26,6 +33,7 @@ export interface SubMenuOption { icon?: string action: () => void color?: string + disabled?: boolean } export enum BadgeVariant { @@ -74,6 +82,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 +112,11 @@ export function useMoreOptionsMenu() { computeSelectionFlags } = useSelectionState() + const canvasStore = useCanvasStore() + const { getImageMenuOptions } = useImageMenuOptions() const { getNodeInfoOption, - getAdjustSizeOption, getNodeVisualOptions, getPinOption, getBypassOption, @@ -102,16 +124,13 @@ export function useMoreOptionsMenu() { } = useNodeMenuOptions() const { getFitGroupToNodesOption, - getGroupShapeOptions, getGroupColorOptions, getGroupModeOptions } = useGroupMenuOptions() const { getBasicSelectionOptions, getSubgraphOptions, - getMultipleNodesOptions, - getDeleteOption, - getAlignmentOptions + getMultipleNodesOptions } = useSelectionMenuOptions() const hasSubgraphs = hasSubgraphsComputed @@ -138,80 +157,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..54047234b7 100644 --- a/src/composables/graph/useNodeMenuOptions.ts +++ b/src/composables/graph/useNodeMenuOptions.ts @@ -96,7 +96,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/useViewportAwareMenuPositioning.ts b/src/composables/graph/useViewportAwareMenuPositioning.ts new file mode 100644 index 0000000000..d1f2e5d1de --- /dev/null +++ b/src/composables/graph/useViewportAwareMenuPositioning.ts @@ -0,0 +1,81 @@ +interface MenuPositionStyle { + position: 'fixed' + left: string + top?: string + bottom?: string + transform: string +} + +interface MenuPositionOptions { + /** The trigger element that opened the menu */ + triggerRect: DOMRect + /** The menu overlay element */ + menuElement: HTMLElement + /** Whether the menu was triggered by the toolbox button */ + isTriggeredByToolbox: boolean + /** Margin from trigger element */ + marginY?: number +} + +/** + * Calculates viewport-aware menu positioning that prevents overflow. + * When a menu would overflow the bottom of the viewport, it docks to the bottom instead. + * + * @returns Positioning style properties to apply to the menu element + */ +export function calculateMenuPosition( + options: MenuPositionOptions +): MenuPositionStyle { + const { + triggerRect, + menuElement, + isTriggeredByToolbox, + marginY = 8 + } = options + + // Calculate horizontal position (same as before) + const left = isTriggeredByToolbox + ? triggerRect.left + triggerRect.width / 2 + : triggerRect.right - triggerRect.width / 4 + + // Calculate initial top position + const initialTop = isTriggeredByToolbox + ? triggerRect.bottom + marginY + : triggerRect.top - marginY - 6 + + // Get menu dimensions + const menuHeight = menuElement.offsetHeight || menuElement.scrollHeight + const viewportHeight = window.innerHeight + + // Calculate available space below the trigger point + const spaceBelow = viewportHeight - initialTop + + // Check if menu would overflow viewport bottom + const wouldOverflow = menuHeight > spaceBelow + + const baseStyle: MenuPositionStyle = { + position: 'fixed', + left: `${left}px`, + transform: 'translate(-50%, 0)' + } + + if (triggerRect.top < 0) { + // Dock to top of viewport if node is above + return { + ...baseStyle, + top: '0px' + } + } else if (wouldOverflow) { + // Dock to bottom of viewport + return { + ...baseStyle, + bottom: '0px' + } + } else { + // Position below trigger as normal + return { + ...baseStyle, + top: `${initialTop}px` + } + } +} 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 70359197e8..430c65a645 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -711,8 +711,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 @@ -8009,8 +8009,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..1a5561da25 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', @@ -95,7 +109,7 @@ class LegacyMenuCompat { methodName: keyof LGraphCanvas, context: LGraphCanvas, ...args: unknown[] - ): IContextMenuValue[] { + ): (IContextMenuValue | null)[] { if (!ENABLE_LEGACY_SUPPORT) return [] if (this.isExtracting) return [] @@ -106,7 +120,7 @@ class LegacyMenuCompat { this.isExtracting = true const originalItems = originalMethod.apply(context, args) as - | IContextMenuValue[] + | (IContextMenuValue | null)[] | undefined if (!originalItems) return [] @@ -127,12 +141,14 @@ 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[] + return patchedItems.slice( + originalItems.length + ) as (IContextMenuValue | null)[] } return [] diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 77a087b141..f4240c58e0 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -124,6 +124,7 @@ "searchExtensions": "Search Extensions", "search": "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.", @@ -427,7 +428,8 @@ "Horizontal": "Horizontal", "Vertical": "Vertical", "new": "new", - "deprecated": "deprecated" + "deprecated": "deprecated", + "Extensions": "Extensions" }, "icon": { "bookmark": "Bookmark",