Floating Selection Toolbox Improvements (#5218)

* WIP

* WIP: UI design for right click menu

* feat: add composable for node customization and information handling

* fix: correct v-show directive in MaskEditorButton and enhance MoreOptions functionality

* feat: add selection and subgraph operations composables for enhanced graph management

* fix: update computed properties to use 'void' for non-reactive calls and add MenuOptionItem component

* feat: add composables for More Options menu and submenu positioning logic

* feat: refactor MoreOptions component to use MenuOptionItem for menu rendering and streamline submenu handling

* feat: implement SubmenuPopover component for enhanced submenu functionality and selection handling

* feat: add 'More Options' label and enhance shape options in localization file

* refactor: simplify shape name handling by removing Pascal case conversion and using localized names

* refactor: enhance submenu handling by dynamically setting refs and improving key assignment

* feat: implement useNodeArrangement composable for node alignment and distribution functionality

* feat: enhance useMoreOptionsMenu with image node operations and alignment options

* feat: localize context menu options and enhance submenu handling

* refactor: improve type safety for title assignment in selection operations and enhance color option retrieval in node customization

* fix: adjust component order in SelectionToolbox for improved layout

* feat: update FrameNodes button visibility and tooltip, and add localization for frameNodes

* feat: enhance button visibility logic in SelectionToolbox based on selection types

* refactor: reorganize properties panel option in More Options menu for single nodes

* remove excessive logging and alerts

* fix component tests

* ad browser tests

* feat: enhance popover behavior in MoreOptions component to manage visibility state during selection overlay changes

* refactor: update visibility logic for buttons in SelectionToolbox and ExecuteButton components

* refactor: remove duplicate shape option and clean up shapeOptions array

* refactor: update help toggle logic in InfoButton and useMoreOptionsMenu to manage sidebar and help state

* refactor: streamline node info handling and integrate output node filtering in useNodeInfo and useMoreOptionsMenu

* Added useSelectionState composable consolidating all selection-derived state and the node help toggle

* Updated toolbox buttons (InfoButton, BookmarkButton, BypassButton, MaskEditorButton, ConvertToSubgraphButton, PinButton, DeleteButton, ColorPickerButton, ExecuteButton, FrameNodes, Load3DViewerButton) to remove duplicated selection logic and use useSelectionState

* Introduced HideReason ('manual' | 'drag') to differentiate drag-induced hides from manual/outside hides in MoreOptions

* refactor: enhance popover visibility handling during drag events using canvas state

* fix: update shape option name from 'default' to 'box' and add localization for 'box'

* refactor: streamline BypassButton logic and enhance MoreOptions menu with state bumping

* refactor: remove toast notifications from subgraph operations for cleaner logic

* refactor: ensure menu options re-compute when selection flags change

* feat: Enhance MoreOptions behavior with drag-and-drop support

* fix: Update mask icon class for consistent styling in MaskEditorButton

* refactor: Standardize icon sizes and classes across selection toolbox buttons

* refactor: Update layout and styling in SelectionToolbox and MoreOptions components

* refactor: Improve selection toolbox behavior with more options state management

* Refactor: Remove unused imports and conditionally add subgraph option in menu

* Enhance popover behavior: add show/hide event handlers and improve positioning logic

* Cleanup: Remove debug comments from popover functions for clarity

* Refactor: Clean up FrameNodes component and add MenuOptionBadge for better option display

* Cleanup: Remove debug comments from useSelectionToolboxPosition for clarity

* Add useFrameNodes composable for grouping selected nodes

* Refactor: Update shape options in useNodeCustomization and localize frame nodes label

* fix tests

* Cleanup: Remove packageManager entry from package.json

* Refactor: Replace ILucide icons with named imports from lucide-vue-next

* Refactor: Update shape selection and improve color picker behavior in selection toolbox

* Update test expectations [skip ci]

* feat: Enhance More Options Menu for group node management and update localization strings

* refactor: Comment out PublishButton

* refactor: Comment out test for bookmark button visibility in SelectionToolbox

* refactor: Update class names for dark theme compatibility in ExecuteButton and MenuOptionItem components

* refactor: Modularize menu options by creating dedicated composables for group, image, node, and selection operations

* refactor: Update selectors in tests to match design changes

* refactor: Update help button selector in Node Help tests

* refactor: Update getGroupColorOptions to accept groupContext and bump parameters

* Update test expectations [skip ci]

* refactor: Center KSampler node before interaction in More Options submenu tests

* refactor: Adjust KSampler node positioning and simplify button click in More Options submenu tests

* refactor: Rename comfyPageFixture import for clarity

* refactor: use gap-1 instead of the explicit gap-[4px]

* refactor: Replace app.canvas with canvasStore.getCanvas for  state management

* refactor: Simplify prop access by removing 'props.' prefix in MenuOptionItem component

* refactor: Remove explicit type annotation for item in buildSelectionSignature function

* refactor: Replace Lucide icons with string-based icon references in menu options

* refactor: Remove export from interface declarations for improved clarity

* refactor: Simplify class binding in BypassButton component for improved readability

* refactor: Update button class for consistent sizing in ExecuteButton component

* refactor: Update help button locator class for consistency in Node Help tests

* fix node help test

* refactor: Remove unused imports and simplify visibility conditions in selection toolbox components

* feat: Add 3D node selection logic and cleanup on unmount for selection toolbox

* refactor: Update help button locator to use consistent data-testid in Node Help tests

* fix: Correct help button locator syntax in Node Help tests

* refactor: Change resetMoreOptionsState to an internal function in useSelectionToolboxPosition

* test: Add Load3D node visibility logic for ColorPickerButton and remove redundant test case

* fix: Increase tooltip show delay for ColorPickerButton

* fix: Update selectedOutputNodes computation to filter by isLGraphNode

* fix: Remove unused nodeDef reference from InfoButton and submenu trigger from MenuOptionItem

* fix: Update showInfoButton logic to depend on nodeDef value

* refactor: Remove deprecated getBasicNodeOptions function for cleaner code

* refactor: Replace useNodeInfo with useSelectedNodeActions

* refactor: Integrate useNodeDefStore for improved node definition handling in SelectionToolbox and InfoButton tests

* refactor: Introduce useCanvasRefresh composable for consistent canvas refresh logic across node operations

* refactor: Remove irrelevant append-to attribute from Popover

* refactor: Use storeToRefs for selectedItems in useSelectionState and add tests for selection logic

* refactor: Update ExecuteButton to use hasOutputNodesSelected for visibility and remove unnecessary computed property

* refactor: move display of execution button tests to selectionToolbox

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Johnpaul Chiwetelu
2025-09-14 06:52:30 +01:00
committed by GitHub
parent f6a115e182
commit ac107b45ea
47 changed files with 3989 additions and 205 deletions

View File

@@ -0,0 +1,22 @@
// call nextTick on all changeTracker
import { useCanvasStore } from '@/stores/graphStore'
import { useWorkflowStore } from '@/stores/workflowStore'
/**
* Composable for refreshing nodes in the graph
* */
export function useCanvasRefresh() {
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const refreshCanvas = () => {
canvasStore.canvas?.emitBeforeChange()
canvasStore.canvas?.setDirty(true, true)
canvasStore.canvas?.graph?.afterChange()
canvasStore.canvas?.emitAfterChange()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
return {
refreshCanvas
}
}

View File

@@ -0,0 +1,30 @@
import { computed } from 'vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useTitleEditorStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
/**
* Composable encapsulating logic for framing currently selected nodes into a group.
*/
export function useFrameNodes() {
const settingStore = useSettingStore()
const titleEditorStore = useTitleEditorStore()
const { hasMultipleSelection } = useSelectionState()
const canFrame = computed(() => hasMultipleSelection.value)
const frameNodes = () => {
const { canvas } = app
if (!canvas.selectedItems?.size) return
const group = new LGraphGroup()
const padding = settingStore.get('Comfy.GroupSelectedNodes.Padding')
group.resizeTo(canvas.selectedItems, padding)
canvas.graph?.add(group)
titleEditorStore.titleEditorTarget = group
}
return { frameNodes, canFrame }
}

View File

@@ -0,0 +1,199 @@
import { useI18n } from 'vue-i18n'
import {
LGraphEventMode,
type LGraphGroup,
type LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useCanvasRefresh } from './useCanvasRefresh'
import type { MenuOption } from './useMoreOptionsMenu'
import { useNodeCustomization } from './useNodeCustomization'
/**
* Composable for group-related menu operations
*/
export function useGroupMenuOptions() {
const { t } = useI18n()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const canvasRefresh = useCanvasRefresh()
const { shapeOptions, colorOptions, isLightTheme } = useNodeCustomization()
const getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({
label: 'Fit Group To Nodes',
icon: 'icon-[lucide--move-diagonal-2]',
action: () => {
try {
groupContext.recomputeInsideNodes()
} catch (e) {
console.warn('Failed to recompute group nodes:', e)
return
}
const padding = settingStore.get('Comfy.GroupSelectedNodes.Padding')
groupContext.resizeTo(groupContext.children, padding)
groupContext.graph?.change()
canvasStore.canvas?.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
})
const getGroupShapeOptions = (
groupContext: LGraphGroup,
bump: () => void
): MenuOption => ({
label: t('contextMenu.Shape'),
icon: 'icon-[lucide--box]',
hasSubmenu: true,
submenu: shapeOptions.map((shape) => ({
label: shape.localizedName,
action: () => {
const nodes = (groupContext.nodes || []) as LGraphNode[]
nodes.forEach((node) => (node.shape = shape.value))
canvasRefresh.refreshCanvas()
bump()
}
}))
})
const getGroupColorOptions = (
groupContext: LGraphGroup,
bump: () => void
): MenuOption => ({
label: t('contextMenu.Color'),
icon: 'icon-[lucide--palette]',
hasSubmenu: true,
submenu: colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
: colorOption.value.dark,
action: () => {
groupContext.color = isLightTheme.value
? colorOption.value.light
: colorOption.value.dark
canvasRefresh.refreshCanvas()
bump()
}
}))
})
const getGroupModeOptions = (
groupContext: LGraphGroup,
bump: () => void
): MenuOption[] => {
const options: MenuOption[] = []
try {
groupContext.recomputeInsideNodes()
} catch (e) {
console.warn('Failed to recompute group nodes for mode options:', e)
return options
}
const groupNodes = (groupContext.nodes || []) as LGraphNode[]
if (!groupNodes.length) return options
// Check if all nodes have the same mode
let allSame = true
for (let i = 1; i < groupNodes.length; i++) {
if (groupNodes[i].mode !== groupNodes[0].mode) {
allSame = false
break
}
}
const createModeAction = (label: string, mode: LGraphEventMode) => ({
label: t(`selectionToolbox.${label}`),
icon:
mode === LGraphEventMode.BYPASS
? 'icon-[lucide--ban]'
: mode === LGraphEventMode.NEVER
? 'icon-[lucide--zap-off]'
: 'icon-[lucide--play]',
action: () => {
groupNodes.forEach((n) => {
n.mode = mode
})
canvasStore.canvas?.setDirty(true, true)
groupContext.graph?.change()
workflowStore.activeWorkflow?.changeTracker?.checkState()
bump()
}
})
if (allSame) {
const current = groupNodes[0].mode
switch (current) {
case LGraphEventMode.ALWAYS:
options.push(
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
)
options.push(
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
)
break
case LGraphEventMode.NEVER:
options.push(
createModeAction(
'Set Group Nodes to Always',
LGraphEventMode.ALWAYS
)
)
options.push(
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
)
break
case LGraphEventMode.BYPASS:
options.push(
createModeAction(
'Set Group Nodes to Always',
LGraphEventMode.ALWAYS
)
)
options.push(
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
)
break
default:
options.push(
createModeAction(
'Set Group Nodes to Always',
LGraphEventMode.ALWAYS
)
)
options.push(
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
)
options.push(
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
)
break
}
} else {
options.push(
createModeAction('Set Group Nodes to Always', LGraphEventMode.ALWAYS)
)
options.push(
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
)
options.push(
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
)
}
return options
}
return {
getFitGroupToNodesOption,
getGroupShapeOptions,
getGroupColorOptions,
getGroupModeOptions
}
}

