From 1809effec580031e8f62de31548bd8f5c1f5c0d4 Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Wed, 29 Oct 2025 05:39:25 +0100 Subject: [PATCH 01/24] right click vue nodes with contextmenu WIP --- src/composables/graph/contextMenuConverter.ts | 121 ++++++++++++++++++ src/composables/graph/useMoreOptionsMenu.ts | 23 ++++ src/composables/useContextMenuTranslation.ts | 20 +++ 3 files changed, 164 insertions(+) create mode 100644 src/composables/graph/contextMenuConverter.ts diff --git a/src/composables/graph/contextMenuConverter.ts b/src/composables/graph/contextMenuConverter.ts new file mode 100644 index 0000000000..16a7566fd8 --- /dev/null +++ b/src/composables/graph/contextMenuConverter.ts @@ -0,0 +1,121 @@ +import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph' + +import type { MenuOption, SubMenuOption } from './useMoreOptionsMenu' + +/** + * Convert LiteGraph IContextMenuValue items to Vue MenuOption format + * Used to bridge LiteGraph context menus into Vue node menus + */ +export function convertContextMenuToOptions( + items: (IContextMenuValue | null)[] +): 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 + } + + const option: MenuOption = { + label: item.content + } + + // Handle disabled state by wrapping callback or not providing action + if (item.disabled) { + // For disabled items, we still provide an action that does nothing + // so the UI can show it as disabled + option.action = () => { + // Do nothing - item is disabled + } + } else if (item.callback) { + // Wrap the callback to match the () => void signature + option.action = () => { + try { + 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) + } + } + } + + // Handle submenus + if (item.has_submenu && item.submenu?.options) { + option.hasSubmenu = true + option.submenu = convertSubmenuToOptions(item.submenu.options) + } + + result.push(option) + } + + return result +} + +/** + * Convert LiteGraph submenu items to Vue SubMenuOption format + */ +function convertSubmenuToOptions( + items: readonly (IContextMenuValue | string | null)[] +): SubMenuOption[] { + const result: SubMenuOption[] = [] + + for (const item of items) { + // Skip null separators and string items + if (!item || typeof item === 'string') continue + + if (!item.content) continue + + const subOption: SubMenuOption = { + label: item.content, + action: () => { + try { + 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) + } + } + } + + result.push(subOption) + } + + return result +} + +/** + * Check if a menu option already exists in the list by label + */ +export function menuOptionExists( + options: MenuOption[], + label: string +): boolean { + return options.some((opt) => opt.label === label) +} + +/** + * Filter out duplicate menu items based on label + * Keeps the first occurrence of each label + */ +export function removeDuplicateMenuOptions( + options: MenuOption[] +): MenuOption[] { + const seen = new Set() + return options.filter((opt) => { + // Always keep dividers + if (opt.type === 'divider') return true + + // Skip items without labels + if (!opt.label) return true + + // Filter duplicates + if (seen.has(opt.label)) return false + seen.add(opt.label) + return true + }) +} diff --git a/src/composables/graph/useMoreOptionsMenu.ts b/src/composables/graph/useMoreOptionsMenu.ts index 45ea6eb1f2..08ca7d4398 100644 --- a/src/composables/graph/useMoreOptionsMenu.ts +++ b/src/composables/graph/useMoreOptionsMenu.ts @@ -2,8 +2,10 @@ 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 { convertContextMenuToOptions } from './contextMenuConverter' import { useGroupMenuOptions } from './useGroupMenuOptions' import { useImageMenuOptions } from './useImageMenuOptions' import { useNodeMenuOptions } from './useNodeMenuOptions' @@ -91,6 +93,8 @@ export function useMoreOptionsMenu() { computeSelectionFlags } = useSelectionState() + const canvasStore = useCanvasStore() + const { getImageMenuOptions } = useImageMenuOptions() const { getNodeInfoOption, @@ -138,6 +142,25 @@ export function useMoreOptionsMenu() { ? selectedGroups[0] : null const hasSubgraphsSelected = hasSubgraphs.value + + // For single node selection, use LiteGraph menu as the primary source + if ( + selectedNodes.value.length === 1 && + !groupContext && + canvasStore.canvas + ) { + try { + const node = selectedNodes.value[0] + const rawItems = canvasStore.canvas.getNodeMenuOptions(node) + const options = convertContextMenuToOptions(rawItems) + return options + } catch (error) { + console.error('Error getting LiteGraph menu items:', error) + // Fall through to Vue menu as fallback + } + } + + // For other cases (groups, multiple selections), build Vue menu const options: MenuOption[] = [] // Section 1: Basic selection operations (Rename, Copy, Duplicate) diff --git a/src/composables/useContextMenuTranslation.ts b/src/composables/useContextMenuTranslation.ts index cfcf5c809f..7a36b2fb7e 100644 --- a/src/composables/useContextMenuTranslation.ts +++ b/src/composables/useContextMenuTranslation.ts @@ -58,6 +58,9 @@ 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 ( @@ -73,11 +76,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 as (...args: unknown[]) => IContextMenuValue[], + nodeMenuFn as (...args: unknown[]) => IContextMenuValue[], + LGraphCanvas.prototype + ) + function translateMenus( values: readonly (IContextMenuValue | string | null)[] | undefined, options: IContextMenuOptions From 521b672038e102dcf18bb4c7ac71e89d695df61d Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Fri, 31 Oct 2025 04:09:25 +0100 Subject: [PATCH 02/24] [feat] Add disabled state support for context menu items - Add disabled property to MenuOption and SubMenuOption interfaces - Apply semantic token styling for disabled items (text-node-icon-disabled) - Prevent interactions on disabled menu and submenu items - Use cursor-not-allowed and pointer-events-none for disabled state - Pass through disabled flag from LiteGraph menu items to Vue components --- .../graph/selectionToolbox/MenuOptionItem.vue | 10 +++++- .../graph/selectionToolbox/SubmenuPopover.vue | 16 +++++++-- src/composables/graph/contextMenuConverter.ts | 36 ++++++++++++++----- src/composables/graph/useMoreOptionsMenu.ts | 2 ++ src/composables/useContextMenuTranslation.ts | 4 ++- 5 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/components/graph/selectionToolbox/MenuOptionItem.vue b/src/components/graph/selectionToolbox/MenuOptionItem.vue index fb1f8c01a5..4de71b997b 100644 --- a/src/components/graph/selectionToolbox/MenuOptionItem.vue +++ b/src/components/graph/selectionToolbox/MenuOptionItem.vue @@ -6,7 +6,12 @@
@@ -57,6 +62,9 @@ const props = defineProps() const emit = defineEmits() const handleClick = (event: Event) => { + if (props.option.disabled) { + return + } emit('click', props.option, event) } diff --git a/src/components/graph/selectionToolbox/SubmenuPopover.vue b/src/components/graph/selectionToolbox/SubmenuPopover.vue index 12c1a8571a..2b4ccca149 100644 --- a/src/components/graph/selectionToolbox/SubmenuPopover.vue +++ b/src/components/graph/selectionToolbox/SubmenuPopover.vue @@ -19,9 +19,15 @@ v-for="subOption in option.submenu" :key="subOption.label" :class=" - isColorSubmenu - ? 'w-7 h-7 flex items-center justify-center hover:bg-smoke-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer' - : 'flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-smoke-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer' + cn( + 'flex items-center rounded', + isColorSubmenu + ? 'w-7 h-7 justify-center' + : 'gap-2 px-3 py-1.5 text-sm', + subOption.disabled + ? 'cursor-not-allowed pointer-events-none text-node-icon-disabled' + : 'hover:bg-smoke-100 dark-theme:hover:bg-zinc-700 cursor-pointer' + ) " :title="subOption.label" @click="handleSubmenuClick(subOption)" @@ -53,6 +59,7 @@ import type { SubMenuOption } from '@/composables/graph/useMoreOptionsMenu' import { useNodeCustomization } from '@/composables/graph/useNodeCustomization' +import { cn } from '@/utils/tailwindUtil' interface Props { option: MenuOption @@ -83,6 +90,9 @@ defineExpose({ }) const handleSubmenuClick = (subOption: SubMenuOption) => { + if (subOption.disabled) { + return + } emit('submenu-click', subOption) } diff --git a/src/composables/graph/contextMenuConverter.ts b/src/composables/graph/contextMenuConverter.ts index 16a7566fd8..a988053bab 100644 --- a/src/composables/graph/contextMenuConverter.ts +++ b/src/composables/graph/contextMenuConverter.ts @@ -27,18 +27,24 @@ export function convertContextMenuToOptions( label: item.content } - // Handle disabled state by wrapping callback or not providing action + // Pass through disabled state if (item.disabled) { - // For disabled items, we still provide an action that does nothing - // so the UI can show it as disabled - option.action = () => { - // Do nothing - item is disabled - } - } else if (item.callback) { + option.disabled = true + } + + // Handle callback (only if not disabled) + if (item.callback && !item.disabled) { // Wrap the callback to match the () => void signature option.action = () => { try { - item.callback?.call(item as any, item.value, {}, null as any, null as any, item as any) + 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) } @@ -75,13 +81,25 @@ function convertSubmenuToOptions( label: item.content, action: () => { try { - item.callback?.call(item as any, item.value, {}, null as any, null as any, item as any) + 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) } diff --git a/src/composables/graph/useMoreOptionsMenu.ts b/src/composables/graph/useMoreOptionsMenu.ts index 08ca7d4398..be616088bc 100644 --- a/src/composables/graph/useMoreOptionsMenu.ts +++ b/src/composables/graph/useMoreOptionsMenu.ts @@ -21,6 +21,7 @@ export interface MenuOption { action?: () => void submenu?: SubMenuOption[] badge?: BadgeVariant + disabled?: boolean } export interface SubMenuOption { @@ -28,6 +29,7 @@ export interface SubMenuOption { icon?: string action: () => void color?: string + disabled?: boolean } export enum BadgeVariant { diff --git a/src/composables/useContextMenuTranslation.ts b/src/composables/useContextMenuTranslation.ts index 7a36b2fb7e..009ca3992a 100644 --- a/src/composables/useContextMenuTranslation.ts +++ b/src/composables/useContextMenuTranslation.ts @@ -93,7 +93,9 @@ export const useContextMenuTranslation = () => { legacyMenuCompat.registerWrapper( 'getNodeMenuOptions', - getNodeMenuOptionsWithExtensions as (...args: unknown[]) => IContextMenuValue[], + getNodeMenuOptionsWithExtensions as ( + ...args: unknown[] + ) => IContextMenuValue[], nodeMenuFn as (...args: unknown[]) => IContextMenuValue[], LGraphCanvas.prototype ) From b31e8d0d1bb82367bb371b4cd92fcd7f8e570c28 Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Tue, 11 Nov 2025 10:02:25 +0100 Subject: [PATCH 03/24] feat: add Extensions translation key to context menu Add translation key for the Extensions section label in the context menu. This allows the Extensions category header to be properly localized. --- src/locales/en/main.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 5bc2d9ca83..e9dbeb8522 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -419,7 +419,8 @@ "Horizontal": "Horizontal", "Vertical": "Vertical", "new": "new", - "deprecated": "deprecated" + "deprecated": "deprecated", + "Extensions": "Extensions" }, "icon": { "bookmark": "Bookmark", From 5aa63883aa3ae2ad85ae93dbc1e81d368d60adfd Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Tue, 11 Nov 2025 10:05:03 +0100 Subject: [PATCH 04/24] feat: add category type support to menu items Add support for non-clickable category labels in the context menu. Category items are displayed as uppercase labels with secondary text color and proper translation support via contextMenu namespace. This enables grouping menu items under section headers like "Extensions". --- src/components/graph/selectionToolbox/MenuOptionItem.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/graph/selectionToolbox/MenuOptionItem.vue b/src/components/graph/selectionToolbox/MenuOptionItem.vue index 4de71b997b..e6909737dc 100644 --- a/src/components/graph/selectionToolbox/MenuOptionItem.vue +++ b/src/components/graph/selectionToolbox/MenuOptionItem.vue @@ -3,6 +3,12 @@ v-if="option.type === 'divider'" class="my-1 h-px bg-smoke-200 dark-theme:bg-zinc-700" /> +
+ {{ t(`contextMenu.${option.label || ''}`) }} +
Date: Tue, 11 Nov 2025 10:08:54 +0100 Subject: [PATCH 05/24] feat: implement menu ordering and extension categorization system Add comprehensive menu ordering system to ensure consistent context menu layout: - Define MENU_ORDER constant with 5 logical sections - Implement automatic section-based divider insertion - Separate core items from extension items - Add blacklist for unwanted duplicate items (Colors, Shapes, Title, Mode, etc.) - Preserve Vue hardcoded options over LiteGraph items during deduplication - Add source tracking (litegraph vs vue) for precedence handling Menu structure: 1. Basic operations (Rename, Copy, Duplicate) 2. Node actions (Run Branch, Pin, Bypass, Mute) 3. Structure operations (Convert to Subgraph, Frame selection, Minimize Node) 4. Node properties (Node Info, Color) 5. Node-specific operations (Image operations) 6. Extensions section (non-core items) 7. Delete (always at bottom) The system ensures proper ordering regardless of whether items come from LiteGraph or Vue hardcoded menus, with Extensions clearly separated. --- src/composables/graph/contextMenuConverter.ts | 696 +++++++++++++++++- 1 file changed, 659 insertions(+), 37 deletions(-) diff --git a/src/composables/graph/contextMenuConverter.ts b/src/composables/graph/contextMenuConverter.ts index a988053bab..20a0c25a5e 100644 --- a/src/composables/graph/contextMenuConverter.ts +++ b/src/composables/graph/contextMenuConverter.ts @@ -1,14 +1,423 @@ +import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph' import type { MenuOption, SubMenuOption } from './useMoreOptionsMenu' +// Debug logging flag - set to true to enable detailed logging +const DEBUG = false + +function debug(...args: unknown[]) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log(...args) + } +} + +/** + * 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', + // 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 + 'Resize', + 'Clone', + '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[] { + /* eslint-disable no-console */ + console.log('[Structure] Input options:', options.length) + console.log( + '[Structure] Input items:', + options.map((o) => o.label || o.type) + ) + + // First, remove duplicates (giving precedence to Vue hardcoded options) + const deduplicated = removeDuplicateMenuOptions(options) + console.log('[Structure] After deduplication:', deduplicated.length) + console.log( + '[Structure] Deduplicated items:', + deduplicated.map((o) => o.label || o.type) + ) + + 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) { + console.log('[Structure] Found Delete item:', option.label) + deleteItem = option + continue + } + + // Categorize based on label + if (option.label && isCoreMenuItem(option.label)) { + console.log('[Structure] Core item:', option.label) + coreItemsMap.set(option.label, option) + } else { + console.log('[Structure] Extension item:', option.label || '(no label)') + extensionItems.push(option) + } + } + + console.log('[Structure] Core items:', coreItemsMap.size) + console.log('[Structure] Extension items:', extensionItems.length) + console.log('[Structure] Delete item:', deleteItem?.label || 'none') + + // 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)) + + console.log('[Structure] Ordered core labels:', coreLabels) + + // 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-13 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse) + // Section 4: 14-15 (Node Info, Color) + // Section 5: 16+ (Image operations and fallback items) + const getSectionNumber = (index: number): number => { + if (index <= 2) return 1 + if (index <= 8) return 2 + if (index <= 13) return 3 + if (index <= 15) 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) { + console.log( + `[Structure] Adding divider between section ${lastSection} and ${currentSection}` + ) + orderedCoreItems.push({ type: 'divider' }) + } + + orderedCoreItems.push(item) + lastSection = currentSection + } + + console.log( + '[Structure] Ordered core items:', + orderedCoreItems.map((o) => o.label || o.type) + ) + + // 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) { + console.log('[Structure] Adding Extensions section') + // 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) { + console.log('[Structure] Adding Delete at bottom') + result.push({ type: 'divider' }) + result.push(deleteItem) + } + + console.log('[Structure] Final result:', result.length) + console.log( + '[Structure] Final items:', + result.map((o) => o.label || o.type) + ) + /* eslint-enable no-console */ + + 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)[] + items: (IContextMenuValue | null)[], + node?: any, + applyStructuring: boolean = true ): MenuOption[] { + debug( + '[ContextMenuConverter] Converting context menu with', + items.length, + 'items' + ) + debug('[ContextMenuConverter] Items:', items) + debug('[ContextMenuConverter] Node context:', node) + const result: MenuOption[] = [] for (const item of items) { @@ -23,8 +432,28 @@ export function convertContextMenuToOptions( continue } + // Skip hard blacklisted items + if (HARD_BLACKLIST.has(item.content)) { + debug( + '[ContextMenuConverter] Skipping hard blacklisted item:', + item.content + ) + continue + } + + // Skip if a similar item already exists in results + if (isDuplicateItem(item.content, result)) { + debug( + '[ContextMenuConverter] Skipping duplicate item:', + item.content, + '(similar item already exists)' + ) + continue + } + const option: MenuOption = { - label: item.content + label: item.content, + source: 'litegraph' } // Pass through disabled state @@ -32,8 +461,43 @@ export function convertContextMenuToOptions( option.disabled = true } - // Handle callback (only if not disabled) - if (item.callback && !item.disabled) { + // Handle submenus + if (item.has_submenu) { + // Static submenu with pre-defined options + if (item.submenu?.options) { + debug('[ContextMenuConverter] Static submenu detected:', item.content) + option.hasSubmenu = true + option.submenu = convertSubmenuToOptions(item.submenu.options) + } + // Dynamic submenu - callback creates it on-demand + else if (item.callback && !item.disabled) { + debug( + '[ContextMenuConverter] Dynamic submenu detected:', + item.content, + 'callback:', + item.callback.name + ) + option.hasSubmenu = true + // Intercept the callback to capture dynamic submenu items + const capturedSubmenu = captureDynamicSubmenu(item, node) + if (capturedSubmenu) { + debug( + '[ContextMenuConverter] Captured submenu items:', + capturedSubmenu.length, + 'items for', + item.content + ) + 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 { @@ -51,36 +515,200 @@ export function convertContextMenuToOptions( } } - // Handle submenus - if (item.has_submenu && item.submenu?.options) { - option.hasSubmenu = true - option.submenu = convertSubmenuToOptions(item.submenu.options) - } - 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 { + debug( + '[ContextMenuConverter] Starting capture for:', + item.content, + 'item:', + item, + 'node:', + node + ) + + 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 + ) { + debug( + '[ContextMenuConverter] ContextMenu constructor called with:', + items.length, + 'items' + ) + debug('[ContextMenuConverter] Raw items:', items) + debug('[ContextMenuConverter] Options:', options) + // 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 { + debug('[ContextMenuConverter] Executing callback:', item.callback?.name) + + // 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 + ) + + debug('[ContextMenuConverter] Callback executed successfully') + } catch (error) { + console.warn( + '[ContextMenuConverter] Error executing callback for:', + item.content, + error + ) + } + } finally { + // Always restore original constructor + LiteGraph.ContextMenu = OriginalContextMenu + debug('[ContextMenuConverter] Restored original ContextMenu constructor') + } + + // Convert captured items to Vue submenu format + if (capturedItems) { + debug( + '[ContextMenuConverter] Converting', + capturedItems.length, + 'captured items to Vue format' + ) + const converted = convertSubmenuToOptions(capturedItems, capturedOptions) + debug('[ContextMenuConverter] Converted result:', converted) + 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)[] + items: readonly (IContextMenuValue | string | null)[], + options?: any ): SubMenuOption[] { + debug('[ContextMenuConverter] convertSubmenuToOptions called with:', items) + debug('[ContextMenuConverter] Options:', options) + const result: SubMenuOption[] = [] for (const item of items) { - // Skip null separators and string items - if (!item || typeof item === 'string') continue + // Skip null separators + if (item === null) { + debug('[ContextMenuConverter] Skipping null separator') + continue + } + + // Handle string items (simple labels like in Mode/Shapes menus) + if (typeof item === 'string') { + debug('[ContextMenuConverter] Processing string item:', item) + + const subOption: SubMenuOption = { + label: item, + action: () => { + try { + debug( + '[ContextMenuConverter] Executing string item action for:', + item + ) + // 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) + } + } + } - if (!item.content) continue + debug( + '[ContextMenuConverter] Created submenu option from string:', + subOption + ) + result.push(subOption) + continue + } + + // Handle object items + if (!item.content) { + debug('[ContextMenuConverter] Skipping item without content:', item) + continue + } + + debug('[ContextMenuConverter] Processing object item:', { + content: item.content, + value: item.value, + disabled: item.disabled, + callback: item.callback?.name + }) + + // Extract text content from HTML if present + const content = stripHtmlTags(item.content) + debug('[ContextMenuConverter] Stripped HTML:', item.content, '->', content) const subOption: SubMenuOption = { - label: item.content, + label: content, action: () => { try { + debug( + '[ContextMenuConverter] Executing object item action for:', + content + ) void item.callback?.call( item as any, item.value, @@ -100,12 +728,28 @@ function convertSubmenuToOptions( subOption.disabled = true } + debug( + '[ContextMenuConverter] Created submenu option from object:', + subOption + ) result.push(subOption) } + debug('[ContextMenuConverter] Final submenu options:', result) 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 +} + /** * Check if a menu option already exists in the list by label */ @@ -115,25 +759,3 @@ export function menuOptionExists( ): boolean { return options.some((opt) => opt.label === label) } - -/** - * Filter out duplicate menu items based on label - * Keeps the first occurrence of each label - */ -export function removeDuplicateMenuOptions( - options: MenuOption[] -): MenuOption[] { - const seen = new Set() - return options.filter((opt) => { - // Always keep dividers - if (opt.type === 'divider') return true - - // Skip items without labels - if (!opt.label) return true - - // Filter duplicates - if (seen.has(opt.label)) return false - seen.add(opt.label) - return true - }) -} From 083fb56c6f4ea7450c8546b27b7c9ef23b556c20 Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Tue, 11 Nov 2025 10:16:08 +0100 Subject: [PATCH 06/24] feat: restructure context menu sections with proper ordering Reorder menu sections to follow the specification: 1. Basic operations: Rename, Copy, Duplicate 2. Node actions: Run Branch, Pin, Bypass, Mute (for groups) 3. Structure operations: Convert to Subgraph, Frame selection, Minimize Node 4. Node properties: Node Info, Color (Shape removed) 5. Node-specific operations: Image operations only Changes: - Add explicit dividers after each section - Remove Shape, Alignment, and Adjust Size options - Merge LiteGraph and Vue options for single node selection - Add source tracking (vue) for precedence during deduplication - Add comprehensive logging for debugging menu construction For single node selection, both LiteGraph and Vue options are merged with Vue options taking precedence, then structured via buildStructuredMenu. --- src/composables/graph/useMoreOptionsMenu.ts | 200 +++++++++++++------- 1 file changed, 136 insertions(+), 64 deletions(-) diff --git a/src/composables/graph/useMoreOptionsMenu.ts b/src/composables/graph/useMoreOptionsMenu.ts index be616088bc..73ff34f8d0 100644 --- a/src/composables/graph/useMoreOptionsMenu.ts +++ b/src/composables/graph/useMoreOptionsMenu.ts @@ -5,7 +5,10 @@ import type { LGraphGroup } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { isLGraphGroup } from '@/utils/litegraphUtil' -import { convertContextMenuToOptions } from './contextMenuConverter' +import { + buildStructuredMenu, + convertContextMenuToOptions +} from './contextMenuConverter' import { useGroupMenuOptions } from './useGroupMenuOptions' import { useImageMenuOptions } from './useImageMenuOptions' import { useNodeMenuOptions } from './useNodeMenuOptions' @@ -17,11 +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 { @@ -78,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 @@ -100,7 +117,6 @@ export function useMoreOptionsMenu() { const { getImageMenuOptions } = useImageMenuOptions() const { getNodeInfoOption, - getAdjustSizeOption, getNodeVisualOptions, getPinOption, getBypassOption, @@ -108,16 +124,13 @@ export function useMoreOptionsMenu() { } = useNodeMenuOptions() const { getFitGroupToNodesOption, - getGroupShapeOptions, getGroupColorOptions, getGroupModeOptions } = useGroupMenuOptions() const { getBasicSelectionOptions, getSubgraphOptions, - getMultipleNodesOptions, - getDeleteOption, - getAlignmentOptions + getMultipleNodesOptions } = useSelectionMenuOptions() const hasSubgraphs = hasSubgraphsComputed @@ -130,6 +143,7 @@ export function useMoreOptionsMenu() { } const menuOptions = computed((): MenuOption[] => { + /* eslint-disable no-console */ // Reference selection flags to ensure re-computation when they change optionsVersion.value @@ -145,7 +159,8 @@ export function useMoreOptionsMenu() { : null const hasSubgraphsSelected = hasSubgraphs.value - // For single node selection, use LiteGraph menu as the primary source + // For single node selection, also get LiteGraph menu items to merge + const litegraphOptions: MenuOption[] = [] if ( selectedNodes.value.length === 1 && !groupContext && @@ -154,89 +169,146 @@ export function useMoreOptionsMenu() { try { const node = selectedNodes.value[0] const rawItems = canvasStore.canvas.getNodeMenuOptions(node) - const options = convertContextMenuToOptions(rawItems) - return options + // 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) - // Fall through to Vue menu as fallback } } - // For other cases (groups, multiple selections), build Vue menu const options: MenuOption[] = [] + console.log('[Menu] Building menu sections...') + // Section 1: Basic selection operations (Rename, Copy, Duplicate) - options.push(...getBasicSelectionOptions()) + const basicOps = getBasicSelectionOptions() + console.log( + '[Menu] Section 1 - Basic operations:', + basicOps.map((o) => o.label) + ) + 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) + console.log('[Menu] Section 2 - Node actions...') + if (hasOutputNodesSelected.value) { + const runBranch = getRunBranchOption() + console.log('[Menu] - Run Branch:', runBranch.label) + 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) + console.log('[Menu] - Pin:', pin.label) + console.log('[Menu] - Bypass:', bypass.label) + 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' }) - } - - // Section 4: Image operations (if image node) - if (hasImageNode.value && selectedNodes.value.length > 0) { - options.push(...getImageMenuOptions(selectedNodes.value[0])) + const groupModes = getGroupModeOptions(groupContext, bump) + console.log( + '[Menu] - Group modes:', + groupModes.map((o) => o.label) + ) + options.push(...groupModes) } + options.push({ type: 'divider' }) - // Section 5: Subgraph operations - options.push(...getSubgraphOptions(hasSubgraphsSelected)) - - // Section 6: Multiple nodes operations + // Section 3: Structure operations (Convert to Subgraph, Frame selection, Minimize Node) + console.log('[Menu] Section 3 - Structure operations...') + const subgraphOps = getSubgraphOptions(hasSubgraphsSelected) + console.log( + '[Menu] - Subgraph:', + subgraphOps.map((o) => o.label) + ) + options.push(...subgraphOps) if (hasMultipleNodes.value) { - options.push(...getMultipleNodesOptions()) + const multiOps = getMultipleNodesOptions() + console.log( + '[Menu] - Multiple nodes:', + multiOps.map((o) => o.label) + ) + options.push(...multiOps) } - - // Section 7: Divider - options.push({ type: 'divider' }) - - // Section 8: Pin/Unpin (non-group only) - if (!groupContext) { - options.push(getPinOption(states, bump)) + if (groupContext) { + const fitGroup = getFitGroupToNodesOption(groupContext) + console.log('[Menu] - Fit group:', fitGroup.label) + options.push(fitGroup) + } else { + // Add minimize/expand option only + const visualOptions = getNodeVisualOptions(states, bump) + if (visualOptions.length > 0) { + console.log('[Menu] - Minimize/Expand:', visualOptions[0].label) + options.push(visualOptions[0]) // Minimize/Expand + } } + options.push({ type: 'divider' }) - // Section 9: Alignment (if multiple nodes) - if (hasMultipleNodes.value) { - options.push(...getAlignmentOptions()) + // Section 4: Node properties (Node Info, Color) + console.log('[Menu] Section 4 - Node properties...') + if (nodeDef.value) { + const nodeInfo = getNodeInfoOption(showNodeHelp) + console.log('[Menu] - Node Info:', nodeInfo.label) + options.push(nodeInfo) } - - // Section 10: Mode operations if (groupContext) { - // Group mode operations - options.push(...getGroupModeOptions(groupContext, bump)) + const groupColor = getGroupColorOptions(groupContext, bump) + console.log('[Menu] - Group Color:', groupColor.label) + options.push(groupColor) } else { - // Bypass option for nodes - options.push(getBypassOption(states, bump)) + // Add color option only (not shape) + const visualOptions = getNodeVisualOptions(states, bump) + if (visualOptions.length > 2) { + console.log('[Menu] - Color:', visualOptions[2].label) + options.push(visualOptions[2]) // Color (index 2) + } } + options.push({ type: 'divider' }) - // Section 11: Run Branch (if output nodes) - if (hasOutputNodesSelected.value) { - options.push(getRunBranchOption()) + // Section 5: Node-specific options (image operations) + if (hasImageNode.value && selectedNodes.value.length > 0) { + const imageOps = getImageMenuOptions(selectedNodes.value[0]) + console.log( + '[Menu] Section 5 - Image operations:', + imageOps.map((o) => o.label) + ) + options.push(...imageOps) + options.push({ type: 'divider' }) } - // Section 12: Final divider and Delete - options.push({ type: 'divider' }) - options.push(getDeleteOption()) + console.log('[Menu] Total Vue options before marking:', options.length) + + // Section 6 & 7: Extensions and Delete are handled by buildStructuredMenu + + // Mark all Vue options with source + const markedVueOptions = markAsVueOptions(options) + console.log('[Menu] Marked Vue options:', markedVueOptions.length) + + // For single node selection, merge LiteGraph options with Vue options + // Vue options will take precedence during deduplication in buildStructuredMenu + if (litegraphOptions.length > 0) { + console.log( + '[Menu] Merging with LiteGraph options:', + litegraphOptions.length + ) + console.log( + '[Menu] LiteGraph items:', + litegraphOptions.map((o) => o.label || o.type) + ) + // Merge: LiteGraph options first, then Vue options (Vue will win in dedup) + const merged = [...litegraphOptions, ...markedVueOptions] + console.log('[Menu] Merged total:', merged.length) + // Now apply structuring (which includes deduplication with Vue precedence) + return buildStructuredMenu(merged) + } - return options + console.log('[Menu] No LiteGraph options, using Vue only') + // For other cases, structure the Vue options + const result = buildStructuredMenu(markedVueOptions) + /* eslint-enable no-console */ + return result }) // Computed property to get only menu items with submenus From 22798887088a82800cd8bf2a9d4f5e311955e7a7 Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Tue, 11 Nov 2025 10:23:50 +0100 Subject: [PATCH 07/24] feat: add search functionality to context menu Add real-time search filtering to the node context menu: - Search input with icon at the top of the menu - Case-insensitive filtering by menu item label - Auto-focus on menu open - Clear search on Escape key or menu close - Smart divider handling (removes unnecessary dividers during search) - Hides Extensions category label when searching The search provides instant feedback as users type, making it easier to find specific menu items in long context menus. --- .../graph/selectionToolbox/NodeOptions.vue | 78 ++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/src/components/graph/selectionToolbox/NodeOptions.vue b/src/components/graph/selectionToolbox/NodeOptions.vue index 9965ffa6d4..f6e4226980 100644 --- a/src/components/graph/selectionToolbox/NodeOptions.vue +++ b/src/components/graph/selectionToolbox/NodeOptions.vue @@ -14,8 +14,26 @@ @wheel="canvasInteractions.forwardEventToCanvas" >
+ +
+
+ + +
+
+ + >() const targetElement = ref(null) +const searchInput = ref(null) +const searchQuery = ref('') const isTriggeredByToolbox = ref(true) // Track open state ourselves so we can restore after drag/move const isOpen = ref(false) @@ -74,6 +97,45 @@ const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu() const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning() const canvasInteractions = useCanvasInteractions() +// Filter menu options based on search query +const filteredMenuOptions = computed(() => { + const query = searchQuery.value.toLowerCase().trim() + if (!query) { + return menuOptions.value + } + + const filtered: MenuOption[] = [] + let lastWasDivider = false + + for (const option of menuOptions.value) { + // Skip category labels and dividers during filtering, add them back contextually + if (option.type === 'divider') { + lastWasDivider = true + continue + } + + if (option.type === 'category') { + continue + } + + // Check if option matches search query + const label = option.label?.toLowerCase() || '' + if (label.includes(query)) { + // Add divider before this item if the last item was separated by a divider + if (lastWasDivider && filtered.length > 0) { + const lastItem = filtered[filtered.length - 1] + if (lastItem.type !== 'divider') { + filtered.push({ type: 'divider' }) + } + } + filtered.push(option) + lastWasDivider = false + } + } + + return filtered +}) + let lastLogTs = 0 const LOG_INTERVAL = 120 // ms let overlayElCache: HTMLElement | null = null @@ -253,6 +315,10 @@ const setSubmenuRef = (key: string, el: any) => { } } +const clearSearch = () => { + searchQuery.value = '' +} + const pt = computed(() => ({ root: { class: 'absolute z-50 w-[300px] px-[12]' @@ -269,8 +335,14 @@ const pt = computed(() => ({ // Distinguish outside click (PrimeVue dismiss) from programmatic hides. const onPopoverShow = () => { overlayElCache = resolveOverlayEl() + // Clear search and focus input + searchQuery.value = '' // Delay first reposition slightly to ensure DOM fully painted - requestAnimationFrame(() => repositionPopover()) + requestAnimationFrame(() => { + repositionPopover() + // Focus the search input after popover is shown + searchInput.value?.focus() + }) startSync() } @@ -282,6 +354,8 @@ const onPopoverHide = () => { moreOptionsOpen.value = false moreOptionsRestorePending.value = false } + // Clear search when hiding + searchQuery.value = '' overlayElCache = null stopSync() lastProgrammaticHideReason.value = null From f827f486f6576293e43429341f6b703b0e9c8678 Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Tue, 11 Nov 2025 15:17:58 +0100 Subject: [PATCH 08/24] fix: move Resize and Clone to structure operations section Move Resize and Clone menu items from fallback section to Section 3 (Structure operations) so they appear alongside Convert to Subgraph, Frame selection, and Minimize Node. Updated section boundaries: - Section 3 now spans indices 9-15 (was 9-13) - Section 4 now spans indices 16-17 (was 14-15) This ensures Resize and Clone appear under the same divider group as other structure-related operations. --- src/composables/graph/contextMenuConverter.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/composables/graph/contextMenuConverter.ts b/src/composables/graph/contextMenuConverter.ts index 20a0c25a5e..c31683187e 100644 --- a/src/composables/graph/contextMenuConverter.ts +++ b/src/composables/graph/contextMenuConverter.ts @@ -232,6 +232,8 @@ const MENU_ORDER = [ 'Minimize Node', 'Expand', 'Collapse', + 'Resize', + 'Clone', // Section 4: Node properties 'Node Info', 'Color', @@ -243,8 +245,6 @@ const MENU_ORDER = [ 'Copy (Clipspace)', 'Paste (Clipspace)', // Fallback for other core items - 'Resize', - 'Clone', 'Convert to Group Node (Deprecated)' ] as const @@ -324,14 +324,14 @@ export function buildStructuredMenu(options: MenuOption[]): MenuOption[] { // 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-13 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse) - // Section 4: 14-15 (Node Info, Color) - // Section 5: 16+ (Image operations and fallback items) + // 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 <= 13) return 3 - if (index <= 15) return 4 + if (index <= 15) return 3 + if (index <= 17) return 4 return 5 } From 727ce2ebcd8f03900c26d998126bab972e7d9af7 Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Tue, 11 Nov 2025 17:40:49 +0100 Subject: [PATCH 09/24] WIP scroll --- .../graph/selectionToolbox/NodeOptions.vue | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/components/graph/selectionToolbox/NodeOptions.vue b/src/components/graph/selectionToolbox/NodeOptions.vue index f6e4226980..1310e0d4bf 100644 --- a/src/components/graph/selectionToolbox/NodeOptions.vue +++ b/src/components/graph/selectionToolbox/NodeOptions.vue @@ -11,10 +11,9 @@ :pt="pt" @show="onPopoverShow" @hide="onPopoverHide" - @wheel="canvasInteractions.forwardEventToCanvas" >
- +
- - + +
+ +
@@ -72,7 +74,8 @@ import type { SubMenuOption } from '@/composables/graph/useMoreOptionsMenu' import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning' -import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' + +// import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import MenuOptionItem from './MenuOptionItem.vue' import SubmenuPopover from './SubmenuPopover.vue' @@ -95,7 +98,7 @@ const currentSubmenu = ref(null) const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu() const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning() -const canvasInteractions = useCanvasInteractions() +// const canvasInteractions = useCanvasInteractions() // Filter menu options based on search query const filteredMenuOptions = computed(() => { From 20c6aa4f900542ded2ef5f8ece6083ff2c507854 Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Tue, 11 Nov 2025 23:38:35 +0100 Subject: [PATCH 10/24] feat: add viewport-aware positioning to prevent menu overflow When right-clicking near the bottom of the viewport, the context menu now docks to the bottom edge instead of floating off-screen, ensuring it remains accessible and scrollable. --- .../graph/selectionToolbox/NodeOptions.vue | 37 ++++++--- .../graph/selectionToolbox/SubmenuPopover.vue | 31 +++++++- .../graph/useViewportAwareMenuPositioning.ts | 75 +++++++++++++++++++ 3 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 src/composables/graph/useViewportAwareMenuPositioning.ts diff --git a/src/components/graph/selectionToolbox/NodeOptions.vue b/src/components/graph/selectionToolbox/NodeOptions.vue index 1310e0d4bf..c43f447ab3 100644 --- a/src/components/graph/selectionToolbox/NodeOptions.vue +++ b/src/components/graph/selectionToolbox/NodeOptions.vue @@ -21,8 +21,8 @@ /> { const btn = targetElement.value const overlayEl = resolveOverlayEl() if (!btn || !overlayEl) return + const rect = btn.getBoundingClientRect() - const marginY = 8 // tailwind mt-2 ~ 0.5rem = 8px - const left = isTriggeredByToolbox.value - ? rect.left + rect.width / 2 - : rect.right - rect.width / 4 - const top = isTriggeredByToolbox.value - ? rect.bottom + marginY - : rect.top - marginY - 6 + try { - overlayEl.style.position = 'fixed' - overlayEl.style.left = `${left}px` - overlayEl.style.top = `${top}px` - overlayEl.style.transform = 'translate(-50%, 0)' + // Calculate viewport-aware position + const style = calculateMenuPosition({ + triggerRect: rect, + menuElement: overlayEl, + isTriggeredByToolbox: isTriggeredByToolbox.value, + marginY: 8 + }) + + // Apply positioning styles + overlayEl.style.position = style.position + overlayEl.style.left = style.left + overlayEl.style.transform = style.transform + + // Handle top vs bottom positioning + if (style.top !== undefined) { + overlayEl.style.top = style.top + overlayEl.style.bottom = '' // Clear bottom if using top + } else if (style.bottom !== undefined) { + overlayEl.style.bottom = style.bottom + overlayEl.style.top = '' // Clear top if using bottom + } } catch (e) { console.warn('[NodeOptions] Failed to set overlay style', e) return diff --git a/src/components/graph/selectionToolbox/SubmenuPopover.vue b/src/components/graph/selectionToolbox/SubmenuPopover.vue index 2b4ccca149..10baa5ee40 100644 --- a/src/components/graph/selectionToolbox/SubmenuPopover.vue +++ b/src/components/graph/selectionToolbox/SubmenuPopover.vue @@ -52,7 +52,7 @@