mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-22 23:39:45 +00:00
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:
committed by
GitHub
parent
f6a115e182
commit
ac107b45ea
22
src/composables/graph/useCanvasRefresh.ts
Normal file
22
src/composables/graph/useCanvasRefresh.ts
Normal 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
|
||||
}
|
||||
}
|
||||
30
src/composables/graph/useFrameNodes.ts
Normal file
30
src/composables/graph/useFrameNodes.ts
Normal 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 }
|
||||
}
|
||||
199
src/composables/graph/useGroupMenuOptions.ts
Normal file
199
src/composables/graph/useGroupMenuOptions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
122
src/composables/graph/useImageMenuOptions.ts
Normal file
122
src/composables/graph/useImageMenuOptions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
186
src/composables/graph/useMoreOptionsMenu.ts
Normal file
186
src/composables/graph/useMoreOptionsMenu.ts
Normal 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
|
||||
}
|
||||
}
|
||||
106
src/composables/graph/useNodeArrangement.ts
Normal file
106
src/composables/graph/useNodeArrangement.ts
Normal 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
|
||||
}
|
||||
}
|
||||
167
src/composables/graph/useNodeCustomization.ts
Normal file
167
src/composables/graph/useNodeCustomization.ts
Normal 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
|
||||
}
|
||||
}
|
||||
128
src/composables/graph/useNodeMenuOptions.ts
Normal file
128
src/composables/graph/useNodeMenuOptions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
68
src/composables/graph/useSelectedNodeActions.ts
Normal file
68
src/composables/graph/useSelectedNodeActions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
147
src/composables/graph/useSelectionMenuOptions.ts
Normal file
147
src/composables/graph/useSelectionMenuOptions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
165
src/composables/graph/useSelectionOperations.ts
Normal file
165
src/composables/graph/useSelectionOperations.ts
Normal 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
|
||||
}
|
||||
}
|
||||
144
src/composables/graph/useSelectionState.ts
Normal file
144
src/composables/graph/useSelectionState.ts
Normal 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
|
||||
}
|
||||
}
|
||||
131
src/composables/graph/useSubgraphOperations.ts
Normal file
131
src/composables/graph/useSubgraphOperations.ts
Normal 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
|
||||
}
|
||||
}
|
||||
163
src/composables/graph/useSubmenuPositioning.ts
Normal file
163
src/composables/graph/useSubmenuPositioning.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user