View File

@@ -0,0 +1,122 @@
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import type { MenuOption } from './useMoreOptionsMenu'
/**
* Composable for image-related menu operations
*/
export function useImageMenuOptions() {
const { t } = useI18n()
const openMaskEditor = () => {
const commandStore = useCommandStore()
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
}
const openImage = (node: any) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
const url = new URL(img.src)
url.searchParams.delete('preview')
window.open(url.toString(), '_blank')
}
const copyImage = async (node: any) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
ctx.drawImage(img, 0, 0)
try {
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
if (!blob) {
console.warn('Failed to create image blob')
return
}
// Check if clipboard API is available
if (!navigator.clipboard?.write) {
console.warn('Clipboard API not available')
return
}
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
])
} catch (error) {
console.error('Failed to copy image to clipboard:', error)
}
}
const saveImage = (node: any) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
try {
const url = new URL(img.src)
url.searchParams.delete('preview')
const a = document.createElement('a')
a.href = url.toString()
a.setAttribute(
'download',
new URLSearchParams(url.search).get('filename') ?? 'image.png'
)
a.style.display = 'none'
document.body.appendChild(a)
a.click()
requestAnimationFrame(() => {
if (document.body.contains(a)) {
document.body.removeChild(a)
}
})
} catch (error) {
console.error('Failed to save image:', error)
}
}
const getImageMenuOptions = (node: any): MenuOption[] => {
if (!node?.imgs?.length) return []
return [
{
label: t('contextMenu.Open in Mask Editor'),
action: () => openMaskEditor()
},
{
label: t('contextMenu.Open Image'),
icon: 'icon-[lucide--external-link]',
action: () => openImage(node)
},
{
label: t('contextMenu.Copy Image'),
icon: 'icon-[lucide--copy]',
action: () => copyImage(node)
},
{
label: t('contextMenu.Save Image'),
icon: 'icon-[lucide--download]',
action: () => saveImage(node)
}
]
}
return {
getImageMenuOptions
}
}

