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>
@@ -46,7 +46,7 @@ test.describe('Node Help', () => {
|
|||||||
|
|
||||||
// Click the help button in the selection toolbox
|
// Click the help button in the selection toolbox
|
||||||
const helpButton = comfyPage.selectionToolbox.locator(
|
const helpButton = comfyPage.selectionToolbox.locator(
|
||||||
'button:has(.pi-question-circle)'
|
'button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
await expect(helpButton).toBeVisible()
|
await expect(helpButton).toBeVisible()
|
||||||
await helpButton.click()
|
await helpButton.click()
|
||||||
@@ -164,7 +164,7 @@ test.describe('Node Help', () => {
|
|||||||
|
|
||||||
// Click help button
|
// Click help button
|
||||||
const helpButton = comfyPage.page.locator(
|
const helpButton = comfyPage.page.locator(
|
||||||
'.selection-toolbox button:has(.pi-question-circle)'
|
'.selection-toolbox button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
await helpButton.click()
|
await helpButton.click()
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ test.describe('Node Help', () => {
|
|||||||
|
|
||||||
// Click help button
|
// Click help button
|
||||||
const helpButton = comfyPage.page.locator(
|
const helpButton = comfyPage.page.locator(
|
||||||
'.selection-toolbox button:has(.pi-question-circle)'
|
'.selection-toolbox button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
await helpButton.click()
|
await helpButton.click()
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ test.describe('Node Help', () => {
|
|||||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||||
|
|
||||||
const helpButton = comfyPage.page.locator(
|
const helpButton = comfyPage.page.locator(
|
||||||
'.selection-toolbox button:has(.pi-question-circle)'
|
'.selection-toolbox button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
await helpButton.click()
|
await helpButton.click()
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ test.describe('Node Help', () => {
|
|||||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||||
|
|
||||||
const helpButton = comfyPage.page.locator(
|
const helpButton = comfyPage.page.locator(
|
||||||
'.selection-toolbox button:has(.pi-question-circle)'
|
'.selection-toolbox button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
await helpButton.click()
|
await helpButton.click()
|
||||||
|
|
||||||
@@ -348,7 +348,7 @@ This is documentation for a custom node.
|
|||||||
}
|
}
|
||||||
|
|
||||||
const helpButton = comfyPage.page.locator(
|
const helpButton = comfyPage.page.locator(
|
||||||
'.selection-toolbox button:has(.pi-question-circle)'
|
'.selection-toolbox button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
if (await helpButton.isVisible()) {
|
if (await helpButton.isVisible()) {
|
||||||
await helpButton.click()
|
await helpButton.click()
|
||||||
@@ -389,7 +389,7 @@ This is documentation for a custom node.
|
|||||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||||
|
|
||||||
const helpButton = comfyPage.page.locator(
|
const helpButton = comfyPage.page.locator(
|
||||||
'.selection-toolbox button:has(.pi-question-circle)'
|
'.selection-toolbox button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
await helpButton.click()
|
await helpButton.click()
|
||||||
|
|
||||||
@@ -456,7 +456,7 @@ This is English documentation.
|
|||||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||||
|
|
||||||
const helpButton = comfyPage.page.locator(
|
const helpButton = comfyPage.page.locator(
|
||||||
'.selection-toolbox button:has(.pi-question-circle)'
|
'.selection-toolbox button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
await helpButton.click()
|
await helpButton.click()
|
||||||
|
|
||||||
@@ -479,7 +479,7 @@ This is English documentation.
|
|||||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||||
|
|
||||||
const helpButton = comfyPage.page.locator(
|
const helpButton = comfyPage.page.locator(
|
||||||
'.selection-toolbox button:has(.pi-question-circle)'
|
'.selection-toolbox button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
await helpButton.click()
|
await helpButton.click()
|
||||||
|
|
||||||
@@ -522,7 +522,7 @@ This is English documentation.
|
|||||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||||
|
|
||||||
const helpButton = comfyPage.page.locator(
|
const helpButton = comfyPage.page.locator(
|
||||||
'.selection-toolbox button:has(.pi-question-circle)'
|
'.selection-toolbox button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
await helpButton.click()
|
await helpButton.click()
|
||||||
|
|
||||||
@@ -538,7 +538,7 @@ This is English documentation.
|
|||||||
|
|
||||||
// Click help button again
|
// Click help button again
|
||||||
const helpButton2 = comfyPage.page.locator(
|
const helpButton2 = comfyPage.page.locator(
|
||||||
'.selection-toolbox button:has(.pi-question-circle)'
|
'.selection-toolbox button[data-testid="info-button"]'
|
||||||
)
|
)
|
||||||
await helpButton2.click()
|
await helpButton2.click()
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,9 @@ test.describe('Remote COMBO Widget', () => {
|
|||||||
await comfyPage.page.keyboard.press('Control+A')
|
await comfyPage.page.keyboard.press('Control+A')
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
comfyPage.page.locator('.selection-toolbox .pi-refresh')
|
comfyPage.page.locator(
|
||||||
|
'.selection-toolbox button[data-testid="refresh-button"]'
|
||||||
|
)
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
177
browser_tests/tests/selectionToolboxSubmenus.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||||
|
|
||||||
|
test.describe('Selection Toolbox - More Options Submenus', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||||
|
await comfyPage.loadWorkflow('nodes/single_ksampler')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.selectNodes(['KSampler'])
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
})
|
||||||
|
|
||||||
|
const openMoreOptions = async (comfyPage: any) => {
|
||||||
|
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
|
||||||
|
if (ksamplerNodes.length === 0) {
|
||||||
|
throw new Error('No KSampler nodes found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag the KSampler to the center of the screen
|
||||||
|
const nodePos = await ksamplerNodes[0].getPosition()
|
||||||
|
const viewportSize = comfyPage.page.viewportSize()
|
||||||
|
const centerX = viewportSize.width / 3
|
||||||
|
const centerY = viewportSize.height / 2
|
||||||
|
await comfyPage.dragAndDrop(
|
||||||
|
{ x: nodePos.x, y: nodePos.y },
|
||||||
|
{ x: centerX, y: centerY }
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await ksamplerNodes[0].click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.page.waitForTimeout(500)
|
||||||
|
|
||||||
|
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
|
||||||
|
const moreOptionsBtn = comfyPage.page.locator(
|
||||||
|
'[data-testid="more-options-button"]'
|
||||||
|
)
|
||||||
|
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
|
||||||
|
|
||||||
|
await comfyPage.page.click('[data-testid="more-options-button"]')
|
||||||
|
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const menuOptionsVisible = await comfyPage.page
|
||||||
|
.getByText('Rename')
|
||||||
|
.isVisible({ timeout: 2000 })
|
||||||
|
.catch(() => false)
|
||||||
|
if (menuOptionsVisible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await moreOptionsBtn.click({ force: true })
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||||
|
.getByText('Rename')
|
||||||
|
.isVisible({ timeout: 2000 })
|
||||||
|
.catch(() => false)
|
||||||
|
if (menuOptionsVisibleAfterClick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Could not open More Options menu - popover not showing')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
|
||||||
|
await openMoreOptions(comfyPage)
|
||||||
|
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
|
||||||
|
exact: true
|
||||||
|
})
|
||||||
|
await expect(nodeInfoButton).toBeVisible()
|
||||||
|
await nodeInfoButton.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
|
||||||
|
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||||
|
const initialShape = await nodeRef.getProperty<number>('shape')
|
||||||
|
|
||||||
|
await openMoreOptions(comfyPage)
|
||||||
|
await comfyPage.page.getByText('Shape', { exact: true }).click()
|
||||||
|
await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
await comfyPage.page.getByText('Box', { exact: true }).click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const newShape = await nodeRef.getProperty<number>('shape')
|
||||||
|
expect(newShape).not.toBe(initialShape)
|
||||||
|
expect(newShape).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('changes node color via Color submenu swatch', async ({ comfyPage }) => {
|
||||||
|
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||||
|
const initialColor = await nodeRef.getProperty<string | undefined>('color')
|
||||||
|
|
||||||
|
await openMoreOptions(comfyPage)
|
||||||
|
await comfyPage.page.getByText('Color', { exact: true }).click()
|
||||||
|
const blueSwatch = comfyPage.page.locator('[title="Blue"]')
|
||||||
|
await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 })
|
||||||
|
await blueSwatch.first().click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const newColor = await nodeRef.getProperty<string | undefined>('color')
|
||||||
|
expect(newColor).toBe('#223')
|
||||||
|
if (initialColor) {
|
||||||
|
expect(newColor).not.toBe(initialColor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renames a node using Rename action', async ({ comfyPage }) => {
|
||||||
|
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||||
|
await openMoreOptions(comfyPage)
|
||||||
|
await comfyPage.page
|
||||||
|
.getByText('Rename', { exact: true })
|
||||||
|
.click({ force: true })
|
||||||
|
const input = comfyPage.page.locator(
|
||||||
|
'.group-title-editor.node-title-editor .editable-text input'
|
||||||
|
)
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill('RenamedNode')
|
||||||
|
await input.press('Enter')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
const newTitle = await nodeRef.getProperty<string>('title')
|
||||||
|
expect(newTitle).toBe('RenamedNode')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('closes More Options menu when clicking outside', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await openMoreOptions(comfyPage)
|
||||||
|
await expect(
|
||||||
|
comfyPage.page.getByText('Rename', { exact: true })
|
||||||
|
).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
|
await comfyPage.page
|
||||||
|
.locator('#graph-canvas')
|
||||||
|
.click({ position: { x: 0, y: 50 }, force: true })
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(
|
||||||
|
comfyPage.page.getByText('Rename', { exact: true })
|
||||||
|
).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('closes More Options menu when clicking the button again (toggle)', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await openMoreOptions(comfyPage)
|
||||||
|
await expect(
|
||||||
|
comfyPage.page.getByText('Rename', { exact: true })
|
||||||
|
).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
const btn = document.querySelector('[data-testid="more-options-button"]')
|
||||||
|
if (btn) {
|
||||||
|
const event = new MouseEvent('click', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
view: window,
|
||||||
|
detail: 1
|
||||||
|
})
|
||||||
|
btn.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.page.waitForTimeout(500)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.page.getByText('Rename', { exact: true })
|
||||||
|
).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
14
src/assets/icons/custom/mask.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="" viewBox="0 0 16 16" fill="none">
|
||||||
|
<g clip-path="url(#clip0_704_2695)">
|
||||||
|
<path d="M6.05048 2C5.52055 7.29512 9.23033 10.4722 14 9.94267" stroke="#9C9EAB" stroke-width="1.3"/>
|
||||||
|
<path d="M6.5 5.5L10 2" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round"/>
|
||||||
|
<path d="M8 8L12.5 3.5" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="square"/>
|
||||||
|
<path d="M10.5 9.5L14 6" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round"/>
|
||||||
|
<path d="M7.99992 14.6667C11.6818 14.6667 14.6666 11.6819 14.6666 8.00004C14.6666 4.31814 11.6818 1.33337 7.99992 1.33337C4.31802 1.33337 1.33325 4.31814 1.33325 8.00004C1.33325 11.6819 4.31802 14.6667 7.99992 14.6667Z" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_704_2695">
|
||||||
|
<rect width="16" height="16" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 938 B |
500
src/components/graph/SelectionToolbox.spec.ts
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||||
|
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||||
|
import { useExtensionService } from '@/services/extensionService'
|
||||||
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
|
||||||
|
// Mock the composables and services
|
||||||
|
vi.mock('@/composables/graph/useCanvasInteractions', () => ({
|
||||||
|
useCanvasInteractions: vi.fn(() => ({
|
||||||
|
handleWheel: vi.fn()
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/canvas/useSelectionToolboxPosition', () => ({
|
||||||
|
useSelectionToolboxPosition: vi.fn(() => ({
|
||||||
|
visible: { value: true }
|
||||||
|
})),
|
||||||
|
resetMoreOptionsState: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/element/useRetriggerableAnimation', () => ({
|
||||||
|
useRetriggerableAnimation: vi.fn(() => ({
|
||||||
|
shouldAnimate: { value: false }
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/renderer/extensions/minimap/composables/useMinimap', () => ({
|
||||||
|
useMinimap: vi.fn(() => ({
|
||||||
|
containerStyles: {
|
||||||
|
value: {
|
||||||
|
backgroundColor: '#ffffff'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/services/extensionService', () => ({
|
||||||
|
useExtensionService: vi.fn(() => ({
|
||||||
|
extensionCommands: { value: new Map() },
|
||||||
|
invokeExtensions: vi.fn(() => [])
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/litegraphUtil', () => ({
|
||||||
|
isLGraphNode: vi.fn(() => true),
|
||||||
|
isImageNode: vi.fn(() => false),
|
||||||
|
isLoad3dNode: vi.fn(() => false)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||||
|
isOutputNode: vi.fn(() => false),
|
||||||
|
filterOutputNodes: vi.fn((nodes) => nodes.filter(() => false))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/settingStore', () => ({
|
||||||
|
useSettingStore: () => ({
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === 'Comfy.Load3D.3DViewerEnable') return true
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/commandStore', () => ({
|
||||||
|
useCommandStore: () => ({
|
||||||
|
getCommand: vi.fn(() => ({ id: 'test-command', title: 'Test Command' }))
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
let nodeDefMock = {
|
||||||
|
type: 'TestNode',
|
||||||
|
title: 'Test Node'
|
||||||
|
} as unknown
|
||||||
|
|
||||||
|
vi.mock('@/stores/nodeDefStore', () => ({
|
||||||
|
useNodeDefStore: () => ({
|
||||||
|
fromLGraphNode: vi.fn(() => nodeDefMock)
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('SelectionToolbox', () => {
|
||||||
|
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
g: {
|
||||||
|
info: 'Node Info',
|
||||||
|
bookmark: 'Save to Library',
|
||||||
|
frameNodes: 'Frame Nodes',
|
||||||
|
moreOptions: 'More Options',
|
||||||
|
refreshNode: 'Refresh Node'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockProvide = {
|
||||||
|
isVisible: { value: true },
|
||||||
|
selectedItems: []
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
canvasStore = useCanvasStore()
|
||||||
|
|
||||||
|
// Mock the canvas to avoid "getCanvas: canvas is null" errors
|
||||||
|
canvasStore.canvas = {
|
||||||
|
setDirty: vi.fn(),
|
||||||
|
state: {
|
||||||
|
selectionChanged: false
|
||||||
|
}
|
||||||
|
} as any
|
||||||
|
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountComponent = (props = {}) => {
|
||||||
|
return mount(SelectionToolbox, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
plugins: [i18n, PrimeVue],
|
||||||
|
provide: {
|
||||||
|
[Symbol.for('SelectionOverlay')]: mockProvide
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
Panel: {
|
||||||
|
template:
|
||||||
|
'<div class="panel selection-toolbox absolute left-1/2 rounded-lg"><slot /></div>',
|
||||||
|
props: ['pt', 'style', 'class']
|
||||||
|
},
|
||||||
|
InfoButton: { template: '<div class="info-button" />' },
|
||||||
|
ColorPickerButton: {
|
||||||
|
template:
|
||||||
|
'<button data-testid="color-picker-button" class="color-picker-button" />'
|
||||||
|
},
|
||||||
|
FrameNodes: { template: '<div class="frame-nodes" />' },
|
||||||
|
PublishButton: {
|
||||||
|
template:
|
||||||
|
'<button data-testid="add-to-library" class="bookmark-button" />'
|
||||||
|
},
|
||||||
|
BypassButton: {
|
||||||
|
template:
|
||||||
|
'<button data-testid="bypass-button" class="bypass-button" />'
|
||||||
|
},
|
||||||
|
PinButton: { template: '<div class="pin-button" />' },
|
||||||
|
Load3DViewerButton: {
|
||||||
|
template: '<div class="load-3d-viewer-button" />'
|
||||||
|
},
|
||||||
|
MaskEditorButton: { template: '<div class="mask-editor-button" />' },
|
||||||
|
DeleteButton: {
|
||||||
|
template:
|
||||||
|
'<button data-testid="delete-button" class="delete-button" />'
|
||||||
|
},
|
||||||
|
RefreshSelectionButton: {
|
||||||
|
template: '<div class="refresh-button" />'
|
||||||
|
},
|
||||||
|
ExecuteButton: { template: '<div class="execute-button" />' },
|
||||||
|
ConvertToSubgraphButton: {
|
||||||
|
template:
|
||||||
|
'<button data-testid="convert-to-subgraph-button" class="convert-to-subgraph-button" />'
|
||||||
|
},
|
||||||
|
ExtensionCommandButton: {
|
||||||
|
template: '<div class="extension-command-button" />'
|
||||||
|
},
|
||||||
|
MoreOptions: {
|
||||||
|
template:
|
||||||
|
'<button data-testid="more-options-button" class="more-options" />'
|
||||||
|
},
|
||||||
|
VerticalDivider: { template: '<div class="vertical-divider" />' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Button Visibility Logic', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const mockExtensionService = vi.mocked(useExtensionService)
|
||||||
|
mockExtensionService.mockReturnValue({
|
||||||
|
extensionCommands: { value: new Map() },
|
||||||
|
invokeExtensions: vi.fn(() => [])
|
||||||
|
} as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show info button only for single selections', () => {
|
||||||
|
// Single node selection
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.find('.info-button').exists()).toBe(true)
|
||||||
|
|
||||||
|
// Multiple node selection
|
||||||
|
canvasStore.selectedItems = [
|
||||||
|
{ type: 'TestNode1' },
|
||||||
|
{ type: 'TestNode2' }
|
||||||
|
] as any
|
||||||
|
wrapper.unmount()
|
||||||
|
const wrapper2 = mountComponent()
|
||||||
|
expect(wrapper2.find('.info-button').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show info button when node definition is not found', () => {
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
// mock nodedef and return null
|
||||||
|
nodeDefMock = null
|
||||||
|
// remount component
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.find('.info-button').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show color picker for all selections', () => {
|
||||||
|
// Single node selection
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.find('[data-testid="color-picker-button"]').exists()).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Multiple node selection
|
||||||
|
canvasStore.selectedItems = [
|
||||||
|
{ type: 'TestNode1' },
|
||||||
|
{ type: 'TestNode2' }
|
||||||
|
] as any
|
||||||
|
wrapper.unmount()
|
||||||
|
const wrapper2 = mountComponent()
|
||||||
|
expect(
|
||||||
|
wrapper2.find('[data-testid="color-picker-button"]').exists()
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show frame nodes only for multiple selections', () => {
|
||||||
|
// Single node selection
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
|
||||||
|
|
||||||
|
// Multiple node selection
|
||||||
|
canvasStore.selectedItems = [
|
||||||
|
{ type: 'TestNode1' },
|
||||||
|
{ type: 'TestNode2' }
|
||||||
|
] as any
|
||||||
|
wrapper.unmount()
|
||||||
|
const wrapper2 = mountComponent()
|
||||||
|
expect(wrapper2.find('.frame-nodes').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show bypass button for appropriate selections', () => {
|
||||||
|
// Single node selection
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.find('[data-testid="bypass-button"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
// Multiple node selection
|
||||||
|
canvasStore.selectedItems = [
|
||||||
|
{ type: 'TestNode1' },
|
||||||
|
{ type: 'TestNode2' }
|
||||||
|
] as any
|
||||||
|
wrapper.unmount()
|
||||||
|
const wrapper2 = mountComponent()
|
||||||
|
expect(wrapper2.find('[data-testid="bypass-button"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show common buttons for all selections', () => {
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="delete-button"]').exists()).toBe(true)
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="convert-to-subgraph-button"]').exists()
|
||||||
|
).toBe(true)
|
||||||
|
expect(wrapper.find('[data-testid="more-options-button"]').exists()).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show mask editor only for single image nodes', async () => {
|
||||||
|
const mockUtils = await import('@/utils/litegraphUtil')
|
||||||
|
const isImageNodeSpy = vi.spyOn(mockUtils, 'isImageNode')
|
||||||
|
|
||||||
|
// Single image node
|
||||||
|
isImageNodeSpy.mockReturnValue(true)
|
||||||
|
canvasStore.selectedItems = [{ type: 'ImageNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.find('.mask-editor-button').exists()).toBe(true)
|
||||||
|
|
||||||
|
// Single non-image node
|
||||||
|
isImageNodeSpy.mockReturnValue(false)
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
wrapper.unmount()
|
||||||
|
const wrapper2 = mountComponent()
|
||||||
|
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show Color picker button only for single Load3D nodes', async () => {
|
||||||
|
const mockUtils = await import('@/utils/litegraphUtil')
|
||||||
|
const isLoad3dNodeSpy = vi.spyOn(mockUtils, 'isLoad3dNode')
|
||||||
|
|
||||||
|
// Single Load3D node
|
||||||
|
isLoad3dNodeSpy.mockReturnValue(true)
|
||||||
|
canvasStore.selectedItems = [{ type: 'Load3DNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.find('.load-3d-viewer-button').exists()).toBe(true)
|
||||||
|
|
||||||
|
// Single non-Load3D node
|
||||||
|
isLoad3dNodeSpy.mockReturnValue(false)
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
wrapper.unmount()
|
||||||
|
const wrapper2 = mountComponent()
|
||||||
|
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show ExecuteButton only when output nodes are selected', async () => {
|
||||||
|
const mockNodeFilterUtil = await import('@/utils/nodeFilterUtil')
|
||||||
|
const isOutputNodeSpy = vi.spyOn(mockNodeFilterUtil, 'isOutputNode')
|
||||||
|
const filterOutputNodesSpy = vi.spyOn(
|
||||||
|
mockNodeFilterUtil,
|
||||||
|
'filterOutputNodes'
|
||||||
|
)
|
||||||
|
|
||||||
|
// With output node selected
|
||||||
|
isOutputNodeSpy.mockReturnValue(true)
|
||||||
|
filterOutputNodesSpy.mockReturnValue([{ type: 'SaveImage' }] as any)
|
||||||
|
canvasStore.selectedItems = [
|
||||||
|
{ type: 'SaveImage', constructor: { nodeData: { output_node: true } } }
|
||||||
|
] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.find('.execute-button').exists()).toBe(true)
|
||||||
|
|
||||||
|
// Without output node selected
|
||||||
|
isOutputNodeSpy.mockReturnValue(false)
|
||||||
|
filterOutputNodesSpy.mockReturnValue([])
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
wrapper.unmount()
|
||||||
|
const wrapper2 = mountComponent()
|
||||||
|
expect(wrapper2.find('.execute-button').exists()).toBe(false)
|
||||||
|
|
||||||
|
// No selection at all
|
||||||
|
canvasStore.selectedItems = []
|
||||||
|
wrapper2.unmount()
|
||||||
|
const wrapper3 = mountComponent()
|
||||||
|
expect(wrapper3.find('.execute-button').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Divider Visibility Logic', () => {
|
||||||
|
it('should show dividers between button groups when both groups have buttons', () => {
|
||||||
|
// Setup single node to show info + other buttons
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const dividers = wrapper.findAll('.vertical-divider')
|
||||||
|
expect(dividers.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show dividers when adjacent groups are empty', () => {
|
||||||
|
// No selection should show minimal buttons and dividers
|
||||||
|
canvasStore.selectedItems = []
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const buttons = wrapper.find('.panel').element.children
|
||||||
|
expect(buttons.length).toBeGreaterThan(0) // At least MoreOptions should show
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Extension Commands', () => {
|
||||||
|
it('should render extension command buttons when available', () => {
|
||||||
|
const mockExtensionService = vi.mocked(useExtensionService)
|
||||||
|
mockExtensionService.mockReturnValue({
|
||||||
|
extensionCommands: {
|
||||||
|
value: new Map([
|
||||||
|
['test-command', { id: 'test-command', title: 'Test Command' }]
|
||||||
|
])
|
||||||
|
},
|
||||||
|
invokeExtensions: vi.fn(() => ['test-command'])
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.find('.extension-command-button').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render extension commands when none available', () => {
|
||||||
|
const mockExtensionService = vi.mocked(useExtensionService)
|
||||||
|
mockExtensionService.mockReturnValue({
|
||||||
|
extensionCommands: { value: new Map() },
|
||||||
|
invokeExtensions: vi.fn(() => [])
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.find('.extension-command-button').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Container Styling', () => {
|
||||||
|
it('should apply minimap container styles', () => {
|
||||||
|
const mockExtensionService = vi.mocked(useExtensionService)
|
||||||
|
mockExtensionService.mockReturnValue({
|
||||||
|
extensionCommands: { value: new Map() },
|
||||||
|
invokeExtensions: vi.fn(() => [])
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const panel = wrapper.find('.panel')
|
||||||
|
expect(panel.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct CSS classes', () => {
|
||||||
|
const mockExtensionService = vi.mocked(useExtensionService)
|
||||||
|
mockExtensionService.mockReturnValue({
|
||||||
|
extensionCommands: { value: new Map() },
|
||||||
|
invokeExtensions: vi.fn(() => [])
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const panel = wrapper.find('.panel')
|
||||||
|
expect(panel.classes()).toContain('selection-toolbox')
|
||||||
|
expect(panel.classes()).toContain('absolute')
|
||||||
|
expect(panel.classes()).toContain('left-1/2')
|
||||||
|
expect(panel.classes()).toContain('rounded-lg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle animation class conditionally', () => {
|
||||||
|
const mockExtensionService = vi.mocked(useExtensionService)
|
||||||
|
mockExtensionService.mockReturnValue({
|
||||||
|
extensionCommands: { value: new Map() },
|
||||||
|
invokeExtensions: vi.fn(() => [])
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const panel = wrapper.find('.panel')
|
||||||
|
expect(panel.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Event Handling', () => {
|
||||||
|
it('should handle wheel events', async () => {
|
||||||
|
const mockCanvasInteractions = vi.mocked(useCanvasInteractions)
|
||||||
|
const handleWheelSpy = vi.fn()
|
||||||
|
mockCanvasInteractions.mockReturnValue({
|
||||||
|
handleWheel: handleWheelSpy
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const mockExtensionService = vi.mocked(useExtensionService)
|
||||||
|
mockExtensionService.mockReturnValue({
|
||||||
|
extensionCommands: { value: new Map() },
|
||||||
|
invokeExtensions: vi.fn(() => [])
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const panel = wrapper.find('.panel')
|
||||||
|
await panel.trigger('wheel')
|
||||||
|
|
||||||
|
expect(handleWheelSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('No Selection State', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const mockExtensionService = vi.mocked(useExtensionService)
|
||||||
|
mockExtensionService.mockReturnValue({
|
||||||
|
extensionCommands: { value: new Map() },
|
||||||
|
invokeExtensions: vi.fn(() => [])
|
||||||
|
} as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should still show MoreOptions when no items selected', () => {
|
||||||
|
canvasStore.selectedItems = []
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.find('.more-options').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide most buttons when no items selected', () => {
|
||||||
|
canvasStore.selectedItems = []
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.find('.info-button').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.color-picker-button').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.bookmark-button').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,28 +8,37 @@
|
|||||||
<Panel
|
<Panel
|
||||||
v-if="visible"
|
v-if="visible"
|
||||||
class="rounded-lg selection-toolbox pointer-events-auto"
|
class="rounded-lg selection-toolbox pointer-events-auto"
|
||||||
|
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
|
||||||
:pt="{
|
:pt="{
|
||||||
header: 'hidden',
|
header: 'hidden',
|
||||||
content: 'p-0 flex flex-row'
|
content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1'
|
||||||
}"
|
}"
|
||||||
@wheel="canvasInteractions.handleWheel"
|
@wheel="canvasInteractions.handleWheel"
|
||||||
>
|
>
|
||||||
<ExecuteButton />
|
<DeleteButton v-if="showDelete" />
|
||||||
<ColorPickerButton />
|
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
|
||||||
<BypassButton />
|
<InfoButton v-if="showInfoButton" />
|
||||||
<PinButton />
|
|
||||||
<Load3DViewerButton />
|
<ColorPickerButton v-if="showColorPicker" />
|
||||||
<MaskEditorButton />
|
<FrameNodes v-if="showFrameNodes" />
|
||||||
<ConvertToSubgraphButton />
|
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
|
||||||
<PublishSubgraphButton />
|
<PublishSubgraphButton v-if="showPublishSubgraph" />
|
||||||
<DeleteButton />
|
<MaskEditorButton v-if="showMaskEditor" />
|
||||||
<RefreshSelectionButton />
|
<VerticalDivider
|
||||||
|
v-if="showAnyPrimaryActions && showAnyControlActions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BypassButton v-if="showBypass" />
|
||||||
|
<RefreshSelectionButton v-if="showRefresh" />
|
||||||
|
<Load3DViewerButton v-if="showLoad3DViewer" />
|
||||||
|
|
||||||
<ExtensionCommandButton
|
<ExtensionCommandButton
|
||||||
v-for="command in extensionToolboxCommands"
|
v-for="command in extensionToolboxCommands"
|
||||||
:key="command.id"
|
:key="command.id"
|
||||||
:command="command"
|
:command="command"
|
||||||
/>
|
/>
|
||||||
<HelpButton />
|
<ExecuteButton v-if="showExecute" />
|
||||||
|
<MoreOptions />
|
||||||
</Panel>
|
</Panel>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,22 +54,29 @@ import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/Convert
|
|||||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||||
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
|
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
|
||||||
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
|
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
|
||||||
import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewerButton.vue'
|
import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewerButton.vue'
|
||||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||||
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
|
|
||||||
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
|
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
|
||||||
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
|
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
|
||||||
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
|
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
|
||||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||||
|
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||||
|
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||||
import { useExtensionService } from '@/services/extensionService'
|
import { useExtensionService } from '@/services/extensionService'
|
||||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
|
||||||
|
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
||||||
|
import MoreOptions from './selectionToolbox/MoreOptions.vue'
|
||||||
|
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
||||||
|
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const extensionService = useExtensionService()
|
const extensionService = useExtensionService()
|
||||||
const canvasInteractions = useCanvasInteractions()
|
const canvasInteractions = useCanvasInteractions()
|
||||||
|
const minimap = useMinimap()
|
||||||
|
const containerStyles = minimap.containerStyles
|
||||||
|
|
||||||
const toolboxRef = ref<HTMLElement | undefined>()
|
const toolboxRef = ref<HTMLElement | undefined>()
|
||||||
const { visible } = useSelectionToolboxPosition(toolboxRef)
|
const { visible } = useSelectionToolboxPosition(toolboxRef)
|
||||||
@@ -80,6 +96,44 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
|||||||
.map((commandId) => commandStore.getCommand(commandId))
|
.map((commandId) => commandStore.getCommand(commandId))
|
||||||
.filter((command): command is ComfyCommandImpl => command !== undefined)
|
.filter((command): command is ComfyCommandImpl => command !== undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
hasAnySelection,
|
||||||
|
hasMultipleSelection,
|
||||||
|
isSingleNode,
|
||||||
|
isSingleSubgraph,
|
||||||
|
isSingleImageNode,
|
||||||
|
hasAny3DNodeSelected,
|
||||||
|
hasOutputNodesSelected,
|
||||||
|
nodeDef
|
||||||
|
} = useSelectionState()
|
||||||
|
const showInfoButton = computed(() => !!nodeDef.value)
|
||||||
|
|
||||||
|
const showColorPicker = computed(() => hasAnySelection.value)
|
||||||
|
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||||
|
const showFrameNodes = computed(() => hasMultipleSelection.value)
|
||||||
|
const showPublishSubgraph = computed(() => isSingleSubgraph.value)
|
||||||
|
|
||||||
|
const showBypass = computed(
|
||||||
|
() =>
|
||||||
|
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
|
||||||
|
)
|
||||||
|
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
|
||||||
|
const showMaskEditor = computed(() => isSingleImageNode.value)
|
||||||
|
|
||||||
|
const showDelete = computed(() => hasAnySelection.value)
|
||||||
|
const showRefresh = computed(() => hasAnySelection.value)
|
||||||
|
const showExecute = computed(() => hasOutputNodesSelected.value)
|
||||||
|
|
||||||
|
const showAnyPrimaryActions = computed(
|
||||||
|
() =>
|
||||||
|
showColorPicker.value ||
|
||||||
|
showConvertToSubgraph.value ||
|
||||||
|
showFrameNodes.value ||
|
||||||
|
showPublishSubgraph.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const showAnyControlActions = computed(() => showBypass.value)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
120
src/components/graph/selectionToolbox/BypassButton.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import Tooltip from 'primevue/tooltip'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||||
|
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
|
||||||
|
const mockLGraphNode = {
|
||||||
|
type: 'TestNode',
|
||||||
|
title: 'Test Node',
|
||||||
|
mode: LGraphEventMode.ALWAYS
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@/utils/litegraphUtil', () => ({
|
||||||
|
isLGraphNode: vi.fn(() => true)
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('BypassButton', () => {
|
||||||
|
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||||
|
let commandStore: ReturnType<typeof useCommandStore>
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
selectionToolbox: {
|
||||||
|
bypassButton: {
|
||||||
|
tooltip: 'Toggle bypass mode'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
canvasStore = useCanvasStore()
|
||||||
|
commandStore = useCommandStore()
|
||||||
|
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountComponent = () => {
|
||||||
|
return mount(BypassButton, {
|
||||||
|
global: {
|
||||||
|
plugins: [i18n, PrimeVue],
|
||||||
|
directives: { tooltip: Tooltip },
|
||||||
|
stubs: {
|
||||||
|
'i-lucide:ban': true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should render bypass button', () => {
|
||||||
|
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct test id', () => {
|
||||||
|
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const button = wrapper.find('[data-testid="bypass-button"]')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute bypass command when clicked', async () => {
|
||||||
|
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||||
|
const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue()
|
||||||
|
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
|
||||||
|
expect(executeSpy).toHaveBeenCalledWith(
|
||||||
|
'Comfy.Canvas.ToggleSelectedNodes.Bypass'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show normal styling when node is not bypassed', () => {
|
||||||
|
const normalNode = { ...mockLGraphNode, mode: LGraphEventMode.ALWAYS }
|
||||||
|
canvasStore.selectedItems = [normalNode] as any
|
||||||
|
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
|
||||||
|
expect(button.classes()).not.toContain(
|
||||||
|
'dark-theme:[&:not(:active)]:!bg-[#262729]'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show bypassed styling when node is bypassed', async () => {
|
||||||
|
const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS }
|
||||||
|
canvasStore.selectedItems = [bypassedNode] as any
|
||||||
|
vi.spyOn(commandStore, 'execute').mockResolvedValue()
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
// Click to trigger the reactivity update
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple selected items', () => {
|
||||||
|
vi.spyOn(commandStore, 'execute').mockResolvedValue()
|
||||||
|
canvasStore.selectedItems = [mockLGraphNode, mockLGraphNode] as any
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button
|
<Button
|
||||||
v-show="canvasStore.nodeSelected"
|
|
||||||
v-tooltip.top="{
|
v-tooltip.top="{
|
||||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
|
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
|
||||||
showDelay: 1000
|
showDelay: 1000
|
||||||
@@ -8,12 +7,11 @@
|
|||||||
severity="secondary"
|
severity="secondary"
|
||||||
text
|
text
|
||||||
data-testid="bypass-button"
|
data-testid="bypass-button"
|
||||||
@click="
|
class="hover:dark-theme:bg-charcoal-300 hover:bg-[#E7E6E6]"
|
||||||
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
|
@click="toggleBypass"
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<i-game-icons:detour />
|
<i-lucide:ban class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
@@ -23,9 +21,11 @@ import Button from 'primevue/button'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const canvasStore = useCanvasStore()
|
|
||||||
|
const toggleBypass = async () => {
|
||||||
|
await commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -95,17 +95,6 @@ describe('ColorPickerButton', () => {
|
|||||||
expect(wrapper.find('button').exists()).toBe(true)
|
expect(wrapper.find('button').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not render when nothing is selected', () => {
|
|
||||||
// Keep selectedItems empty
|
|
||||||
canvasStore.selectedItems = []
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
// The button exists but is hidden with v-show
|
|
||||||
expect(wrapper.find('button').exists()).toBe(true)
|
|
||||||
expect(wrapper.find('button').attributes('style')).toContain(
|
|
||||||
'display: none'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should toggle color picker visibility on button click', async () => {
|
it('should toggle color picker visibility on button click', async () => {
|
||||||
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
|
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
|
||||||
const wrapper = createWrapper()
|
const wrapper = createWrapper()
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Button
|
<Button
|
||||||
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
|
|
||||||
v-tooltip.top="{
|
v-tooltip.top="{
|
||||||
value: localizedCurrentColorName ?? t('color.noColor'),
|
value: localizedCurrentColorName ?? t('color.noColor'),
|
||||||
showDelay: 512
|
showDelay: 1000
|
||||||
}"
|
}"
|
||||||
|
data-testid="color-picker-button"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
text
|
text
|
||||||
@click="() => (showColorPicker = !showColorPicker)"
|
@click="() => (showColorPicker = !showColorPicker)"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<div class="flex items-center gap-1 px-0">
|
||||||
<div class="flex items-center gap-1">
|
<i
|
||||||
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
|
class="w-4 h-4 pi pi-circle-fill"
|
||||||
<i class="pi pi-chevron-down" :style="{ fontSize: '0.5rem' }" />
|
:style="{ color: currentColor ?? '' }"
|
||||||
</div>
|
/>
|
||||||
</template>
|
<i
|
||||||
|
class="w-4 h-4 pi pi-chevron-down py-1"
|
||||||
|
:style="{ fontSize: '0.5rem' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
<div
|
<div
|
||||||
v-if="showColorPicker"
|
v-if="showColorPicker"
|
||||||
@@ -46,10 +50,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import SelectButton from 'primevue/selectbutton'
|
import SelectButton from 'primevue/selectbutton'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { Raw, computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import type { ColorOption as CanvasColorOption } from '@/lib/litegraph/src/litegraph'
|
import type {
|
||||||
|
ColorOption as CanvasColorOption,
|
||||||
|
Positionable
|
||||||
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
import {
|
import {
|
||||||
LGraphCanvas,
|
LGraphCanvas,
|
||||||
LiteGraph,
|
LiteGraph,
|
||||||
@@ -140,13 +147,17 @@ const localizedCurrentColorName = computed(() => {
|
|||||||
)
|
)
|
||||||
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
|
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
|
||||||
})
|
})
|
||||||
|
const updateColorSelectionFromNode = (
|
||||||
|
newSelectedItems: Raw<Positionable[]>
|
||||||
|
) => {
|
||||||
|
showColorPicker.value = false
|
||||||
|
selectedColorOption.value = null
|
||||||
|
currentColorOption.value = getItemsColorOption(newSelectedItems)
|
||||||
|
}
|
||||||
watch(
|
watch(
|
||||||
() => canvasStore.selectedItems,
|
() => canvasStore.selectedItems,
|
||||||
(newSelectedItems) => {
|
(newSelectedItems) => {
|
||||||
showColorPicker.value = false
|
updateColorSelectionFromNode(newSelectedItems)
|
||||||
selectedColorOption.value = null
|
|
||||||
currentColorOption.value = getItemsColorOption(newSelectedItems)
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
showDelay: 1000
|
showDelay: 1000
|
||||||
}"
|
}"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
|
data-testid="convert-to-subgraph-button"
|
||||||
text
|
text
|
||||||
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
|
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<i-lucide:expand />
|
<i-lucide:expand class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
showDelay: 1000
|
showDelay: 1000
|
||||||
}"
|
}"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
|
data-testid="convert-to-subgraph-button"
|
||||||
text
|
text
|
||||||
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
|
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
|
||||||
>
|
>
|
||||||
@@ -34,25 +36,15 @@ import Button from 'primevue/button'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const canvasStore = useCanvasStore()
|
const { isSingleSubgraph, hasAnySelection } = useSelectionState()
|
||||||
|
|
||||||
const isUnpackVisible = computed(() => {
|
const isUnpackVisible = isSingleSubgraph
|
||||||
return (
|
const isConvertVisible = computed(
|
||||||
canvasStore.selectedItems?.length === 1 &&
|
() => hasAnySelection.value && !isSingleSubgraph.value
|
||||||
canvasStore.selectedItems[0] instanceof SubgraphNode
|
)
|
||||||
)
|
|
||||||
})
|
|
||||||
const isConvertVisible = computed(() => {
|
|
||||||
return (
|
|
||||||
canvasStore.groupSelected ||
|
|
||||||
canvasStore.rerouteSelected ||
|
|
||||||
canvasStore.nodeSelected
|
|
||||||
)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||||
showDelay: 1000
|
showDelay: 1000
|
||||||
}"
|
}"
|
||||||
severity="danger"
|
severity="secondary"
|
||||||
text
|
text
|
||||||
|
icon-class="w-4 h-4"
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
|
data-testid="delete-button"
|
||||||
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
|
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -17,14 +19,15 @@ import Button from 'primevue/button'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||||
|
import { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const canvasStore = useCanvasStore()
|
const { selectedItems } = useSelectionState()
|
||||||
|
|
||||||
const isDeletable = computed(() =>
|
const isDeletable = computed(() =>
|
||||||
canvasStore.selectedItems.some((x) => x.removable !== false)
|
selectedItems.value.some((x: Positionable) => x.removable !== false)
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
128
src/components/graph/selectionToolbox/ExecuteButton.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import Tooltip from 'primevue/tooltip'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||||
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
|
||||||
|
// Mock the stores
|
||||||
|
vi.mock('@/stores/graphStore', () => ({
|
||||||
|
useCanvasStore: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/commandStore', () => ({
|
||||||
|
useCommandStore: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock the utils
|
||||||
|
vi.mock('@/utils/litegraphUtil', () => ({
|
||||||
|
isLGraphNode: vi.fn((node) => !!node?.type)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||||
|
isOutputNode: vi.fn((node) => !!node?.constructor?.nodeData?.output_node)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock the composables
|
||||||
|
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||||
|
useSelectionState: vi.fn(() => ({
|
||||||
|
selectedNodes: {
|
||||||
|
value: []
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ExecuteButton', () => {
|
||||||
|
let mockCanvas: any
|
||||||
|
let mockCanvasStore: any
|
||||||
|
let mockCommandStore: any
|
||||||
|
let mockSelectedNodes: any[]
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
selectionToolbox: {
|
||||||
|
executeButton: {
|
||||||
|
tooltip: 'Execute selected nodes'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
|
||||||
|
// Reset mocks
|
||||||
|
mockCanvas = {
|
||||||
|
setDirty: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSelectedNodes = []
|
||||||
|
|
||||||
|
mockCanvasStore = {
|
||||||
|
getCanvas: vi.fn(() => mockCanvas),
|
||||||
|
selectedItems: []
|
||||||
|
}
|
||||||
|
|
||||||
|
mockCommandStore = {
|
||||||
|
execute: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup store mocks
|
||||||
|
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore as any)
|
||||||
|
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
|
||||||
|
|
||||||
|
// Update the useSelectionState mock
|
||||||
|
const { useSelectionState } = vi.mocked(
|
||||||
|
await import('@/composables/graph/useSelectionState')
|
||||||
|
)
|
||||||
|
useSelectionState.mockReturnValue({
|
||||||
|
selectedNodes: {
|
||||||
|
value: mockSelectedNodes
|
||||||
|
}
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountComponent = () => {
|
||||||
|
return mount(ExecuteButton, {
|
||||||
|
global: {
|
||||||
|
plugins: [i18n, PrimeVue],
|
||||||
|
directives: { tooltip: Tooltip },
|
||||||
|
stubs: {
|
||||||
|
'i-lucide:play': { template: '<div class="play-icon" />' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should be able to render', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Click Handler', () => {
|
||||||
|
it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
|
||||||
|
await button.trigger('click')
|
||||||
|
|
||||||
|
expect(mockCommandStore.execute).toHaveBeenCalledWith(
|
||||||
|
'Comfy.QueueSelectedOutputNodes'
|
||||||
|
)
|
||||||
|
expect(mockCommandStore.execute).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button
|
<Button
|
||||||
v-show="canvasStore.nodeSelected"
|
|
||||||
v-tooltip.top="{
|
v-tooltip.top="{
|
||||||
value: isDisabled
|
value: t('selectionToolbox.executeButton.tooltip'),
|
||||||
? t('selectionToolbox.executeButton.disabledTooltip')
|
|
||||||
: t('selectionToolbox.executeButton.tooltip'),
|
|
||||||
showDelay: 1000
|
showDelay: 1000
|
||||||
}"
|
}"
|
||||||
:severity="isDisabled ? 'secondary' : 'success'"
|
class="dark-theme:bg-[#0B8CE9] bg-[#31B9F4] size-8 !p-0"
|
||||||
text
|
text
|
||||||
:disabled="isDisabled"
|
|
||||||
@mouseenter="() => handleMouseEnter()"
|
@mouseenter="() => handleMouseEnter()"
|
||||||
@mouseleave="() => handleMouseLeave()"
|
@mouseleave="() => handleMouseLeave()"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<i-lucide:play />
|
<i-lucide:play class="fill-path-white w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -23,26 +19,24 @@ import Button from 'primevue/button'
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||||
|
import { isOutputNode } from '@/utils/nodeFilterUtil'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const canvasStore = useCanvasStore()
|
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
|
const canvasStore = useCanvasStore()
|
||||||
|
const { selectedNodes } = useSelectionState()
|
||||||
|
|
||||||
const canvas = canvasStore.getCanvas()
|
const canvas = canvasStore.getCanvas()
|
||||||
const buttonHovered = ref(false)
|
const buttonHovered = ref(false)
|
||||||
const selectedOutputNodes = computed(
|
const selectedOutputNodes = computed(() =>
|
||||||
() =>
|
selectedNodes.value.filter(isLGraphNode).filter(isOutputNode)
|
||||||
canvasStore.selectedItems.filter(
|
|
||||||
(item) => isLGraphNode(item) && item.constructor.nodeData?.output_node
|
|
||||||
) as LGraphNode[]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const isDisabled = computed(() => selectedOutputNodes.value.length === 0)
|
|
||||||
|
|
||||||
function outputNodeStokeStyle(this: LGraphNode) {
|
function outputNodeStokeStyle(this: LGraphNode) {
|
||||||
if (
|
if (
|
||||||
this.selected &&
|
this.selected &&
|
||||||
@@ -70,3 +64,9 @@ const handleClick = async () => {
|
|||||||
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
|
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
:deep.fill-path-white > path {
|
||||||
|
fill: white;
|
||||||
|
stroke: unset;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
}"
|
}"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
text
|
text
|
||||||
|
icon-class="w-4 h-4"
|
||||||
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
|
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
|
||||||
@click="() => commandStore.execute(command.id)"
|
@click="() => commandStore.execute(command.id)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
22
src/components/graph/selectionToolbox/FrameNodes.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
v-tooltip.top="{
|
||||||
|
value: $t('g.frameNodes'),
|
||||||
|
showDelay: 1000
|
||||||
|
}"
|
||||||
|
class="frame-nodes-button"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
@click="frameNodes"
|
||||||
|
>
|
||||||
|
<i-lucide:frame class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
|
||||||
|
import { useFrameNodes } from '@/composables/graph/useFrameNodes'
|
||||||
|
|
||||||
|
const { frameNodes } = useFrameNodes()
|
||||||
|
</script>
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Button
|
|
||||||
v-show="nodeDef"
|
|
||||||
v-tooltip.top="{
|
|
||||||
value: $t('g.help'),
|
|
||||||
showDelay: 1000
|
|
||||||
}"
|
|
||||||
class="help-button"
|
|
||||||
text
|
|
||||||
icon="pi pi-question-circle"
|
|
||||||
severity="secondary"
|
|
||||||
@click="showHelp"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Button from 'primevue/button'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
|
||||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
|
||||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
|
||||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
|
||||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
|
||||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
|
||||||
|
|
||||||
const canvasStore = useCanvasStore()
|
|
||||||
const nodeDefStore = useNodeDefStore()
|
|
||||||
const sidebarTabStore = useSidebarTabStore()
|
|
||||||
const nodeHelpStore = useNodeHelpStore()
|
|
||||||
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
|
|
||||||
|
|
||||||
const nodeDef = computed<ComfyNodeDefImpl | null>(() => {
|
|
||||||
if (canvasStore.selectedItems.length !== 1) return null
|
|
||||||
const item = canvasStore.selectedItems[0]
|
|
||||||
if (!isLGraphNode(item)) return null
|
|
||||||
return nodeDefStore.fromLGraphNode(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
const showHelp = () => {
|
|
||||||
const def = nodeDef.value
|
|
||||||
if (!def) return
|
|
||||||
if (sidebarTabStore.activeSidebarTabId !== nodeLibraryTabId) {
|
|
||||||
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
|
||||||
}
|
|
||||||
nodeHelpStore.openHelp(def)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
149
src/components/graph/selectionToolbox/InfoButton.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import Tooltip from 'primevue/tooltip'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
|
||||||
|
// NOTE: The component import must come after mocks so they take effect.
|
||||||
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||||
|
|
||||||
|
const mockLGraphNode = {
|
||||||
|
type: 'TestNode',
|
||||||
|
title: 'Test Node'
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@/utils/litegraphUtil', () => ({
|
||||||
|
isLGraphNode: vi.fn(() => true)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
|
||||||
|
useNodeLibrarySidebarTab: () => ({
|
||||||
|
id: 'node-library'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const openHelpMock = vi.fn()
|
||||||
|
const closeHelpMock = vi.fn()
|
||||||
|
const nodeHelpState: { currentHelpNode: any } = { currentHelpNode: null }
|
||||||
|
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
|
||||||
|
useNodeHelpStore: () => ({
|
||||||
|
openHelp: (def: any) => {
|
||||||
|
nodeHelpState.currentHelpNode = def
|
||||||
|
openHelpMock(def)
|
||||||
|
},
|
||||||
|
closeHelp: () => {
|
||||||
|
nodeHelpState.currentHelpNode = null
|
||||||
|
closeHelpMock()
|
||||||
|
},
|
||||||
|
get currentHelpNode() {
|
||||||
|
return nodeHelpState.currentHelpNode
|
||||||
|
},
|
||||||
|
get isHelpOpen() {
|
||||||
|
return nodeHelpState.currentHelpNode !== null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const toggleSidebarTabMock = vi.fn((id: string) => {
|
||||||
|
sidebarState.activeSidebarTabId =
|
||||||
|
sidebarState.activeSidebarTabId === id ? null : id
|
||||||
|
})
|
||||||
|
const sidebarState: { activeSidebarTabId: string | null } = {
|
||||||
|
activeSidebarTabId: 'other-tab'
|
||||||
|
}
|
||||||
|
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||||
|
useSidebarTabStore: () => ({
|
||||||
|
get activeSidebarTabId() {
|
||||||
|
return sidebarState.activeSidebarTabId
|
||||||
|
},
|
||||||
|
toggleSidebarTab: toggleSidebarTabMock
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('InfoButton', () => {
|
||||||
|
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||||
|
let nodeDefStore: ReturnType<typeof useNodeDefStore>
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
g: {
|
||||||
|
info: 'Node Info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
canvasStore = useCanvasStore()
|
||||||
|
nodeDefStore = useNodeDefStore()
|
||||||
|
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountComponent = () => {
|
||||||
|
return mount(InfoButton, {
|
||||||
|
global: {
|
||||||
|
plugins: [i18n, PrimeVue],
|
||||||
|
directives: { tooltip: Tooltip },
|
||||||
|
stubs: {
|
||||||
|
'i-lucide:info': true,
|
||||||
|
Button: {
|
||||||
|
template:
|
||||||
|
'<button class="help-button" severity="secondary"><slot /></button>',
|
||||||
|
props: ['severity', 'text', 'class'],
|
||||||
|
emits: ['click']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should handle click without errors', async () => {
|
||||||
|
const mockNodeDef = {
|
||||||
|
nodePath: 'test/node',
|
||||||
|
display_name: 'Test Node'
|
||||||
|
}
|
||||||
|
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||||
|
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
await button.trigger('click')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct CSS classes', () => {
|
||||||
|
const mockNodeDef = {
|
||||||
|
nodePath: 'test/node',
|
||||||
|
display_name: 'Test Node'
|
||||||
|
}
|
||||||
|
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||||
|
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
|
||||||
|
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
|
||||||
|
expect(button.classes()).toContain('help-button')
|
||||||
|
expect(button.attributes('severity')).toBe('secondary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct tooltip', () => {
|
||||||
|
const mockNodeDef = {
|
||||||
|
nodePath: 'test/node',
|
||||||
|
display_name: 'Test Node'
|
||||||
|
}
|
||||||
|
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||||
|
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
|
||||||
|
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
22
src/components/graph/selectionToolbox/InfoButton.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
v-tooltip.top="{
|
||||||
|
value: $t('g.info'),
|
||||||
|
showDelay: 1000
|
||||||
|
}"
|
||||||
|
data-testid="info-button"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
@click="toggleHelp"
|
||||||
|
>
|
||||||
|
<i-lucide:info class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
|
||||||
|
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||||
|
|
||||||
|
const { showNodeHelp: toggleHelp } = useSelectionState()
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button
|
<Button
|
||||||
v-show="is3DNode"
|
|
||||||
v-tooltip.top="{
|
v-tooltip.top="{
|
||||||
value: t('commands.Comfy_3DViewer_Open3DViewer.label'),
|
value: t('commands.Comfy_3DViewer_Open3DViewer.label'),
|
||||||
showDelay: 1000
|
showDelay: 1000
|
||||||
@@ -8,29 +7,18 @@
|
|||||||
severity="secondary"
|
severity="secondary"
|
||||||
text
|
text
|
||||||
icon="pi pi-pencil"
|
icon="pi pi-pencil"
|
||||||
|
icon-class="w-4 h-4"
|
||||||
@click="open3DViewer"
|
@click="open3DViewer"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
|
||||||
import { useSettingStore } from '@/stores/settingStore'
|
|
||||||
import { isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
|
|
||||||
|
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const canvasStore = useCanvasStore()
|
|
||||||
|
|
||||||
const is3DNode = computed(() => {
|
|
||||||
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
|
|
||||||
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
|
|
||||||
|
|
||||||
return nodes.length === 1 && nodes.some(isLoad3dNode) && enable3DViewer
|
|
||||||
})
|
|
||||||
|
|
||||||
const open3DViewer = () => {
|
const open3DViewer = () => {
|
||||||
void commandStore.execute('Comfy.3DViewer.Open3DViewer')
|
void commandStore.execute('Comfy.3DViewer.Open3DViewer')
|
||||||
|
|||||||
@@ -7,28 +7,21 @@
|
|||||||
}"
|
}"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
text
|
text
|
||||||
icon="pi pi-pencil"
|
|
||||||
@click="openMaskEditor"
|
@click="openMaskEditor"
|
||||||
/>
|
>
|
||||||
|
<i-comfy:mask class="!w-4 !h-4" />
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
|
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
|
||||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
|
||||||
|
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const canvasStore = useCanvasStore()
|
const { isSingleImageNode } = useSelectionState()
|
||||||
|
|
||||||
const isSingleImageNode = computed(() => {
|
|
||||||
const { selectedItems } = canvasStore
|
|
||||||
const item = selectedItems[0]
|
|
||||||
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
const openMaskEditor = () => {
|
const openMaskEditor = () => {
|
||||||
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
||||||
|
|||||||
59
src/components/graph/selectionToolbox/MenuOptionItem.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="option.type === 'divider'"
|
||||||
|
class="h-px bg-gray-200 dark-theme:bg-zinc-700 my-1"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
role="button"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<i v-if="option.icon" :class="[option.icon, 'w-4 h-4']" />
|
||||||
|
<span class="flex-1">{{ option.label }}</span>
|
||||||
|
<span v-if="option.shortcut" class="text-xs opacity-60">
|
||||||
|
{{ option.shortcut }}
|
||||||
|
</span>
|
||||||
|
<i-lucide:chevron-right
|
||||||
|
v-if="option.hasSubmenu"
|
||||||
|
:size="14"
|
||||||
|
class="opacity-60"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="option.badge"
|
||||||
|
:severity="option.badge === 'new' ? 'info' : 'secondary'"
|
||||||
|
:value="t(option.badge)"
|
||||||
|
:class="{
|
||||||
|
'bg-[#31B9F4] dark-theme:bg-[#0B8CE9] rounded-4xl':
|
||||||
|
option.badge === 'new',
|
||||||
|
'bg-[#9C9EAB] dark-theme:bg-[#000] rounded-4xl':
|
||||||
|
option.badge === 'deprecated',
|
||||||
|
'text-white uppercase text-[9px] h-4 px-1 gap-2.5': true
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Badge from 'primevue/badge'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { MenuOption } from '@/composables/graph/useMoreOptionsMenu'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
option: MenuOption
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'click', option: MenuOption, event: Event): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const handleClick = (event: Event) => {
|
||||||
|
emit('click', props.option, event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
316
src/components/graph/selectionToolbox/MoreOptions.vue
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative inline-flex items-center">
|
||||||
|
<Button
|
||||||
|
ref="buttonRef"
|
||||||
|
v-tooltip.top="{
|
||||||
|
value: $t('g.moreOptions'),
|
||||||
|
showDelay: 1000
|
||||||
|
}"
|
||||||
|
data-testid="more-options-button"
|
||||||
|
text
|
||||||
|
class="h-8 w-8 px-0"
|
||||||
|
severity="secondary"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<i-lucide:more-vertical class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
ref="popover"
|
||||||
|
:append-to="'body'"
|
||||||
|
:auto-z-index="true"
|
||||||
|
:base-z-index="1000"
|
||||||
|
:dismissable="true"
|
||||||
|
:close-on-escape="true"
|
||||||
|
unstyled
|
||||||
|
:pt="pt"
|
||||||
|
@show="onPopoverShow"
|
||||||
|
@hide="onPopoverHide"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col p-2 min-w-48">
|
||||||
|
<MenuOptionItem
|
||||||
|
v-for="(option, index) in menuOptions"
|
||||||
|
:key="option.label || `divider-${index}`"
|
||||||
|
:option="option"
|
||||||
|
@click="handleOptionClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<SubmenuPopover
|
||||||
|
v-for="option in menuOptionsWithSubmenu"
|
||||||
|
:key="`submenu-${option.label}`"
|
||||||
|
:ref="(el) => setSubmenuRef(`submenu-${option.label}`, el)"
|
||||||
|
:option="option"
|
||||||
|
:container-styles="containerStyles"
|
||||||
|
@submenu-click="handleSubmenuClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Popover from 'primevue/popover'
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||||
|
import {
|
||||||
|
forceCloseMoreOptionsSignal,
|
||||||
|
moreOptionsOpen,
|
||||||
|
moreOptionsRestorePending,
|
||||||
|
restoreMoreOptionsSignal
|
||||||
|
} from '@/composables/canvas/useSelectionToolboxPosition'
|
||||||
|
import {
|
||||||
|
type MenuOption,
|
||||||
|
type SubMenuOption,
|
||||||
|
useMoreOptionsMenu
|
||||||
|
} from '@/composables/graph/useMoreOptionsMenu'
|
||||||
|
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
|
||||||
|
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||||
|
|
||||||
|
import MenuOptionItem from './MenuOptionItem.vue'
|
||||||
|
import SubmenuPopover from './SubmenuPopover.vue'
|
||||||
|
|
||||||
|
const popover = ref<InstanceType<typeof Popover>>()
|
||||||
|
const buttonRef = ref<InstanceType<typeof Button> | HTMLElement | null>(null)
|
||||||
|
// Track open state ourselves so we can restore after drag/move
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const wasOpenBeforeHide = ref(false)
|
||||||
|
// Track why the popover was hidden so we only auto-reopen after drag.
|
||||||
|
type HideReason = 'manual' | 'drag'
|
||||||
|
const lastProgrammaticHideReason = ref<HideReason | null>(null)
|
||||||
|
const submenuRefs = ref<Record<string, InstanceType<typeof SubmenuPopover>>>({})
|
||||||
|
const currentSubmenu = ref<string | null>(null)
|
||||||
|
|
||||||
|
const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
|
||||||
|
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
|
||||||
|
|
||||||
|
const minimap = useMinimap()
|
||||||
|
const containerStyles = minimap.containerStyles
|
||||||
|
|
||||||
|
function getButtonEl(): HTMLElement | null {
|
||||||
|
const el = (buttonRef.value as any)?.$el || buttonRef.value
|
||||||
|
return el instanceof HTMLElement ? el : null
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastLogTs = 0
|
||||||
|
const LOG_INTERVAL = 120 // ms
|
||||||
|
let overlayElCache: HTMLElement | null = null
|
||||||
|
|
||||||
|
function resolveOverlayEl(): HTMLElement | null {
|
||||||
|
// Prefer cached element (cleared on hide)
|
||||||
|
if (overlayElCache && overlayElCache.isConnected) return overlayElCache
|
||||||
|
// PrimeVue Popover root element (component instance $el)
|
||||||
|
const direct = (popover.value as any)?.$el
|
||||||
|
if (direct instanceof HTMLElement) {
|
||||||
|
overlayElCache = direct
|
||||||
|
return direct
|
||||||
|
}
|
||||||
|
// Fallback: try to locate a recent popover root near the button (same z-index class + absolute)
|
||||||
|
const btn = getButtonEl()
|
||||||
|
if (btn) {
|
||||||
|
const candidates = Array.from(
|
||||||
|
document.querySelectorAll('div.absolute.z-50')
|
||||||
|
) as HTMLElement[]
|
||||||
|
// Heuristic: pick the one closest (vertically) below the button
|
||||||
|
const rect = btn.getBoundingClientRect()
|
||||||
|
let best: { el: HTMLElement; dist: number } | null = null
|
||||||
|
for (const el of candidates) {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
const dist = Math.abs(r.top - rect.bottom)
|
||||||
|
if (!best || dist < best.dist) best = { el, dist }
|
||||||
|
}
|
||||||
|
if (best && best.el) {
|
||||||
|
overlayElCache = best.el
|
||||||
|
return best.el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositionPopover = () => {
|
||||||
|
if (!isOpen.value) return
|
||||||
|
const btn = getButtonEl()
|
||||||
|
const overlayEl = resolveOverlayEl()
|
||||||
|
if (!btn || !overlayEl) return
|
||||||
|
const rect = btn.getBoundingClientRect()
|
||||||
|
const marginY = 8 // tailwind mt-2 ~ 0.5rem = 8px
|
||||||
|
const left = rect.left + rect.width / 2
|
||||||
|
const top = rect.bottom + marginY
|
||||||
|
try {
|
||||||
|
overlayEl.style.position = 'fixed'
|
||||||
|
overlayEl.style.left = `${left}px`
|
||||||
|
overlayEl.style.top = `${top}px`
|
||||||
|
overlayEl.style.transform = 'translate(-50%, 0)'
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[MoreOptions] Failed to set overlay style', e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const now = performance.now()
|
||||||
|
if (now - lastLogTs > LOG_INTERVAL) {
|
||||||
|
lastLogTs = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startSync, stopSync } = useCanvasTransformSync(repositionPopover, {
|
||||||
|
autoStart: false
|
||||||
|
})
|
||||||
|
|
||||||
|
function openPopover(triggerEvent?: Event): boolean {
|
||||||
|
const el = getButtonEl()
|
||||||
|
if (!el || !el.isConnected) return false
|
||||||
|
bump()
|
||||||
|
popover.value?.show(triggerEvent ?? new Event('reopen'), el)
|
||||||
|
isOpen.value = true
|
||||||
|
moreOptionsOpen.value = true
|
||||||
|
moreOptionsRestorePending.value = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopover(reason: HideReason = 'manual') {
|
||||||
|
lastProgrammaticHideReason.value = reason
|
||||||
|
popover.value?.hide()
|
||||||
|
isOpen.value = false
|
||||||
|
moreOptionsOpen.value = false
|
||||||
|
stopSync()
|
||||||
|
hideAll()
|
||||||
|
if (reason !== 'drag') {
|
||||||
|
wasOpenBeforeHide.value = false
|
||||||
|
// Natural hide: cancel any pending restore
|
||||||
|
moreOptionsRestorePending.value = false
|
||||||
|
} else {
|
||||||
|
if (!moreOptionsRestorePending.value) {
|
||||||
|
wasOpenBeforeHide.value = true
|
||||||
|
moreOptionsRestorePending.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let restoreAttempts = 0
|
||||||
|
function attemptRestore() {
|
||||||
|
if (isOpen.value) return
|
||||||
|
if (!wasOpenBeforeHide.value && !moreOptionsRestorePending.value) return
|
||||||
|
// Try immediately
|
||||||
|
if (openPopover(new Event('reopen'))) {
|
||||||
|
wasOpenBeforeHide.value = false
|
||||||
|
restoreAttempts = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Defer with limited retries (layout / mount race)
|
||||||
|
if (restoreAttempts >= 5) return
|
||||||
|
restoreAttempts++
|
||||||
|
requestAnimationFrame(() => attemptRestore())
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (event: Event) => {
|
||||||
|
if (isOpen.value) closePopover('manual')
|
||||||
|
else openPopover(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = (reason: HideReason = 'manual') => closePopover(reason)
|
||||||
|
|
||||||
|
const hideAll = () => {
|
||||||
|
hideAllSubmenus(
|
||||||
|
menuOptionsWithSubmenu.value,
|
||||||
|
submenuRefs.value,
|
||||||
|
currentSubmenu
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOptionClick = (option: MenuOption, event: Event) => {
|
||||||
|
if (!option.hasSubmenu && option.action) {
|
||||||
|
option.action()
|
||||||
|
hide()
|
||||||
|
} else if (option.hasSubmenu) {
|
||||||
|
event.stopPropagation()
|
||||||
|
const submenuKey = `submenu-${option.label}`
|
||||||
|
const submenu = submenuRefs.value[submenuKey]
|
||||||
|
|
||||||
|
if (submenu) {
|
||||||
|
void toggleSubmenu(
|
||||||
|
option,
|
||||||
|
event,
|
||||||
|
submenu,
|
||||||
|
currentSubmenu,
|
||||||
|
menuOptionsWithSubmenu.value,
|
||||||
|
submenuRefs.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmenuClick = (subOption: SubMenuOption) => {
|
||||||
|
subOption.action()
|
||||||
|
hide('manual')
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSubmenuRef = (key: string, el: any) => {
|
||||||
|
if (el) {
|
||||||
|
submenuRefs.value[key] = el
|
||||||
|
} else {
|
||||||
|
delete submenuRefs.value[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pt = computed(() => ({
|
||||||
|
root: {
|
||||||
|
class: 'absolute z-50 w-[300px] px-[12]'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
class: [
|
||||||
|
'mt-2 text-neutral dark-theme:text-white rounded-lg',
|
||||||
|
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
|
||||||
|
],
|
||||||
|
style: {
|
||||||
|
backgroundColor: containerStyles.value.backgroundColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Distinguish outside click (PrimeVue dismiss) from programmatic hides.
|
||||||
|
const onPopoverShow = () => {
|
||||||
|
overlayElCache = resolveOverlayEl()
|
||||||
|
// Delay first reposition slightly to ensure DOM fully painted
|
||||||
|
requestAnimationFrame(() => repositionPopover())
|
||||||
|
startSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPopoverHide = () => {
|
||||||
|
if (lastProgrammaticHideReason.value == null) {
|
||||||
|
isOpen.value = false
|
||||||
|
hideAll()
|
||||||
|
wasOpenBeforeHide.value = false
|
||||||
|
moreOptionsOpen.value = false
|
||||||
|
moreOptionsRestorePending.value = false
|
||||||
|
}
|
||||||
|
overlayElCache = null
|
||||||
|
stopSync()
|
||||||
|
lastProgrammaticHideReason.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for forced close (drag start)
|
||||||
|
watch(
|
||||||
|
() => forceCloseMoreOptionsSignal.value,
|
||||||
|
() => {
|
||||||
|
if (isOpen.value) hide('drag')
|
||||||
|
else
|
||||||
|
wasOpenBeforeHide.value =
|
||||||
|
wasOpenBeforeHide.value || moreOptionsRestorePending.value
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => restoreMoreOptionsSignal.value,
|
||||||
|
() => attemptRestore()
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (moreOptionsRestorePending.value && !isOpen.value) {
|
||||||
|
requestAnimationFrame(() => attemptRestore())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopSync()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Button
|
|
||||||
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
|
|
||||||
v-tooltip.top="{
|
|
||||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Pin.label'),
|
|
||||||
showDelay: 1000
|
|
||||||
}"
|
|
||||||
severity="secondary"
|
|
||||||
text
|
|
||||||
icon="pi pi-thumbtack"
|
|
||||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Button from 'primevue/button'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const commandStore = useCommandStore()
|
|
||||||
const canvasStore = useCanvasStore()
|
|
||||||
</script>
|
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button
|
<Button
|
||||||
v-show="isRefreshable"
|
v-show="isRefreshable"
|
||||||
severity="info"
|
v-tooltip.top="t('g.refreshNode')"
|
||||||
|
severity="secondary"
|
||||||
text
|
text
|
||||||
icon="pi pi-refresh"
|
data-testid="refresh-button"
|
||||||
@click="refreshSelected"
|
@click="refreshSelected"
|
||||||
/>
|
>
|
||||||
|
<i-lucide:refresh-cw class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
|
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const { isRefreshable, refreshSelected } = useRefreshableSelection()
|
const { isRefreshable, refreshSelected } = useRefreshableSelection()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
127
src/components/graph/selectionToolbox/SubmenuPopover.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<Popover
|
||||||
|
ref="popover"
|
||||||
|
:auto-z-index="true"
|
||||||
|
:base-z-index="1100"
|
||||||
|
:dismissable="true"
|
||||||
|
:close-on-escape="true"
|
||||||
|
unstyled
|
||||||
|
:pt="submenuPt"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
isColorSubmenu
|
||||||
|
? 'flex flex-col gap-1 p-2'
|
||||||
|
: 'flex flex-col p-2 min-w-40'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="subOption in option.submenu"
|
||||||
|
:key="subOption.label"
|
||||||
|
:class="
|
||||||
|
isColorSubmenu
|
||||||
|
? 'w-7 h-7 flex items-center justify-center hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
|
||||||
|
: 'flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
|
||||||
|
"
|
||||||
|
:title="subOption.label"
|
||||||
|
@click="handleSubmenuClick(subOption)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="subOption.color"
|
||||||
|
class="w-5 h-5 rounded-full border border-gray-300 dark-theme:border-zinc-600"
|
||||||
|
:style="{ backgroundColor: subOption.color }"
|
||||||
|
/>
|
||||||
|
<template v-else-if="!subOption.color">
|
||||||
|
<i-lucide:check
|
||||||
|
v-if="isShapeSelected(subOption)"
|
||||||
|
class="w-4 h-4 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-4 flex-shrink-0" />
|
||||||
|
<span>{{ subOption.label }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Popover from 'primevue/popover'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type MenuOption,
|
||||||
|
type SubMenuOption
|
||||||
|
} from '@/composables/graph/useMoreOptionsMenu'
|
||||||
|
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
option: MenuOption
|
||||||
|
containerStyles: {
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
backgroundColor: string
|
||||||
|
border: string
|
||||||
|
borderRadius: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submenu-click', subOption: SubMenuOption): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const { getCurrentShape } = useNodeCustomization()
|
||||||
|
|
||||||
|
const popover = ref<InstanceType<typeof Popover>>()
|
||||||
|
|
||||||
|
const show = (event: Event, target?: HTMLElement) => {
|
||||||
|
popover.value?.show(event, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
popover.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
hide
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmenuClick = (subOption: SubMenuOption) => {
|
||||||
|
emit('submenu-click', subOption)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isShapeSelected = (subOption: SubMenuOption): boolean => {
|
||||||
|
if (subOption.color) return false
|
||||||
|
|
||||||
|
const currentShape = getCurrentShape()
|
||||||
|
if (!currentShape) return false
|
||||||
|
|
||||||
|
return currentShape.localizedName === subOption.label
|
||||||
|
}
|
||||||
|
|
||||||
|
const isColorSubmenu = computed(() => {
|
||||||
|
return (
|
||||||
|
props.option.submenu &&
|
||||||
|
props.option.submenu.length > 0 &&
|
||||||
|
props.option.submenu.every((item) => item.color && !item.icon)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const submenuPt = computed(() => ({
|
||||||
|
root: {
|
||||||
|
class: 'absolute z-[60]'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
class: [
|
||||||
|
'text-neutral dark-theme:text-white rounded-lg',
|
||||||
|
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
|
||||||
|
],
|
||||||
|
style: {
|
||||||
|
backgroundColor: props.containerStyles.backgroundColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-6 w-px bg-gray-300/10 dark-theme:bg-gray-600/10 self-center" />
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { onUnmounted, ref, watch } from 'vue'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||||
@@ -8,12 +8,42 @@ import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
|||||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||||
import { computeUnionBounds } from '@/utils/mathUtil'
|
import { computeUnionBounds } from '@/utils/mathUtil'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the position of the selection toolbox independently.
|
* Manages the position of the selection toolbox independently.
|
||||||
* Uses CSS custom properties for performant transform updates.
|
* Uses CSS custom properties for performant transform updates.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Shared signals for auxiliary UI (e.g., MoreOptions) to coordinate hide/restore
|
||||||
|
export const moreOptionsOpen = ref(false)
|
||||||
|
export const forceCloseMoreOptionsSignal = ref(0)
|
||||||
|
export const restoreMoreOptionsSignal = ref(0)
|
||||||
|
export const moreOptionsRestorePending = ref(false)
|
||||||
|
let moreOptionsWasOpenBeforeDrag = false
|
||||||
|
let moreOptionsSelectionSignature: string | null = null
|
||||||
|
|
||||||
|
function buildSelectionSignature(
|
||||||
|
store: ReturnType<typeof useCanvasStore>
|
||||||
|
): string | null {
|
||||||
|
const c = store.canvas
|
||||||
|
if (!c) return null
|
||||||
|
const items = Array.from(c.selectedItems)
|
||||||
|
if (items.length !== 1) return null
|
||||||
|
const item = items[0]
|
||||||
|
if (isLGraphNode(item)) return `N:${item.id}`
|
||||||
|
if (isLGraphGroup(item)) return `G:${item.id}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSelectionMatchesSignature(
|
||||||
|
store: ReturnType<typeof useCanvasStore>
|
||||||
|
) {
|
||||||
|
if (!moreOptionsSelectionSignature) return false
|
||||||
|
return buildSelectionSignature(store) === moreOptionsSelectionSignature
|
||||||
|
}
|
||||||
|
|
||||||
export function useSelectionToolboxPosition(
|
export function useSelectionToolboxPosition(
|
||||||
toolboxRef: Ref<HTMLElement | undefined>
|
toolboxRef: Ref<HTMLElement | undefined>
|
||||||
) {
|
) {
|
||||||
@@ -105,10 +135,17 @@ export function useSelectionToolboxPosition(
|
|||||||
() => canvasStore.getCanvas().state.selectionChanged,
|
() => canvasStore.getCanvas().state.selectionChanged,
|
||||||
(changed) => {
|
(changed) => {
|
||||||
if (changed) {
|
if (changed) {
|
||||||
|
if (moreOptionsRestorePending.value || moreOptionsSelectionSignature) {
|
||||||
|
moreOptionsRestorePending.value = false
|
||||||
|
moreOptionsWasOpenBeforeDrag = false
|
||||||
|
if (!moreOptionsOpen.value) {
|
||||||
|
moreOptionsSelectionSignature = null
|
||||||
|
} else {
|
||||||
|
moreOptionsSelectionSignature = buildSelectionSignature(canvasStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
updateSelectionBounds()
|
updateSelectionBounds()
|
||||||
canvasStore.getCanvas().state.selectionChanged = false
|
canvasStore.getCanvas().state.selectionChanged = false
|
||||||
|
|
||||||
// Start transform sync if we have selection
|
|
||||||
if (visible.value) {
|
if (visible.value) {
|
||||||
startSync()
|
startSync()
|
||||||
} else {
|
} else {
|
||||||
@@ -118,24 +155,77 @@ export function useSelectionToolboxPosition(
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
watch(
|
||||||
|
() => moreOptionsOpen.value,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
moreOptionsSelectionSignature = buildSelectionSignature(canvasStore)
|
||||||
|
} else if (!canvasStore.canvas?.state?.draggingItems) {
|
||||||
|
moreOptionsSelectionSignature = null
|
||||||
|
if (moreOptionsRestorePending.value)
|
||||||
|
moreOptionsRestorePending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Watch for dragging state
|
// Watch for dragging state
|
||||||
watch(
|
watch(
|
||||||
() => canvasStore.canvas?.state?.draggingItems,
|
() => canvasStore.canvas?.state?.draggingItems,
|
||||||
(dragging) => {
|
(dragging) => {
|
||||||
if (dragging) {
|
if (dragging) {
|
||||||
// Hide during node dragging
|
|
||||||
visible.value = false
|
visible.value = false
|
||||||
|
|
||||||
|
if (moreOptionsOpen.value) {
|
||||||
|
const currentSig = buildSelectionSignature(canvasStore)
|
||||||
|
if (currentSig !== moreOptionsSelectionSignature) {
|
||||||
|
moreOptionsSelectionSignature = null
|
||||||
|
}
|
||||||
|
moreOptionsWasOpenBeforeDrag = true
|
||||||
|
moreOptionsOpen.value = false
|
||||||
|
moreOptionsRestorePending.value = !!moreOptionsSelectionSignature
|
||||||
|
if (moreOptionsRestorePending.value) {
|
||||||
|
forceCloseMoreOptionsSignal.value++
|
||||||
|
} else {
|
||||||
|
moreOptionsWasOpenBeforeDrag = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
moreOptionsRestorePending.value = false
|
||||||
|
moreOptionsWasOpenBeforeDrag = false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Update after dragging ends
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
updateSelectionBounds()
|
updateSelectionBounds()
|
||||||
|
const selectionMatches = currentSelectionMatchesSignature(canvasStore)
|
||||||
|
const shouldRestore =
|
||||||
|
moreOptionsWasOpenBeforeDrag &&
|
||||||
|
visible.value &&
|
||||||
|
moreOptionsRestorePending.value &&
|
||||||
|
selectionMatches
|
||||||
|
|
||||||
|
if (shouldRestore) {
|
||||||
|
restoreMoreOptionsSignal.value++
|
||||||
|
} else {
|
||||||
|
moreOptionsRestorePending.value = false
|
||||||
|
}
|
||||||
|
moreOptionsWasOpenBeforeDrag = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resetMoreOptionsState()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visible
|
visible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External cleanup utility to be called when SelectionToolbox component unmounts
|
||||||
|
function resetMoreOptionsState() {
|
||||||
|
moreOptionsOpen.value = false
|
||||||
|
moreOptionsRestorePending.value = false
|
||||||
|
moreOptionsWasOpenBeforeDrag = false
|
||||||
|
moreOptionsSelectionSignature = null
|
||||||
|
}
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"import": "Import",
|
"import": "Import",
|
||||||
"loadAllFolders": "Load All Folders",
|
"loadAllFolders": "Load All Folders",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
|
"refreshNode": "Refresh Node",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"videoFailedToLoad": "Video failed to load",
|
"videoFailedToLoad": "Video failed to load",
|
||||||
@@ -30,7 +31,9 @@
|
|||||||
"icon": "Icon",
|
"icon": "Icon",
|
||||||
"color": "Color",
|
"color": "Color",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"help": "Help",
|
"info": "Node Info",
|
||||||
|
"bookmark": "Save to Library",
|
||||||
|
"moreOptions": "More Options",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"loadingPanel": "Loading {panel} panel...",
|
"loadingPanel": "Loading {panel} panel...",
|
||||||
"preview": "PREVIEW",
|
"preview": "PREVIEW",
|
||||||
@@ -157,7 +160,8 @@
|
|||||||
"nodeContentError": "Node Content Error",
|
"nodeContentError": "Node Content Error",
|
||||||
"nodeHeaderError": "Node Header Error",
|
"nodeHeaderError": "Node Header Error",
|
||||||
"nodeSlotsError": "Node Slots Error",
|
"nodeSlotsError": "Node Slots Error",
|
||||||
"nodeWidgetsError": "Node Widgets Error"
|
"nodeWidgetsError": "Node Widgets Error",
|
||||||
|
"frameNodes": "Frame Nodes"
|
||||||
},
|
},
|
||||||
"manager": {
|
"manager": {
|
||||||
"title": "Custom Nodes Manager",
|
"title": "Custom Nodes Manager",
|
||||||
@@ -311,7 +315,37 @@
|
|||||||
"Save Selected as Template": "Save Selected as Template",
|
"Save Selected as Template": "Save Selected as Template",
|
||||||
"Node Templates": "Node Templates",
|
"Node Templates": "Node Templates",
|
||||||
"Manage": "Manage",
|
"Manage": "Manage",
|
||||||
"Search": "Search"
|
"Search": "Search",
|
||||||
|
"Open in Mask Editor": "Open in Mask Editor",
|
||||||
|
"Open Image": "Open Image",
|
||||||
|
"Copy Image": "Copy Image",
|
||||||
|
"Save Image": "Save Image",
|
||||||
|
"Rename": "Rename",
|
||||||
|
"Copy": "Copy",
|
||||||
|
"Duplicate": "Duplicate",
|
||||||
|
"Paste": "Paste",
|
||||||
|
"Node Info": "Node Info",
|
||||||
|
"Adjust Size": "Adjust Size",
|
||||||
|
"Minimize Node": "Minimize Node",
|
||||||
|
"Expand Node": "Expand Node",
|
||||||
|
"Shape": "Shape",
|
||||||
|
"Color": "Color",
|
||||||
|
"Add Subgraph to Library": "Add Subgraph to Library",
|
||||||
|
"Unpack Subgraph": "Unpack Subgraph",
|
||||||
|
"Convert to Subgraph": "Convert to Subgraph",
|
||||||
|
"Align Selected To": "Align Selected To",
|
||||||
|
"Distribute Nodes": "Distribute Nodes",
|
||||||
|
"Remove Bypass": "Remove Bypass",
|
||||||
|
"Run Branch": "Run Branch",
|
||||||
|
"Delete": "Delete",
|
||||||
|
"Top": "Top",
|
||||||
|
"Bottom": "Bottom",
|
||||||
|
"Left": "Left",
|
||||||
|
"Right": "Right",
|
||||||
|
"Horizontal": "Horizontal",
|
||||||
|
"Vertical": "Vertical",
|
||||||
|
"new": "new",
|
||||||
|
"deprecated": "deprecated"
|
||||||
},
|
},
|
||||||
"icon": {
|
"icon": {
|
||||||
"bookmark": "Bookmark",
|
"bookmark": "Bookmark",
|
||||||
@@ -462,6 +496,14 @@
|
|||||||
"revertChanges": "Revert Changes",
|
"revertChanges": "Revert Changes",
|
||||||
"restart": "Restart"
|
"restart": "Restart"
|
||||||
},
|
},
|
||||||
|
"shape": {
|
||||||
|
"default": "Default",
|
||||||
|
"round": "Round",
|
||||||
|
"CARD": "Card",
|
||||||
|
"circle": "Circle",
|
||||||
|
"arrow": "Arrow",
|
||||||
|
"box": "Box"
|
||||||
|
},
|
||||||
"sideToolbar": {
|
"sideToolbar": {
|
||||||
"themeToggle": "Toggle Theme",
|
"themeToggle": "Toggle Theme",
|
||||||
"helpCenter": "Help Center",
|
"helpCenter": "Help Center",
|
||||||
@@ -1753,7 +1795,10 @@
|
|||||||
"executeButton": {
|
"executeButton": {
|
||||||
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
|
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
|
||||||
"disabledTooltip": "No output nodes selected"
|
"disabledTooltip": "No output nodes selected"
|
||||||
}
|
},
|
||||||
|
"Set Group Nodes to Never": "Set Group Nodes to Never",
|
||||||
|
"Bypass Group Nodes": "Bypass Group Nodes",
|
||||||
|
"Set Group Nodes to Always": "Set Group Nodes to Always"
|
||||||
},
|
},
|
||||||
"chatHistory": {
|
"chatHistory": {
|
||||||
"cancelEdit": "Cancel",
|
"cancelEdit": "Cancel",
|
||||||
|
|||||||
270
tests-ui/tests/composables/graph/useSelectionState.test.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||||
|
import { type Ref, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||||
|
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||||
|
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||||
|
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||||
|
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||||
|
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||||
|
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||||
|
|
||||||
|
// Test interfaces
|
||||||
|
interface TestNodeConfig {
|
||||||
|
type?: string
|
||||||
|
mode?: LGraphEventMode
|
||||||
|
flags?: { collapsed?: boolean }
|
||||||
|
pinned?: boolean
|
||||||
|
removable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestNode {
|
||||||
|
type: string
|
||||||
|
mode: LGraphEventMode
|
||||||
|
flags?: { collapsed?: boolean }
|
||||||
|
pinned?: boolean
|
||||||
|
removable?: boolean
|
||||||
|
isSubgraphNode: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockedItem = TestNode | { type: string; isNode: boolean }
|
||||||
|
|
||||||
|
// Mock all stores
|
||||||
|
vi.mock('@/stores/graphStore', () => ({
|
||||||
|
useCanvasStore: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/nodeDefStore', () => ({
|
||||||
|
useNodeDefStore: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||||
|
useSidebarTabStore: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
|
||||||
|
useNodeHelpStore: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
|
||||||
|
useNodeLibrarySidebarTab: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/litegraphUtil', () => ({
|
||||||
|
isLGraphNode: vi.fn(),
|
||||||
|
isImageNode: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||||
|
filterOutputNodes: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const createTestNode = (config: TestNodeConfig = {}): TestNode => {
|
||||||
|
return {
|
||||||
|
type: config.type || 'TestNode',
|
||||||
|
mode: config.mode || LGraphEventMode.ALWAYS,
|
||||||
|
flags: config.flags,
|
||||||
|
pinned: config.pinned,
|
||||||
|
removable: config.removable,
|
||||||
|
isSubgraphNode: () => false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock comment/connection objects
|
||||||
|
const mockComment = { type: 'comment', isNode: false }
|
||||||
|
const mockConnection = { type: 'connection', isNode: false }
|
||||||
|
|
||||||
|
describe('useSelectionState', () => {
|
||||||
|
// Mock store instances
|
||||||
|
let mockSelectedItems: Ref<MockedItem[]>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
|
||||||
|
// Setup mock canvas store with proper ref
|
||||||
|
mockSelectedItems = ref([])
|
||||||
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
|
selectedItems: mockSelectedItems,
|
||||||
|
// Add minimal required properties for the store
|
||||||
|
$id: 'canvas',
|
||||||
|
$state: {} as any,
|
||||||
|
$patch: vi.fn(),
|
||||||
|
$reset: vi.fn(),
|
||||||
|
$subscribe: vi.fn(),
|
||||||
|
$onAction: vi.fn(),
|
||||||
|
$dispose: vi.fn(),
|
||||||
|
_customProperties: new Set(),
|
||||||
|
_p: {} as any
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
// Setup mock node def store
|
||||||
|
vi.mocked(useNodeDefStore).mockReturnValue({
|
||||||
|
fromLGraphNode: vi.fn((node: TestNode) => {
|
||||||
|
if (node?.type === 'TestNode') {
|
||||||
|
return { nodePath: 'test.TestNode', name: 'TestNode' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
// Add minimal required properties for the store
|
||||||
|
$id: 'nodeDef',
|
||||||
|
$state: {} as any,
|
||||||
|
$patch: vi.fn(),
|
||||||
|
$reset: vi.fn(),
|
||||||
|
$subscribe: vi.fn(),
|
||||||
|
$onAction: vi.fn(),
|
||||||
|
$dispose: vi.fn(),
|
||||||
|
_customProperties: new Set(),
|
||||||
|
_p: {} as any
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
// Setup mock sidebar tab store
|
||||||
|
const mockToggleSidebarTab = vi.fn()
|
||||||
|
vi.mocked(useSidebarTabStore).mockReturnValue({
|
||||||
|
activeSidebarTabId: null,
|
||||||
|
toggleSidebarTab: mockToggleSidebarTab,
|
||||||
|
// Add minimal required properties for the store
|
||||||
|
$id: 'sidebarTab',
|
||||||
|
$state: {} as any,
|
||||||
|
$patch: vi.fn(),
|
||||||
|
$reset: vi.fn(),
|
||||||
|
$subscribe: vi.fn(),
|
||||||
|
$onAction: vi.fn(),
|
||||||
|
$dispose: vi.fn(),
|
||||||
|
_customProperties: new Set(),
|
||||||
|
_p: {} as any
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
// Setup mock node help store
|
||||||
|
const mockOpenHelp = vi.fn()
|
||||||
|
const mockCloseHelp = vi.fn()
|
||||||
|
const mockNodeHelpStore = {
|
||||||
|
isHelpOpen: false,
|
||||||
|
currentHelpNode: null,
|
||||||
|
openHelp: mockOpenHelp,
|
||||||
|
closeHelp: mockCloseHelp,
|
||||||
|
// Add minimal required properties for the store
|
||||||
|
$id: 'nodeHelp',
|
||||||
|
$state: {} as any,
|
||||||
|
$patch: vi.fn(),
|
||||||
|
$reset: vi.fn(),
|
||||||
|
$subscribe: vi.fn(),
|
||||||
|
$onAction: vi.fn(),
|
||||||
|
$dispose: vi.fn(),
|
||||||
|
_customProperties: new Set(),
|
||||||
|
_p: {} as any
|
||||||
|
}
|
||||||
|
vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any)
|
||||||
|
|
||||||
|
// Setup mock composables
|
||||||
|
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
|
||||||
|
id: 'node-library-tab',
|
||||||
|
title: 'Node Library',
|
||||||
|
type: 'custom',
|
||||||
|
render: () => null
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
// Setup mock utility functions
|
||||||
|
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
|
||||||
|
const typedItem = item as { isNode?: boolean }
|
||||||
|
return typedItem?.isNode !== false
|
||||||
|
})
|
||||||
|
vi.mocked(isImageNode).mockImplementation((node: unknown) => {
|
||||||
|
const typedNode = node as { type?: string }
|
||||||
|
return typedNode?.type === 'ImageNode'
|
||||||
|
})
|
||||||
|
vi.mocked(filterOutputNodes).mockImplementation(
|
||||||
|
(nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Selection Detection', () => {
|
||||||
|
test('should return false when nothing selected', () => {
|
||||||
|
const { hasAnySelection } = useSelectionState()
|
||||||
|
expect(hasAnySelection.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when items selected', () => {
|
||||||
|
// Update the mock data before creating the composable
|
||||||
|
const node1 = createTestNode()
|
||||||
|
const node2 = createTestNode()
|
||||||
|
mockSelectedItems.value = [node1, node2]
|
||||||
|
|
||||||
|
const { hasAnySelection } = useSelectionState()
|
||||||
|
expect(hasAnySelection.value).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Node Type Filtering', () => {
|
||||||
|
test('should pick only LGraphNodes from mixed selections', () => {
|
||||||
|
// Update the mock data before creating the composable
|
||||||
|
const graphNode = createTestNode()
|
||||||
|
mockSelectedItems.value = [graphNode, mockComment, mockConnection]
|
||||||
|
|
||||||
|
const { selectedNodes } = useSelectionState()
|
||||||
|
expect(selectedNodes.value).toHaveLength(1)
|
||||||
|
expect(selectedNodes.value[0]).toEqual(graphNode)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Node State Computation', () => {
|
||||||
|
test('should detect bypassed nodes', () => {
|
||||||
|
// Update the mock data before creating the composable
|
||||||
|
const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS })
|
||||||
|
mockSelectedItems.value = [bypassedNode]
|
||||||
|
|
||||||
|
const { selectedNodes } = useSelectionState()
|
||||||
|
const isBypassed = selectedNodes.value.some(
|
||||||
|
(n) => n.mode === LGraphEventMode.BYPASS
|
||||||
|
)
|
||||||
|
expect(isBypassed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should detect pinned/collapsed states', () => {
|
||||||
|
// Update the mock data before creating the composable
|
||||||
|
const pinnedNode = createTestNode({ pinned: true })
|
||||||
|
const collapsedNode = createTestNode({ flags: { collapsed: true } })
|
||||||
|
mockSelectedItems.value = [pinnedNode, collapsedNode]
|
||||||
|
|
||||||
|
const { selectedNodes } = useSelectionState()
|
||||||
|
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
|
||||||
|
const isCollapsed = selectedNodes.value.some(
|
||||||
|
(n) => n.flags?.collapsed === true
|
||||||
|
)
|
||||||
|
const isBypassed = selectedNodes.value.some(
|
||||||
|
(n) => n.mode === LGraphEventMode.BYPASS
|
||||||
|
)
|
||||||
|
expect(isPinned).toBe(true)
|
||||||
|
expect(isCollapsed).toBe(true)
|
||||||
|
expect(isBypassed).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should provide non-reactive state computation', () => {
|
||||||
|
// Update the mock data before creating the composable
|
||||||
|
const node = createTestNode({ pinned: true })
|
||||||
|
mockSelectedItems.value = [node]
|
||||||
|
|
||||||
|
const { selectedNodes } = useSelectionState()
|
||||||
|
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
|
||||||
|
const isCollapsed = selectedNodes.value.some(
|
||||||
|
(n) => n.flags?.collapsed === true
|
||||||
|
)
|
||||||
|
const isBypassed = selectedNodes.value.some(
|
||||||
|
(n) => n.mode === LGraphEventMode.BYPASS
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(isPinned).toBe(true)
|
||||||
|
expect(isCollapsed).toBe(false)
|
||||||
|
expect(isBypassed).toBe(false)
|
||||||
|
|
||||||
|
// Test with empty selection using new composable instance
|
||||||
|
mockSelectedItems.value = []
|
||||||
|
const { selectedNodes: newSelectedNodes } = useSelectionState()
|
||||||
|
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
|
||||||
|
expect(newIsPinned).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||