View File

@@ -0,0 +1,186 @@
import { computed, ref } from 'vue'
import { type LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { isLGraphGroup } from '@/utils/litegraphUtil'
import { useGroupMenuOptions } from './useGroupMenuOptions'
import { useImageMenuOptions } from './useImageMenuOptions'
import { useNodeMenuOptions } from './useNodeMenuOptions'
import { useSelectionMenuOptions } from './useSelectionMenuOptions'
import { useSelectionState } from './useSelectionState'
export interface MenuOption {
label?: string
icon?: string
shortcut?: string
hasSubmenu?: boolean
type?: 'divider'
action?: () => void
submenu?: SubMenuOption[]
badge?: BadgeVariant
}
export interface SubMenuOption {
label: string
icon?: string
action: () => void
color?: string
}
export enum BadgeVariant {
NEW = 'new',
DEPRECATED = 'deprecated'
}
/**
* Composable for managing the More Options menu configuration
* Refactored to use smaller, focused composables for better maintainability
*/
export function useMoreOptionsMenu() {
const {
selectedItems,
selectedNodes,
nodeDef,
showNodeHelp,
hasSubgraphs: hasSubgraphsComputed,
hasImageNode,
hasOutputNodesSelected,
hasMultipleSelection,
computeSelectionFlags
} = useSelectionState()
const { getImageMenuOptions } = useImageMenuOptions()
const {
getNodeInfoOption,
getAdjustSizeOption,
getNodeVisualOptions,
getPinOption,
getBypassOption,
getRunBranchOption
} = useNodeMenuOptions()
const {
getFitGroupToNodesOption,
getGroupShapeOptions,
getGroupColorOptions,
getGroupModeOptions
} = useGroupMenuOptions()
const {
getBasicSelectionOptions,
getSubgraphOptions,
getMultipleNodesOptions,
getDeleteOption,
getAlignmentOptions
} = useSelectionMenuOptions()
const hasSubgraphs = hasSubgraphsComputed
const hasMultipleNodes = hasMultipleSelection
// Internal version to force menu rebuild after state mutations
const optionsVersion = ref(0)
const bump = () => {
optionsVersion.value++
}
const menuOptions = computed((): MenuOption[] => {
// Reference selection flags to ensure re-computation when they change
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
optionsVersion.value
const states = computeSelectionFlags()
// Detect single group selection context (and no nodes explicitly selected)
const selectedGroups = selectedItems.value.filter(
isLGraphGroup
) as LGraphGroup[]
const groupContext: LGraphGroup | null =
selectedGroups.length === 1 && selectedNodes.value.length === 0
? selectedGroups[0]
: null
const hasSubgraphsSelected = hasSubgraphs.value
const options: MenuOption[] = []
// Section 1: Basic selection operations (Rename, Copy, Duplicate)
options.push(...getBasicSelectionOptions())
options.push({ type: 'divider' })
// Section 2: Node Info & Size Adjustment
if (nodeDef.value) {
options.push(getNodeInfoOption(showNodeHelp))
}
if (groupContext) {
options.push(getFitGroupToNodesOption(groupContext))
} else {
options.push(getAdjustSizeOption())
}
// 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]))
}
// 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)
if (hasMultipleNodes.value) {
options.push(...getAlignmentOptions())
}
// Section 10: Mode operations
if (groupContext) {
// Group mode operations
options.push(...getGroupModeOptions(groupContext, bump))
} else {
// Bypass option for nodes
options.push(getBypassOption(states, bump))
}
// Section 11: Run Branch (if output nodes)
if (hasOutputNodesSelected.value) {
options.push(getRunBranchOption())
}
// Section 12: Final divider and Delete
options.push({ type: 'divider' })
options.push(getDeleteOption())
return options
})
// Computed property to get only menu items with submenus
const menuOptionsWithSubmenu = computed(() =>
menuOptions.value.filter((option) => option.hasSubmenu && option.submenu)
)
return {
menuOptions,
menuOptionsWithSubmenu,
bump,
hasSubgraphs
}
}

View File

@@ -0,0 +1,106 @@
import { useI18n } from 'vue-i18n'
import type { Direction } from '@/lib/litegraph/src/interfaces'
import { alignNodes, distributeNodes } from '@/lib/litegraph/src/utils/arrange'
import { useCanvasStore } from '@/stores/graphStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useCanvasRefresh } from './useCanvasRefresh'
interface AlignOption {
name: string
localizedName: string
value: Direction
icon: string
}
interface DistributeOption {
name: string
localizedName: string
value: boolean // true for horizontal, false for vertical
icon: string
}
/**
* Composable for handling node alignment and distribution
*/
export function useNodeArrangement() {
const { t } = useI18n()
const canvasStore = useCanvasStore()
const canvasRefresh = useCanvasRefresh()
const alignOptions: AlignOption[] = [
{
name: 'top',
localizedName: t('contextMenu.Top'),
value: 'top',
icon: 'icon-[lucide--align-start-vertical]'
},
{
name: 'bottom',
localizedName: t('contextMenu.Bottom'),
value: 'bottom',
icon: 'icon-[lucide--align-end-vertical]'
},
{
name: 'left',
localizedName: t('contextMenu.Left'),
value: 'left',
icon: 'icon-[lucide--align-start-horizontal]'
},
{
name: 'right',
localizedName: t('contextMenu.Right'),
value: 'right',
icon: 'icon-[lucide--align-end-horizontal]'
}
]
const distributeOptions: DistributeOption[] = [
{
name: 'horizontal',
localizedName: t('contextMenu.Horizontal'),
value: true,
icon: 'icon-[lucide--align-center-horizontal]'
},
{
name: 'vertical',
localizedName: t('contextMenu.Vertical'),
value: false,
icon: 'icon-[lucide--align-center-vertical]'
}
]
const applyAlign = (alignOption: AlignOption) => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter((item) =>
isLGraphNode(item)
)
if (selectedNodes.length === 0) {
return
}
alignNodes(selectedNodes, alignOption.value)
canvasRefresh.refreshCanvas()
}
const applyDistribute = (distributeOption: DistributeOption) => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter((item) =>
isLGraphNode(item)
)
if (selectedNodes.length < 2) {
return
}
distributeNodes(selectedNodes, distributeOption.value)
canvasRefresh.refreshCanvas()
}
return {
alignOptions,
distributeOptions,
applyAlign,
applyDistribute
}
}

View File

@@ -0,0 +1,167 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
LGraphCanvas,
LGraphNode,
LiteGraph,
RenderShape,
isColorable
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useCanvasRefresh } from './useCanvasRefresh'
interface ColorOption {
name: string
localizedName: string
value: {
dark: string
light: string
}
}
interface ShapeOption {
name: string
localizedName: string
value: RenderShape
}
/**
* Composable for handling node color and shape customization
*/
export function useNodeCustomization() {
const { t } = useI18n()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const canvasRefresh = useCanvasRefresh()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const toLightThemeColor = (color: string) =>
adjustColor(color, { lightness: 0.5 })
// Color options
const NO_COLOR_OPTION: ColorOption = {
name: 'noColor',
localizedName: t('color.noColor'),
value: {
dark: LiteGraph.NODE_DEFAULT_BGCOLOR,
light: toLightThemeColor(LiteGraph.NODE_DEFAULT_BGCOLOR)
}
}
const colorOptions: ColorOption[] = [
NO_COLOR_OPTION,
...Object.entries(LGraphCanvas.node_colors).map(([name, color]) => ({
name,
localizedName: t(`color.${name}`),
value: {
dark: color.bgcolor,
light: toLightThemeColor(color.bgcolor)
}
}))
]
// Shape options
const shapeOptions: ShapeOption[] = [
{
name: 'default',
localizedName: t('shape.default'),
value: RenderShape.ROUND
},
{
name: 'box',
localizedName: t('shape.box'),
value: RenderShape.BOX
},
{
name: 'card',
localizedName: t('shape.CARD'),
value: RenderShape.CARD
}
]
const applyColor = (colorOption: ColorOption | null) => {
const colorName = colorOption?.name ?? NO_COLOR_OPTION.name
const canvasColorOption =
colorName === NO_COLOR_OPTION.name
? null
: LGraphCanvas.node_colors[colorName]
for (const item of canvasStore.selectedItems) {
if (isColorable(item)) {
item.setColorOption(canvasColorOption)
}
}
canvasRefresh.refreshCanvas()
}
const applyShape = (shapeOption: ShapeOption) => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
(item): item is LGraphNode => item instanceof LGraphNode
)
if (selectedNodes.length === 0) {
return
}
selectedNodes.forEach((node) => {
node.shape = shapeOption.value
})
canvasRefresh.refreshCanvas()
}
const getCurrentColor = (): ColorOption | null => {
const selectedItems = Array.from(canvasStore.selectedItems)
if (selectedItems.length === 0) return null
// Get color from first colorable item
const firstColorableItem = selectedItems.find((item) => isColorable(item))
if (!firstColorableItem || !isColorable(firstColorableItem)) return null
// Get the current color option from the colorable item
const currentColorOption = firstColorableItem.getColorOption()
const currentBgColor = currentColorOption?.bgcolor ?? null
// Find matching color option
return (
colorOptions.find(
(option) =>
option.value.dark === currentBgColor ||
option.value.light === currentBgColor
) ?? NO_COLOR_OPTION
)
}
const getCurrentShape = (): ShapeOption | null => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
(item): item is LGraphNode => item instanceof LGraphNode
)
if (selectedNodes.length === 0) return null
const firstNode = selectedNodes[0]
const currentShape = firstNode.shape ?? RenderShape.ROUND
return (
shapeOptions.find((option) => option.value === currentShape) ??
shapeOptions[0]
)
}
return {
colorOptions,
shapeOptions,
applyColor,
applyShape,
getCurrentColor,
getCurrentShape,
isLightTheme
}
}

View File

@@ -0,0 +1,128 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MenuOption } from './useMoreOptionsMenu'
import { useNodeCustomization } from './useNodeCustomization'
import { useSelectedNodeActions } from './useSelectedNodeActions'
import type { NodeSelectionState } from './useSelectionState'
/**
* Composable for node-related menu operations
*/
export function useNodeMenuOptions() {
const { t } = useI18n()
const { shapeOptions, applyShape, applyColor, colorOptions, isLightTheme } =
useNodeCustomization()
const {
adjustNodeSize,
toggleNodeCollapse,
toggleNodePin,
toggleNodeBypass,
runBranch
} = useSelectedNodeActions()
const shapeSubmenu = computed(() =>
shapeOptions.map((shape) => ({
label: shape.localizedName,
action: () => applyShape(shape)
}))
)
const colorSubmenu = computed(() => {
return colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
: colorOption.value.dark,
action: () =>
applyColor(colorOption.name === 'noColor' ? null : colorOption)
}))
})
const getAdjustSizeOption = (): MenuOption => ({
label: t('contextMenu.Adjust Size'),
icon: 'icon-[lucide--move-diagonal-2]',
action: adjustNodeSize
})
const getNodeVisualOptions = (
states: NodeSelectionState,
bump: () => void
): MenuOption[] => [
{
label: states.collapsed
? t('contextMenu.Expand Node')
: t('contextMenu.Minimize Node'),
icon: states.collapsed
? 'icon-[lucide--maximize-2]'
: 'icon-[lucide--minimize-2]',
action: () => {
toggleNodeCollapse()
bump()
}
},
{
label: t('contextMenu.Shape'),
icon: 'icon-[lucide--box]',
hasSubmenu: true,
submenu: shapeSubmenu.value,
action: () => {}
},
{
label: t('contextMenu.Color'),
icon: 'icon-[lucide--palette]',
hasSubmenu: true,
submenu: colorSubmenu.value,
action: () => {}
}
]
const getPinOption = (
states: NodeSelectionState,
bump: () => void
): MenuOption => ({
label: states.pinned ? t('contextMenu.Unpin') : t('contextMenu.Pin'),
icon: states.pinned ? 'icon-[lucide--pin-off]' : 'icon-[lucide--pin]',
action: () => {
toggleNodePin()
bump()
}
})
const getBypassOption = (
states: NodeSelectionState,
bump: () => void
): MenuOption => ({
label: states.bypassed
? t('contextMenu.Remove Bypass')
: t('contextMenu.Bypass'),
icon: states.bypassed ? 'icon-[lucide--zap-off]' : 'icon-[lucide--ban]',
shortcut: 'Ctrl+B',
action: () => {
toggleNodeBypass()
bump()
}
})
const getRunBranchOption = (): MenuOption => ({
label: t('contextMenu.Run Branch'),
icon: 'icon-[lucide--play]',
action: runBranch
})
const getNodeInfoOption = (showNodeHelp: () => void): MenuOption => ({
label: t('contextMenu.Node Info'),
icon: 'icon-[lucide--info]',
action: showNodeHelp
})
return {
getNodeInfoOption,
getAdjustSizeOption,
getNodeVisualOptions,
getPinOption,
getBypassOption,
getRunBranchOption,
colorSubmenu
}
}

View File

@@ -0,0 +1,68 @@
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
/**
* Composable for handling node information and utility operations
*/
export function useSelectedNodeActions() {
const { getSelectedNodes, toggleSelectedNodesMode } =
useSelectedLiteGraphItems()
const commandStore = useCommandStore()
const workflowStore = useWorkflowStore()
const adjustNodeSize = () => {
const selectedNodes = getSelectedNodes()
selectedNodes.forEach((node) => {
const optimalSize = node.computeSize()
node.setSize([optimalSize[0], optimalSize[1]])
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodeCollapse = () => {
const selectedNodes = getSelectedNodes()
selectedNodes.forEach((node) => {
node.collapse()
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodePin = () => {
const selectedNodes = getSelectedNodes()
selectedNodes.forEach((node) => {
node.pin(!node.pinned)
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodeBypass = () => {
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
app.canvas.setDirty(true, true)
}
const runBranch = async () => {
const selectedNodes = getSelectedNodes()
const selectedOutputNodes = filterOutputNodes(selectedNodes)
if (selectedOutputNodes.length === 0) return
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
}
return {
adjustNodeSize,
toggleNodeCollapse,
toggleNodePin,
toggleNodeBypass,
runBranch
}
}

View File

@@ -0,0 +1,147 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useFrameNodes } from './useFrameNodes'
import { BadgeVariant, type MenuOption } from './useMoreOptionsMenu'
import { useNodeArrangement } from './useNodeArrangement'
import { useSelectionOperations } from './useSelectionOperations'
import { useSubgraphOperations } from './useSubgraphOperations'
/**
* Composable for selection-related menu operations
*/
export function useSelectionMenuOptions() {
const { t } = useI18n()
const {
copySelection,
duplicateSelection,
deleteSelection,
renameSelection
} = useSelectionOperations()
const { alignOptions, distributeOptions, applyAlign, applyDistribute } =
useNodeArrangement()
const { convertToSubgraph, unpackSubgraph, addSubgraphToLibrary } =
useSubgraphOperations()
const { frameNodes } = useFrameNodes()
const alignSubmenu = computed(() =>
alignOptions.map((align) => ({
label: align.localizedName,
icon: align.icon,
action: () => applyAlign(align)
}))
)
const distributeSubmenu = computed(() =>
distributeOptions.map((distribute) => ({
label: distribute.localizedName,
icon: distribute.icon,
action: () => applyDistribute(distribute)
}))
)
const getBasicSelectionOptions = (): MenuOption[] => [
{
label: t('contextMenu.Rename'),
action: renameSelection
},
{
label: t('contextMenu.Copy'),
shortcut: 'Ctrl+C',
action: copySelection
},
{
label: t('contextMenu.Duplicate'),
shortcut: 'Ctrl+D',
action: duplicateSelection
}
]
const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => {
if (hasSubgraphs) {
return [
{
label: t('contextMenu.Add Subgraph to Library'),
icon: 'icon-[lucide--folder-plus]',
action: addSubgraphToLibrary
},
{
label: t('contextMenu.Unpack Subgraph'),
icon: 'icon-[lucide--expand]',
action: unpackSubgraph
}
]
} else {
return [
{
label: t('contextMenu.Convert to Subgraph'),
icon: 'icon-[lucide--shrink]',
action: convertToSubgraph,
badge: BadgeVariant.NEW
}
]
}
}
const getMultipleNodesOptions = (): MenuOption[] => {
const convertToGroupNodes = () => {
const commandStore = useCommandStore()
void commandStore.execute(
'Comfy.GroupNode.ConvertSelectedNodesToGroupNode'
)
}
return [
{
label: t('contextMenu.Convert to Group Node'),
icon: 'icon-[lucide--group]',
action: convertToGroupNodes,
badge: BadgeVariant.DEPRECATED
},
{
label: t('g.frameNodes'),
icon: 'icon-[lucide--frame]',
action: frameNodes
}
]
}
const getAlignmentOptions = (): MenuOption[] => [
{
label: t('contextMenu.Align Selected To'),
icon: 'icon-[lucide--align-start-horizontal]',
hasSubmenu: true,
submenu: alignSubmenu.value,
action: () => {}
},
{
label: t('contextMenu.Distribute Nodes'),
icon: 'icon-[lucide--align-center-horizontal]',
hasSubmenu: true,
submenu: distributeSubmenu.value,
action: () => {}
}
]
const getDeleteOption = (): MenuOption => ({
label: t('contextMenu.Delete'),
icon: 'icon-[lucide--trash-2]',
shortcut: 'Delete',
action: deleteSelection
})
return {
getBasicSelectionOptions,
getSubgraphOptions,
getMultipleNodesOptions,
getDeleteOption,
getAlignmentOptions,
alignSubmenu,
distributeSubmenu
}
}

View File

@@ -0,0 +1,165 @@
// import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' // Unused for now
import { t } from '@/i18n'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useToastStore } from '@/stores/toastStore'
import { useWorkflowStore } from '@/stores/workflowStore'
/**
* Composable for handling basic selection operations like copy, paste, duplicate, delete, rename
*/
export function useSelectionOperations() {
// const { getSelectedNodes } = useSelectedLiteGraphItems() // Unused for now
const canvasStore = useCanvasStore()
const toastStore = useToastStore()
const dialogService = useDialogService()
const titleEditorStore = useTitleEditorStore()
const workflowStore = useWorkflowStore()
const copySelection = () => {
const canvas = app.canvas
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
toastStore.add({
severity: 'warn',
summary: t('g.nothingToCopy'),
detail: t('g.selectItemsToCopy'),
life: 3000
})
return
}
canvas.copyToClipboard()
toastStore.add({
severity: 'success',
summary: t('g.copied'),
detail: t('g.itemsCopiedToClipboard'),
life: 2000
})
}
const pasteSelection = () => {
const canvas = app.canvas
canvas.pasteFromClipboard({ connectInputs: false })
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const duplicateSelection = () => {
const canvas = app.canvas
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
toastStore.add({
severity: 'warn',
summary: t('g.nothingToDuplicate'),
detail: t('g.selectItemsToDuplicate'),
life: 3000
})
return
}
// Copy current selection
canvas.copyToClipboard()
// Clear selection to avoid confusion
canvas.selectedItems.clear()
canvasStore.updateSelectedItems()
// Paste to create duplicates
canvas.pasteFromClipboard({ connectInputs: false })
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const deleteSelection = () => {
const canvas = app.canvas
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
toastStore.add({
severity: 'warn',
summary: t('g.nothingToDelete'),
detail: t('g.selectItemsToDelete'),
life: 3000
})
return
}
canvas.deleteSelected()
canvas.setDirty(true, true)
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const renameSelection = async () => {
const selectedItems = Array.from(canvasStore.selectedItems)
// Handle single node selection
if (selectedItems.length === 1) {
const item = selectedItems[0]
// For nodes, use the title editor
if (item instanceof LGraphNode) {
titleEditorStore.titleEditorTarget = item
return
}
// For other items like groups, use prompt dialog
const currentTitle = 'title' in item ? (item.title as string) : ''
const newTitle = await dialogService.prompt({
title: t('g.rename'),
message: t('g.enterNewName'),
defaultValue: currentTitle
})
if (newTitle && newTitle !== currentTitle) {
if ('title' in item) {
// Type-safe assignment for items with title property
const titledItem = item as { title: string }
titledItem.title = newTitle
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
}
return
}
// Handle multiple selections - batch rename
if (selectedItems.length > 1) {
const baseTitle = await dialogService.prompt({
title: t('g.batchRename'),
message: t('g.enterBaseName'),
defaultValue: 'Item'
})
if (baseTitle) {
selectedItems.forEach((item, index) => {
if ('title' in item) {
// Type-safe assignment for items with title property
const titledItem = item as { title: string }
titledItem.title = `${baseTitle} ${index + 1}`
}
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
return
}
toastStore.add({
severity: 'warn',
summary: t('g.nothingToRename'),
detail: t('g.selectItemsToRename'),
life: 3000
})
}
return {
copySelection,
pasteSelection,
duplicateSelection,
deleteSelection,
renameSelection
}
}

View File

@@ -0,0 +1,144 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import {
LGraphEventMode,
LGraphNode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
export interface NodeSelectionState {
collapsed: boolean
pinned: boolean
bypassed: boolean
}
/**
* Centralized computed selection state + shared helper actions to avoid duplication
* between selection toolbox, context menus, and other UI affordances.
*/
export function useSelectionState() {
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const sidebarTabStore = useSidebarTabStore()
const nodeHelpStore = useNodeHelpStore()
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
const { selectedItems } = storeToRefs(canvasStore)
const selectedNodes = computed(() => {
return selectedItems.value.filter((i) => isLGraphNode(i)) as LGraphNode[]
})
const nodeDef = computed(() => {
if (selectedNodes.value.length !== 1) return null
return nodeDefStore.fromLGraphNode(selectedNodes.value[0])
})
const hasAnySelection = computed(() => selectedItems.value.length > 0)
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
const isSingleNode = computed(
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
)
const isSingleSubgraph = computed(
() =>
isSingleNode.value &&
(selectedItems.value[0] as LGraphNode)?.isSubgraphNode?.()
)
const isSingleImageNode = computed(
() =>
isSingleNode.value && isImageNode(selectedItems.value[0] as LGraphNode)
)
const hasSubgraphs = computed(() =>
selectedItems.value.some((i) => i instanceof SubgraphNode)
)
const hasAny3DNodeSelected = computed(() => {
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
return (
selectedNodes.value.length === 1 &&
selectedNodes.value.some(isLoad3dNode) &&
enable3DViewer
)
})
const hasImageNode = computed(() => isSingleImageNode.value)
const hasOutputNodesSelected = computed(
() => filterOutputNodes(selectedNodes.value).length > 0
)
// Helper function to compute selection flags (reused by both computed and function)
const computeSelectionStatesFromNodes = (
nodes: LGraphNode[]
): NodeSelectionState => {
if (!nodes.length)
return { collapsed: false, pinned: false, bypassed: false }
return {
collapsed: nodes.some((n) => n.flags?.collapsed),
pinned: nodes.some((n) => n.pinned),
bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS)
}
}
const selectedNodesStates = computed<NodeSelectionState>(() =>
computeSelectionStatesFromNodes(selectedNodes.value)
)
// On-demand computation (non-reactive) so callers can fetch fresh flags
const computeSelectionFlags = (): NodeSelectionState =>
computeSelectionStatesFromNodes(selectedNodes.value)
/** Toggle node help sidebar/panel for the single selected node (if any). */
const showNodeHelp = () => {
const def = nodeDef.value
if (!def) return
const isSidebarActive =
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
const currentHelpNode: any = nodeHelpStore.currentHelpNode
const isSameNodeHelpOpen =
isSidebarActive &&
nodeHelpStore.isHelpOpen &&
currentHelpNode &&
currentHelpNode.nodePath === def.nodePath
if (isSameNodeHelpOpen) {
nodeHelpStore.closeHelp()
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
return
}
if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
nodeHelpStore.openHelp(def)
}
return {
selectedItems,
selectedNodes,
nodeDef,
showNodeHelp,
hasAny3DNodeSelected,
hasAnySelection,
hasSingleSelection,
hasMultipleSelection,
isSingleNode,
isSingleSubgraph,
isSingleImageNode,
hasSubgraphs,
hasImageNode,
hasOutputNodesSelected,
selectedNodesStates,
computeSelectionFlags
}
}

View File

@@ -0,0 +1,131 @@
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
/**
* Composable for handling subgraph-related operations
*/
export function useSubgraphOperations() {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const nodeOutputStore = useNodeOutputStore()
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const convertToSubgraph = () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) {
return null
}
const res = graph.convertToSubgraph(canvas.selectedItems)
if (!res) {
return
}
const { node } = res
canvas.select(node)
canvasStore.updateSelectedItems()
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const unpackSubgraph = () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) {
return
}
const selectedItems = Array.from(canvas.selectedItems)
const subgraphNodes = selectedItems.filter(
(item): item is SubgraphNode => item instanceof SubgraphNode
)
if (subgraphNodes.length === 0) {
return
}
subgraphNodes.forEach((subgraphNode) => {
// Revoke any image previews for the subgraph
nodeOutputStore.revokeSubgraphPreviews(subgraphNode)
// Unpack the subgraph
graph.unpackSubgraph(subgraphNode)
})
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const addSubgraphToLibrary = async () => {
const selectedItems = Array.from(canvasStore.selectedItems)
// Handle single node selection like BookmarkButton.vue
if (selectedItems.length === 1) {
const item = selectedItems[0]
if (isLGraphNode(item)) {
const nodeDef = nodeDefStore.fromLGraphNode(item)
if (nodeDef) {
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
return
}
}
}
// Handle multiple nodes - convert to subgraph first then bookmark
const selectedNodes = getSelectedNodes()
if (selectedNodes.length === 0) {
return
}
// Check if selection contains subgraph nodes
const hasSubgraphs = selectedNodes.some(
(node) => node instanceof SubgraphNode
)
if (!hasSubgraphs) {
// Convert regular nodes to subgraph first
convertToSubgraph()
return
}
// For subgraph nodes, bookmark them
let bookmarkedCount = 0
for (const node of selectedNodes) {
if (node instanceof SubgraphNode) {
const nodeDef = nodeDefStore.fromLGraphNode(node)
if (nodeDef) {
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
bookmarkedCount++
}
}
}
}
const isSubgraphSelected = (): boolean => {
const selectedItems = Array.from(canvasStore.selectedItems)
return selectedItems.some((item) => item instanceof SubgraphNode)
}
const hasSelectableNodes = (): boolean => {
return getSelectedNodes().length > 0
}
return {
convertToSubgraph,
unpackSubgraph,
addSubgraphToLibrary,
isSubgraphSelected,
hasSelectableNodes
}
}

View File

@@ -0,0 +1,163 @@
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<string, any> // Component instances
): Promise<void> => {
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<string, any>, // 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
}
}