Merge branch 'main' into sno-fix-playwright-babel-config
2
.github/workflows/update-electron-types.yaml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Get new version
|
||||
id: get-version
|
||||
run: |
|
||||
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].version')
|
||||
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].dependencies."@comfyorg/comfyui-electron-types".version')
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ComfyActionbar } from '../helpers/actionbar'
|
||||
import { LocationMock } from '../helpers/locationMock'
|
||||
import { ComfyTemplates } from '../helpers/templates'
|
||||
import { ComfyMouse } from './ComfyMouse'
|
||||
import { VueNodeHelpers } from './VueNodeHelpers'
|
||||
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import {
|
||||
@@ -146,6 +147,7 @@ export class ComfyPage {
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly locationMock: LocationMock
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -175,6 +177,7 @@ export class ComfyPage {
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.locationMock = new LocationMock(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
}
|
||||
|
||||
convertLeafToContent(structure: FolderStructure): FolderStructure {
|
||||
@@ -1433,7 +1436,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async closeDialog() {
|
||||
await this.page.locator('.p-dialog-close-button').click()
|
||||
await this.page.locator('.p-dialog-close-button').click({ force: true })
|
||||
await expect(this.page.locator('.p-dialog')).toBeHidden()
|
||||
}
|
||||
|
||||
|
||||
110
browser_tests/fixtures/VueNodeHelpers.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Vue Node Test Helpers
|
||||
*/
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Get locator for all Vue node components in the DOM
|
||||
*/
|
||||
get nodes(): Locator {
|
||||
return this.page.locator('[data-node-id]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for selected Vue node components (using visual selection indicators)
|
||||
*/
|
||||
get selectedNodes(): Locator {
|
||||
return this.page.locator(
|
||||
'[data-node-id].outline-black, [data-node-id].outline-white'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of Vue nodes in the DOM
|
||||
*/
|
||||
async getNodeCount(): Promise<number> {
|
||||
return await this.nodes.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of selected Vue nodes
|
||||
*/
|
||||
async getSelectedNodeCount(): Promise<number> {
|
||||
return await this.selectedNodes.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Vue node IDs currently in the DOM
|
||||
*/
|
||||
async getNodeIds(): Promise<string[]> {
|
||||
return await this.nodes.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((n) => n.getAttribute('data-node-id'))
|
||||
.filter((id): id is string => id !== null)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a specific Vue node by ID
|
||||
*/
|
||||
async selectNode(nodeId: string): Promise<void> {
|
||||
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select multiple Vue nodes by IDs using Ctrl+click
|
||||
*/
|
||||
async selectNodes(nodeIds: string[]): Promise<void> {
|
||||
if (nodeIds.length === 0) return
|
||||
|
||||
// Select first node normally
|
||||
await this.selectNode(nodeIds[0])
|
||||
|
||||
// Add additional nodes with Ctrl+click
|
||||
for (let i = 1; i < nodeIds.length; i++) {
|
||||
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selections by clicking empty space
|
||||
*/
|
||||
async clearSelection(): Promise<void> {
|
||||
await this.page.mouse.click(50, 50)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected Vue nodes using Delete key
|
||||
*/
|
||||
async deleteSelected(): Promise<void> {
|
||||
await this.page.locator('#graph-canvas').focus()
|
||||
await this.page.keyboard.press('Delete')
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected Vue nodes using Backspace key
|
||||
*/
|
||||
async deleteSelectedWithBackspace(): Promise<void> {
|
||||
await this.page.locator('#graph-canvas').focus()
|
||||
await this.page.keyboard.press('Backspace')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Vue nodes to be rendered
|
||||
*/
|
||||
async waitForNodes(expectedCount?: number): Promise<void> {
|
||||
if (expectedCount !== undefined) {
|
||||
await this.page.waitForFunction(
|
||||
(count) => document.querySelectorAll('[data-node-id]').length >= count,
|
||||
expectedCount
|
||||
)
|
||||
} else {
|
||||
await this.page.waitForSelector('[data-node-id]')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ test.describe('Node Help', () => {
|
||||
|
||||
// Click the help button in the selection toolbox
|
||||
const helpButton = comfyPage.selectionToolbox.locator(
|
||||
'button:has(.pi-question-circle)'
|
||||
'button[data-testid="info-button"]'
|
||||
)
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click()
|
||||
@@ -164,7 +164,7 @@ test.describe('Node Help', () => {
|
||||
|
||||
// Click help button
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
@@ -194,7 +194,7 @@ test.describe('Node Help', () => {
|
||||
|
||||
// Click help button
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
@@ -228,7 +228,7 @@ test.describe('Node Help', () => {
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
@@ -276,7 +276,7 @@ test.describe('Node Help', () => {
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
@@ -348,7 +348,7 @@ This is documentation for a custom node.
|
||||
}
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
if (await helpButton.isVisible()) {
|
||||
await helpButton.click()
|
||||
@@ -389,7 +389,7 @@ This is documentation for a custom node.
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
@@ -456,7 +456,7 @@ This is English documentation.
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
@@ -479,7 +479,7 @@ This is English documentation.
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
@@ -522,7 +522,7 @@ This is English documentation.
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
@@ -538,7 +538,7 @@ This is English documentation.
|
||||
|
||||
// Click help button again
|
||||
const helpButton2 = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
await helpButton2.click()
|
||||
|
||||
|
||||
@@ -190,7 +190,9 @@ test.describe('Remote COMBO Widget', () => {
|
||||
await comfyPage.page.keyboard.press('Control+A')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-toolbox .pi-refresh')
|
||||
comfyPage.page.locator(
|
||||
'.selection-toolbox button[data-testid="refresh-button"]'
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
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()
|
||||
})
|
||||
})
|
||||
141
browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes - Delete Key Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Enable Vue nodes rendering
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Can select all and delete Vue nodes with Delete key', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Get initial Vue node count
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
// Select all Vue nodes
|
||||
await comfyPage.ctrlA()
|
||||
|
||||
// Verify all Vue nodes are selected
|
||||
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
|
||||
expect(selectedCount).toBe(initialNodeCount)
|
||||
|
||||
// Delete with Delete key
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
|
||||
// Verify all Vue nodes were deleted
|
||||
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(finalNodeCount).toBe(0)
|
||||
})
|
||||
|
||||
test('Can select specific Vue node and delete it', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Get initial Vue node count
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
// Get first Vue node ID and select it
|
||||
const nodeIds = await comfyPage.vueNodes.getNodeIds()
|
||||
await comfyPage.vueNodes.selectNode(nodeIds[0])
|
||||
|
||||
// Verify selection
|
||||
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
|
||||
expect(selectedCount).toBe(1)
|
||||
|
||||
// Delete with Delete key
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
|
||||
// Verify one Vue node was deleted
|
||||
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
})
|
||||
|
||||
test('Can select and delete Vue node with Backspace key', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
|
||||
// Select first Vue node
|
||||
const nodeIds = await comfyPage.vueNodes.getNodeIds()
|
||||
await comfyPage.vueNodes.selectNode(nodeIds[0])
|
||||
|
||||
// Delete with Backspace key instead of Delete
|
||||
await comfyPage.vueNodes.deleteSelectedWithBackspace()
|
||||
|
||||
// Verify Vue node was deleted
|
||||
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
})
|
||||
|
||||
test('Delete key does not delete node when typing in Vue node widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.getGraphNodesCount()
|
||||
|
||||
// Find a text input widget in a Vue node
|
||||
const textWidget = comfyPage.page
|
||||
.locator('input[type="text"], textarea')
|
||||
.first()
|
||||
|
||||
// Click on text widget to focus it
|
||||
await textWidget.click()
|
||||
await textWidget.fill('test text')
|
||||
|
||||
// Press Delete while focused on widget - should delete text, not node
|
||||
await textWidget.press('Delete')
|
||||
|
||||
// Node count should remain the same
|
||||
const finalNodeCount = await comfyPage.getGraphNodesCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('Delete key does not delete node when nothing is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Ensure no Vue nodes are selected
|
||||
await comfyPage.vueNodes.clearSelection()
|
||||
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
|
||||
expect(selectedCount).toBe(0)
|
||||
|
||||
// Press Delete key - should not crash and should handle gracefully
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
// Vue node count should remain the same
|
||||
const nodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(nodeCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Can multi-select with Ctrl+click and delete multiple Vue nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
|
||||
// Multi-select first two Vue nodes using Ctrl+click
|
||||
const nodeIds = await comfyPage.vueNodes.getNodeIds()
|
||||
const nodesToSelect = nodeIds.slice(0, 2)
|
||||
await comfyPage.vueNodes.selectNodes(nodesToSelect)
|
||||
|
||||
// Verify expected nodes are selected
|
||||
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
|
||||
expect(selectedCount).toBe(nodesToSelect.length)
|
||||
|
||||
// Delete selected Vue nodes
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
|
||||
// Verify expected nodes were deleted
|
||||
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - nodesToSelect.length)
|
||||
})
|
||||
})
|
||||
@@ -264,7 +264,13 @@ test.describe('Animated image widget', () => {
|
||||
expect(filename).toContain('animated_webp.webp')
|
||||
})
|
||||
|
||||
test('Can preview saved animated webp image', async ({ comfyPage }) => {
|
||||
// FIXME: This test keeps flip-flopping because it relies on animated webp timing,
|
||||
// which is inherently unreliable in CI environments. The test asset is an animated
|
||||
// webp with 2 frames, and the test depends on animation frame timing to verify that
|
||||
// animated webp images are properly displayed (as opposed to being treated as static webp).
|
||||
// While the underlying functionality works (animated webp are correctly distinguished
|
||||
// from static webp), the test is flaky due to timing dependencies with webp animation frames.
|
||||
test.fixme('Can preview saved animated webp image', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/save_animated_webp')
|
||||
|
||||
// Get position of the load animated webp node
|
||||
|
||||
|
Before Width: | Height: | Size: 169 KiB |
@@ -114,7 +114,7 @@
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.69",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.72",
|
||||
"@iconify/json": "^2.2.380",
|
||||
"@primeuix/forms": "0.0.2",
|
||||
"@primeuix/styled": "0.3.2",
|
||||
|
||||
18
pnpm-lock.yaml
generated
@@ -15,8 +15,8 @@ importers:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: ^0.4.69
|
||||
version: 0.4.69
|
||||
specifier: ^0.4.72
|
||||
version: 0.4.72
|
||||
'@iconify/json':
|
||||
specifier: ^2.2.380
|
||||
version: 2.2.380
|
||||
@@ -1038,8 +1038,8 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.4.69':
|
||||
resolution: {integrity: sha512-emEapJvbbx8lXiJ/84gmk+fYU73MmqkQKgBDQkyDwctcOb+eNe347PaH/+0AIjX8A/DtFHfnwgh9J8k3RVdqZA==}
|
||||
'@comfyorg/comfyui-electron-types@0.4.72':
|
||||
resolution: {integrity: sha512-Ecf0XYOKDqqIcnjSWL8GHLo6MOsuwqs0I1QgWc3Hv+BZm+qUE4vzOXCyhfFoTIGHLZFTwe37gnygPPKFzMu00Q==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
@@ -6741,8 +6741,8 @@ packages:
|
||||
vue-component-type-helpers@2.2.12:
|
||||
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
|
||||
|
||||
vue-component-type-helpers@3.0.6:
|
||||
resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==}
|
||||
vue-component-type-helpers@3.0.7:
|
||||
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -7908,7 +7908,7 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.4.69': {}
|
||||
'@comfyorg/comfyui-electron-types@0.4.72': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
@@ -9331,7 +9331,7 @@ snapshots:
|
||||
storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.0.6
|
||||
vue-component-type-helpers: 3.0.7
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -14598,7 +14598,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@2.2.12: {}
|
||||
|
||||
vue-component-type-helpers@3.0.6: {}
|
||||
vue-component-type-helpers@3.0.7: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
@@ -961,9 +961,7 @@ audio.comfy-audio.empty-audio-widget {
|
||||
/* Uses default styling - no overrides needed */
|
||||
}
|
||||
|
||||
/* Smooth transitions between LOD levels */
|
||||
.lg-node {
|
||||
transition: min-height 0.2s ease;
|
||||
/* Disable text selection on all nodes */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
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 |
41
src/base/common/downloadUtil.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Utility functions for downloading files
|
||||
*/
|
||||
|
||||
// Constants
|
||||
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
||||
|
||||
/**
|
||||
* Download a file from a URL by creating a temporary anchor element
|
||||
* @param url - The URL of the file to download (must be a valid URL string)
|
||||
* @param filename - Optional filename override (will use URL filename or default if not provided)
|
||||
* @throws {Error} If the URL is invalid or empty
|
||||
*/
|
||||
export const downloadFile = (url: string, filename?: string): void => {
|
||||
if (!url || typeof url !== 'string' || url.trim().length === 0) {
|
||||
throw new Error('Invalid URL provided for download')
|
||||
}
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download =
|
||||
filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from a URL's query parameters
|
||||
* @param url - The URL to extract filename from
|
||||
* @returns The extracted filename or null if not found
|
||||
*/
|
||||
const extractFilenameFromUrl = (url: string): string | null => {
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin)
|
||||
return urlObj.searchParams.get('filename')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
@@ -40,6 +40,7 @@ import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue
|
||||
import CustomFormValue from '@/components/common/CustomFormValue.vue'
|
||||
import FormColorPicker from '@/components/common/FormColorPicker.vue'
|
||||
import FormImageUpload from '@/components/common/FormImageUpload.vue'
|
||||
import FormRadioGroup from '@/components/common/FormRadioGroup.vue'
|
||||
import InputKnob from '@/components/common/InputKnob.vue'
|
||||
import InputSlider from '@/components/common/InputSlider.vue'
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
@@ -66,6 +67,7 @@ function getFormAttrs(item: FormItem) {
|
||||
}
|
||||
switch (item.type) {
|
||||
case 'combo':
|
||||
case 'radio':
|
||||
attrs['options'] =
|
||||
typeof item.options === 'function'
|
||||
? // @ts-expect-error: Audit and deprecate usage of legacy options type:
|
||||
@@ -97,6 +99,8 @@ function getFormComponent(item: FormItem): Component {
|
||||
return InputKnob
|
||||
case 'combo':
|
||||
return Select
|
||||
case 'radio':
|
||||
return FormRadioGroup
|
||||
case 'image':
|
||||
return FormImageUpload
|
||||
case 'color':
|
||||
|
||||
245
src/components/common/FormRadioGroup.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import type { SettingOption } from '@/types/settingTypes'
|
||||
|
||||
import FormRadioGroup from './FormRadioGroup.vue'
|
||||
|
||||
describe('FormRadioGroup', () => {
|
||||
beforeAll(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any, options = {}) => {
|
||||
return mount(FormRadioGroup, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { RadioButton }
|
||||
},
|
||||
props,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
describe('normalizedOptions computed property', () => {
|
||||
it('handles string array options', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'option1',
|
||||
options: ['option1', 'option2', 'option3'],
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('option1')
|
||||
expect(radioButtons[1].props('value')).toBe('option2')
|
||||
expect(radioButtons[2].props('value')).toBe('option3')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('option1')
|
||||
expect(labels[1].text()).toBe('option2')
|
||||
expect(labels[2].text()).toBe('option3')
|
||||
})
|
||||
|
||||
it('handles SettingOption array', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Small', value: 'sm' },
|
||||
{ text: 'Medium', value: 'md' },
|
||||
{ text: 'Large', value: 'lg' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'md',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('sm')
|
||||
expect(radioButtons[1].props('value')).toBe('md')
|
||||
expect(radioButtons[2].props('value')).toBe('lg')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Small')
|
||||
expect(labels[1].text()).toBe('Medium')
|
||||
expect(labels[2].text()).toBe('Large')
|
||||
})
|
||||
|
||||
it('handles SettingOption with undefined value (uses text as value)', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Option A', value: undefined },
|
||||
{ text: 'Option B' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Option A',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('Option A')
|
||||
expect(radioButtons[1].props('value')).toBe('Option B')
|
||||
})
|
||||
|
||||
it('handles custom object with optionLabel and optionValue', () => {
|
||||
const options = [
|
||||
{ name: 'First Option', id: 1 },
|
||||
{ name: 'Second Option', id: 2 },
|
||||
{ name: 'Third Option', id: 3 }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 2,
|
||||
options,
|
||||
optionLabel: 'name',
|
||||
optionValue: 'id',
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe(1)
|
||||
expect(radioButtons[1].props('value')).toBe(2)
|
||||
expect(radioButtons[2].props('value')).toBe(3)
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('First Option')
|
||||
expect(labels[1].text()).toBe('Second Option')
|
||||
expect(labels[2].text()).toBe('Third Option')
|
||||
})
|
||||
|
||||
it('handles mixed array with strings and SettingOptions', () => {
|
||||
const options: (string | SettingOption)[] = [
|
||||
'Simple String',
|
||||
{ text: 'Complex Option', value: 'complex' },
|
||||
'Another String'
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'complex',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('Simple String')
|
||||
expect(radioButtons[1].props('value')).toBe('complex')
|
||||
expect(radioButtons[2].props('value')).toBe('Another String')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Simple String')
|
||||
expect(labels[1].text()).toBe('Complex Option')
|
||||
expect(labels[2].text()).toBe('Another String')
|
||||
})
|
||||
|
||||
it('handles empty options array', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: null,
|
||||
options: [],
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles undefined options gracefully', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: null,
|
||||
options: undefined,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles object with missing properties gracefully', () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', val: 'opt1' },
|
||||
{ text: 'Option 2', value: 'opt2' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(2)
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Unknown')
|
||||
expect(labels[1].text()).toBe('Option 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('component functionality', () => {
|
||||
it('sets correct input-id and name attributes', () => {
|
||||
const options = ['A', 'B']
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'A',
|
||||
options,
|
||||
id: 'my-radio-group'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
|
||||
expect(radioButtons[0].props('name')).toBe('my-radio-group')
|
||||
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
|
||||
expect(radioButtons[1].props('name')).toBe('my-radio-group')
|
||||
})
|
||||
|
||||
it('associates labels with radio buttons correctly', () => {
|
||||
const options = ['Yes', 'No']
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Yes',
|
||||
options,
|
||||
id: 'confirm-radio'
|
||||
})
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
|
||||
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
|
||||
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
|
||||
})
|
||||
|
||||
it('sets aria-describedby attribute correctly', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Option 1', value: 'opt1' },
|
||||
{ text: 'Option 2', value: 'opt2' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].attributes('aria-describedby')).toBe(
|
||||
'Option 1-label'
|
||||
)
|
||||
expect(radioButtons[1].attributes('aria-describedby')).toBe(
|
||||
'Option 2-label'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
62
src/components/common/FormRadioGroup.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-4">
|
||||
<div
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
class="flex items-center"
|
||||
>
|
||||
<RadioButton
|
||||
:input-id="`${id}-${option.value}`"
|
||||
:name="id"
|
||||
:value="option.value"
|
||||
:model-value="modelValue"
|
||||
:aria-describedby="`${option.text}-label`"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<label :for="`${id}-${option.value}`" class="ml-2 cursor-pointer">
|
||||
{{ option.text }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SettingOption } from '@/types/settingTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any
|
||||
options: (SettingOption | string)[]
|
||||
optionLabel?: string
|
||||
optionValue?: string
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: any]
|
||||
}>()
|
||||
|
||||
const normalizedOptions = computed<SettingOption[]>(() => {
|
||||
if (!props.options) return []
|
||||
|
||||
return props.options.map((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return { text: option, value: option }
|
||||
}
|
||||
|
||||
if ('text' in option) {
|
||||
return {
|
||||
text: option.text,
|
||||
value: option.value ?? option.text
|
||||
}
|
||||
}
|
||||
// Handle optionLabel/optionValue
|
||||
return {
|
||||
text: option[props.optionLabel || 'text'] || 'Unknown',
|
||||
value: option[props.optionValue || 'value']
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -113,7 +113,12 @@ const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
settings: flattenTree<SettingParams>(group)
|
||||
settings: flattenTree<SettingParams>(group).sort((a, b) => {
|
||||
const sortOrderA = a.sortOrder ?? 0
|
||||
const sortOrderB = b.sortOrder ?? 0
|
||||
|
||||
return sortOrderB - sortOrderA
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ import { computed } from 'vue'
|
||||
|
||||
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
|
||||
@@ -40,13 +40,12 @@
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<VueGraphNode
|
||||
v-for="nodeData in nodesToRender"
|
||||
v-for="nodeData in allNodes"
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
:size="nodeSizes.get(nodeData.id)"
|
||||
:readonly="false"
|
||||
:executing="executionStore.executingNodeId === nodeData.id"
|
||||
:error="
|
||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
||||
? 'Execution error'
|
||||
@@ -113,11 +112,13 @@ import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import TransformPane from '@/renderer/core/layout/TransformPane.vue'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -127,7 +128,6 @@ import { newUserService } from '@/services/newUserService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
@@ -183,12 +183,12 @@ const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
|
||||
|
||||
const nodePositions = vueNodeLifecycle.nodePositions
|
||||
const nodeSizes = vueNodeLifecycle.nodeSizes
|
||||
const nodesToRender = viewportCulling.nodesToRender
|
||||
const allNodes = viewportCulling.allNodes
|
||||
|
||||
const handleTransformUpdate = () => {
|
||||
viewportCulling.handleTransformUpdate(
|
||||
vueNodeLifecycle.detectChangesInRAF.value
|
||||
)
|
||||
viewportCulling.handleTransformUpdate()
|
||||
// TODO: Fix paste position sync in separate PR
|
||||
vueNodeLifecycle.detectChangesInRAF.value()
|
||||
}
|
||||
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
|
||||
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
|
||||
@@ -205,6 +205,9 @@ const selectedNodeIds = computed(
|
||||
)
|
||||
provide(SelectedNodeIdsKey, selectedNodeIds)
|
||||
|
||||
// Provide execution state to all Vue nodes
|
||||
useExecutionStateProvider()
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
})
|
||||
|
||||
@@ -127,9 +127,9 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useZoomControls } from '@/composables/useZoomControls'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
|
||||
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 { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
// 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
|
||||
v-if="visible"
|
||||
class="rounded-lg selection-toolbox pointer-events-auto"
|
||||
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
|
||||
:pt="{
|
||||
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"
|
||||
>
|
||||
<ExecuteButton />
|
||||
<ColorPickerButton />
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<Load3DViewerButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<PublishSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshSelectionButton />
|
||||
<DeleteButton v-if="showDelete" />
|
||||
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="showInfoButton" />
|
||||
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
|
||||
<PublishSubgraphButton v-if="showPublishSubgraph" />
|
||||
<MaskEditorButton v-if="showMaskEditor" />
|
||||
<VerticalDivider
|
||||
v-if="showAnyPrimaryActions && showAnyControlActions"
|
||||
/>
|
||||
|
||||
<BypassButton v-if="showBypass" />
|
||||
<RefreshSelectionButton v-if="showRefresh" />
|
||||
<Load3DViewerButton v-if="showLoad3DViewer" />
|
||||
|
||||
<ExtensionCommandButton
|
||||
v-for="command in extensionToolboxCommands"
|
||||
:key="command.id"
|
||||
:command="command"
|
||||
/>
|
||||
<HelpButton />
|
||||
<ExecuteButton v-if="showExecute" />
|
||||
<MoreOptions />
|
||||
</Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -45,22 +54,29 @@ import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/Convert
|
||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.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 MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
|
||||
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
|
||||
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
|
||||
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
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 canvasStore = useCanvasStore()
|
||||
const extensionService = useExtensionService()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const minimap = useMinimap()
|
||||
const containerStyles = minimap.containerStyles
|
||||
|
||||
const toolboxRef = ref<HTMLElement | undefined>()
|
||||
const { visible } = useSelectionToolboxPosition(toolboxRef)
|
||||
@@ -80,6 +96,44 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
.map((commandId) => commandStore.getCommand(commandId))
|
||||
.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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -24,8 +24,11 @@ import {
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
useCanvasStore,
|
||||
useTitleEditorStore
|
||||
} from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -139,9 +139,9 @@ import { Button, InputNumber, type InputNumberInputEvent } from 'primevue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
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 { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
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>
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
|
||||
showDelay: 1000
|
||||
@@ -8,12 +7,11 @@
|
||||
severity="secondary"
|
||||
text
|
||||
data-testid="bypass-button"
|
||||
@click="
|
||||
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
|
||||
"
|
||||
class="hover:dark-theme:bg-charcoal-300 hover:bg-[#E7E6E6]"
|
||||
@click="toggleBypass"
|
||||
>
|
||||
<template #icon>
|
||||
<i-game-icons:detour />
|
||||
<i-lucide:ban class="w-4 h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -23,9 +21,11 @@ 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()
|
||||
|
||||
const toggleBypass = async () => {
|
||||
await commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
// Import after mocks
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
// Mock the litegraph module
|
||||
@@ -95,17 +95,6 @@ describe('ColorPickerButton', () => {
|
||||
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 () => {
|
||||
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
|
||||
const wrapper = createWrapper()
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
|
||||
v-tooltip.top="{
|
||||
value: localizedCurrentColorName ?? t('color.noColor'),
|
||||
showDelay: 512
|
||||
showDelay: 1000
|
||||
}"
|
||||
data-testid="color-picker-button"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="() => (showColorPicker = !showColorPicker)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
|
||||
<i class="pi pi-chevron-down" :style="{ fontSize: '0.5rem' }" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex items-center gap-1 px-0">
|
||||
<i
|
||||
class="w-4 h-4 pi pi-circle-fill"
|
||||
:style="{ color: currentColor ?? '' }"
|
||||
/>
|
||||
<i
|
||||
class="w-4 h-4 pi pi-chevron-down py-1"
|
||||
:style="{ fontSize: '0.5rem' }"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
<div
|
||||
v-if="showColorPicker"
|
||||
@@ -46,16 +50,19 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Raw, computed, ref, watch } from 'vue'
|
||||
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 {
|
||||
LGraphCanvas,
|
||||
LiteGraph,
|
||||
isColorable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
@@ -140,13 +147,17 @@ const localizedCurrentColorName = computed(() => {
|
||||
)
|
||||
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
|
||||
})
|
||||
|
||||
const updateColorSelectionFromNode = (
|
||||
newSelectedItems: Raw<Positionable[]>
|
||||
) => {
|
||||
showColorPicker.value = false
|
||||
selectedColorOption.value = null
|
||||
currentColorOption.value = getItemsColorOption(newSelectedItems)
|
||||
}
|
||||
watch(
|
||||
() => canvasStore.selectedItems,
|
||||
(newSelectedItems) => {
|
||||
showColorPicker.value = false
|
||||
selectedColorOption.value = null
|
||||
currentColorOption.value = getItemsColorOption(newSelectedItems)
|
||||
updateColorSelectionFromNode(newSelectedItems)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
data-testid="convert-to-subgraph-button"
|
||||
text
|
||||
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:expand />
|
||||
<i-lucide:expand class="w-4 h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -20,6 +21,7 @@
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
data-testid="convert-to-subgraph-button"
|
||||
text
|
||||
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
|
||||
>
|
||||
@@ -34,25 +36,15 @@ import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { isSingleSubgraph, hasAnySelection } = useSelectionState()
|
||||
|
||||
const isUnpackVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.selectedItems?.length === 1 &&
|
||||
canvasStore.selectedItems[0] instanceof SubgraphNode
|
||||
)
|
||||
})
|
||||
const isConvertVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.groupSelected ||
|
||||
canvasStore.rerouteSelected ||
|
||||
canvasStore.nodeSelected
|
||||
)
|
||||
})
|
||||
const isUnpackVisible = isSingleSubgraph
|
||||
const isConvertVisible = computed(
|
||||
() => hasAnySelection.value && !isSingleSubgraph.value
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="danger"
|
||||
severity="secondary"
|
||||
text
|
||||
icon-class="w-4 h-4"
|
||||
icon="pi pi-trash"
|
||||
data-testid="delete-button"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
|
||||
/>
|
||||
</template>
|
||||
@@ -17,14 +19,15 @@ import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { selectedItems } = useSelectionState()
|
||||
|
||||
const isDeletable = computed(() =>
|
||||
canvasStore.selectedItems.some((x) => x.removable !== false)
|
||||
selectedItems.value.some((x: Positionable) => x.removable !== false)
|
||||
)
|
||||
</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 { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
// Mock the stores
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
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>
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected"
|
||||
v-tooltip.top="{
|
||||
value: isDisabled
|
||||
? t('selectionToolbox.executeButton.disabledTooltip')
|
||||
: t('selectionToolbox.executeButton.tooltip'),
|
||||
value: t('selectionToolbox.executeButton.tooltip'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
:severity="isDisabled ? 'secondary' : 'success'"
|
||||
class="dark-theme:bg-[#0B8CE9] bg-[#31B9F4] size-8 !p-0"
|
||||
text
|
||||
:disabled="isDisabled"
|
||||
@mouseenter="() => handleMouseEnter()"
|
||||
@mouseleave="() => handleMouseLeave()"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i-lucide:play />
|
||||
<i-lucide:play class="fill-path-white w-4 h-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -23,26 +19,24 @@ import Button from 'primevue/button'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isOutputNode } from '@/utils/nodeFilterUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { selectedNodes } = useSelectionState()
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const buttonHovered = ref(false)
|
||||
const selectedOutputNodes = computed(
|
||||
() =>
|
||||
canvasStore.selectedItems.filter(
|
||||
(item) => isLGraphNode(item) && item.constructor.nodeData?.output_node
|
||||
) as LGraphNode[]
|
||||
const selectedOutputNodes = computed(() =>
|
||||
selectedNodes.value.filter(isLGraphNode).filter(isOutputNode)
|
||||
)
|
||||
|
||||
const isDisabled = computed(() => selectedOutputNodes.value.length === 0)
|
||||
|
||||
function outputNodeStokeStyle(this: LGraphNode) {
|
||||
if (
|
||||
this.selected &&
|
||||
@@ -70,3 +64,9 @@ const handleClick = async () => {
|
||||
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
:deep.fill-path-white > path {
|
||||
fill: white;
|
||||
stroke: unset;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon-class="w-4 h-4"
|
||||
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
|
||||
@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 '@/renderer/core/canvas/canvasStore'
|
||||
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>
|
||||
<Button
|
||||
v-show="is3DNode"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_3DViewer_Open3DViewer.label'),
|
||||
showDelay: 1000
|
||||
@@ -8,29 +7,18 @@
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-pencil"
|
||||
icon-class="w-4 h-4"
|
||||
@click="open3DViewer"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
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 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 = () => {
|
||||
void commandStore.execute('Comfy.3DViewer.Open3DViewer')
|
||||
|
||||
@@ -7,28 +7,21 @@
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-pencil"
|
||||
@click="openMaskEditor"
|
||||
/>
|
||||
>
|
||||
<i-comfy:mask class="!w-4 !h-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { t } from '@/i18n'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isSingleImageNode = computed(() => {
|
||||
const { selectedItems } = canvasStore
|
||||
const item = selectedItems[0]
|
||||
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
|
||||
})
|
||||
const { isSingleImageNode } = useSelectionState()
|
||||
|
||||
const 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>
|
||||
<Button
|
||||
v-show="isRefreshable"
|
||||
severity="info"
|
||||
v-tooltip.top="t('g.refreshNode')"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-refresh"
|
||||
data-testid="refresh-button"
|
||||
@click="refreshSelected"
|
||||
/>
|
||||
>
|
||||
<i-lucide:refresh-cw class="w-4 h-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isRefreshable, refreshSelected } = useRefreshableSelection()
|
||||
</script>
|
||||
|
||||
@@ -21,8 +21,8 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
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>
|
||||
@@ -30,9 +30,9 @@ import {
|
||||
|
||||
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
|
||||
import { useDomClipping } from '@/composables/element/useDomClipping'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
|
||||
import { type DomWidgetState } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import type { DomWidgetState } from '@/stores/domWidgetStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const { widgetState } = defineProps<{
|
||||
|
||||
@@ -45,8 +45,8 @@ import {
|
||||
type LiteGraphCanvasEvent
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
interface CanvasTransformSyncOptions {
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
type Positionable,
|
||||
Reroute
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import {
|
||||
collectFromNodes,
|
||||
traverseNodesDepthFirst
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
@@ -6,14 +6,44 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { computeUnionBounds } from '@/utils/mathUtil'
|
||||
|
||||
/**
|
||||
* Manages the position of the selection toolbox independently.
|
||||
* 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(
|
||||
toolboxRef: Ref<HTMLElement | undefined>
|
||||
) {
|
||||
@@ -105,10 +135,17 @@ export function useSelectionToolboxPosition(
|
||||
() => canvasStore.getCanvas().state.selectionChanged,
|
||||
(changed) => {
|
||||
if (changed) {
|
||||
if (moreOptionsRestorePending.value || moreOptionsSelectionSignature) {
|
||||
moreOptionsRestorePending.value = false
|
||||
moreOptionsWasOpenBeforeDrag = false
|
||||
if (!moreOptionsOpen.value) {
|
||||
moreOptionsSelectionSignature = null
|
||||
} else {
|
||||
moreOptionsSelectionSignature = buildSelectionSignature(canvasStore)
|
||||
}
|
||||
}
|
||||
updateSelectionBounds()
|
||||
canvasStore.getCanvas().state.selectionChanged = false
|
||||
|
||||
// Start transform sync if we have selection
|
||||
if (visible.value) {
|
||||
startSync()
|
||||
} else {
|
||||
@@ -118,24 +155,77 @@ export function useSelectionToolboxPosition(
|
||||
},
|
||||
{ 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(
|
||||
() => canvasStore.canvas?.state?.draggingItems,
|
||||
(dragging) => {
|
||||
if (dragging) {
|
||||
// Hide during node dragging
|
||||
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 {
|
||||
// Update after dragging ends
|
||||
requestAnimationFrame(() => {
|
||||
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 {
|
||||
visible
|
||||
}
|
||||
}
|
||||
|
||||
// External cleanup utility to be called when SelectionToolbox component unmounts
|
||||
function resetMoreOptionsState() {
|
||||
moreOptionsOpen.value = false
|
||||
moreOptionsRestorePending.value = false
|
||||
moreOptionsWasOpenBeforeDrag = false
|
||||
moreOptionsSelectionSignature = null
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type CSSProperties, ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import type { Size, Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
export interface PositionConfig {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
/**
|
||||
|
||||
22
src/composables/graph/useCanvasRefresh.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// call nextTick on all changeTracker
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
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 { useTitleEditorStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
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 }
|
||||
}
|
||||
@@ -53,9 +53,11 @@ export interface VueNodeData {
|
||||
mode: number
|
||||
selected: boolean
|
||||
executing: boolean
|
||||
subgraphId?: string | null
|
||||
widgets?: SafeWidgetData[]
|
||||
inputs?: unknown[]
|
||||
outputs?: unknown[]
|
||||
hasErrors?: boolean
|
||||
flags?: {
|
||||
collapsed?: boolean
|
||||
}
|
||||
@@ -166,6 +168,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
const subgraphId =
|
||||
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
|
||||
? String(node.graph.id)
|
||||
: null
|
||||
// Extract safe widget data
|
||||
const safeWidgets = node.widgets?.map((widget) => {
|
||||
try {
|
||||
@@ -201,13 +208,22 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
}
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
node.type ||
|
||||
node.constructor?.comfyClass ||
|
||||
node.constructor?.title ||
|
||||
node.constructor?.name ||
|
||||
'Unknown'
|
||||
|
||||
return {
|
||||
id: String(node.id),
|
||||
title: node.title || 'Untitled',
|
||||
type: node.type || 'Unknown',
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
type: nodeType,
|
||||
mode: node.mode || 0,
|
||||
selected: node.selected || false,
|
||||
executing: false, // Will be updated separately based on execution state
|
||||
subgraphId,
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: node.inputs ? [...node.inputs] : undefined,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
@@ -610,7 +626,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract safe data for Vue
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
// Set up reactive tracking state
|
||||
@@ -655,7 +671,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
// Chain our callback with any existing onAfterGraphConfigured callback
|
||||
node.onAfterGraphConfigured = useChainCallback(
|
||||
node.onAfterGraphConfigured,
|
||||
initializeVueNodeLayout
|
||||
() => {
|
||||
// Re-extract data now that configure() has populated title/slots/widgets/etc.
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Not during workflow loading - initialize layout immediately
|
||||
|
||||
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 '@/renderer/core/canvas/canvasStore'
|
||||
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
|
||||
}
|
||||
}
|
||||
108
src/composables/graph/useImageMenuOptions.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
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')
|
||||
downloadFile(url.toString())
|
||||
} 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 '@/renderer/core/canvas/canvasStore'
|
||||
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 '@/renderer/core/canvas/canvasStore'
|
||||
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
|
||||
}
|
||||
}
|
||||
168
src/composables/graph/useSelectionOperations.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' // Unused for now
|
||||
import { t } from '@/i18n'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
useCanvasStore,
|
||||
useTitleEditorStore
|
||||
} from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
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
|
||||
}
|
||||
}
|
||||
146
src/composables/graph/useSelectionState.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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 '@/renderer/core/canvas/canvasStore'
|
||||
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: unknown) =>
|
||||
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: unknown) => 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 '@/renderer/core/canvas/canvasStore'
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* Viewport Culling Composable
|
||||
* Vue Nodes Viewport Culling
|
||||
*
|
||||
* Handles viewport culling optimization for Vue nodes including:
|
||||
* - Transform state synchronization
|
||||
* - Visible node calculation with screen space transforms
|
||||
* - Adaptive margin computation based on zoom level
|
||||
* - Performance optimizations for large graphs
|
||||
* Principles:
|
||||
* 1. Query DOM directly using data attributes (no cache to maintain)
|
||||
* 2. Set display none on element to avoid cascade resolution overhead
|
||||
* 3. Only run when transform changes (event driven)
|
||||
*/
|
||||
import { type Ref, computed, readonly, ref } from 'vue'
|
||||
import { type Ref, computed } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useTransformState } from '@/renderer/core/layout/useTransformState'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
interface NodeManager {
|
||||
getNode: (id: string) => any
|
||||
@@ -25,188 +23,84 @@ export function useViewportCulling(
|
||||
nodeManager: Ref<NodeManager | null>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { syncWithCanvas } = useTransformState()
|
||||
|
||||
// Transform tracking for performance optimization
|
||||
const lastScale = ref(1)
|
||||
const lastOffsetX = ref(0)
|
||||
const lastOffsetY = ref(0)
|
||||
|
||||
// Current transform state
|
||||
const currentTransformState = computed(() => ({
|
||||
scale: lastScale.value,
|
||||
offsetX: lastOffsetX.value,
|
||||
offsetY: lastOffsetY.value
|
||||
}))
|
||||
|
||||
/**
|
||||
* Computed property that returns nodes visible in the current viewport
|
||||
* Implements sophisticated culling algorithm with adaptive margins
|
||||
*/
|
||||
const nodesToRender = computed(() => {
|
||||
if (!isVueNodesEnabled.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Access trigger to force re-evaluation after nodeManager initialization
|
||||
void nodeDataTrigger.value
|
||||
|
||||
if (!comfyApp.graph) {
|
||||
return []
|
||||
}
|
||||
|
||||
const allNodes = Array.from(vueNodeData.value.values())
|
||||
|
||||
// Apply viewport culling - check if node bounds intersect with viewport
|
||||
// TODO: use quadtree
|
||||
if (nodeManager.value && canvasStore.canvas && comfyApp.canvas) {
|
||||
const canvas = canvasStore.canvas
|
||||
const manager = nodeManager.value
|
||||
|
||||
// Ensure transform is synced before checking visibility
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
|
||||
const ds = canvas.ds
|
||||
|
||||
// Work in screen space - viewport is simply the canvas element size
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
|
||||
// Add margin that represents a constant distance in canvas space
|
||||
// Convert canvas units to screen pixels by multiplying by scale
|
||||
const canvasMarginDistance = 200 // Fixed margin in canvas units
|
||||
const margin_x = canvasMarginDistance * ds.scale
|
||||
const margin_y = canvasMarginDistance * ds.scale
|
||||
|
||||
const filtered = allNodes.filter((nodeData) => {
|
||||
const node = manager.getNode(nodeData.id)
|
||||
if (!node) return false
|
||||
|
||||
// Transform node position to screen space (same as DOM widgets)
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
|
||||
// Check if node bounds intersect with expanded viewport (in screen space)
|
||||
const isVisible = !(
|
||||
screen_x + screen_width < -margin_x ||
|
||||
screen_x > viewport_width + margin_x ||
|
||||
screen_y + screen_height < -margin_y ||
|
||||
screen_y > viewport_height + margin_y
|
||||
)
|
||||
|
||||
return isVisible
|
||||
})
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
return allNodes
|
||||
const allNodes = computed(() => {
|
||||
if (!isVueNodesEnabled.value) return []
|
||||
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
|
||||
return Array.from(vueNodeData.value.values())
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle transform updates with performance optimization
|
||||
* Only syncs when transform actually changes to avoid unnecessary reflows
|
||||
* Update visibility of all nodes based on viewport
|
||||
* Queries DOM directly - no cache maintenance needed
|
||||
*/
|
||||
const handleTransformUpdate = (detectChangesInRAF: () => void) => {
|
||||
// Skip all work if Vue nodes are disabled
|
||||
if (!isVueNodesEnabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sync transform state only when it changes (avoids reflows)
|
||||
if (comfyApp.canvas?.ds) {
|
||||
const currentScale = comfyApp.canvas.ds.scale
|
||||
const currentOffsetX = comfyApp.canvas.ds.offset[0]
|
||||
const currentOffsetY = comfyApp.canvas.ds.offset[1]
|
||||
|
||||
if (
|
||||
currentScale !== lastScale.value ||
|
||||
currentOffsetX !== lastOffsetX.value ||
|
||||
currentOffsetY !== lastOffsetY.value
|
||||
) {
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
lastScale.value = currentScale
|
||||
lastOffsetX.value = currentOffsetX
|
||||
lastOffsetY.value = currentOffsetY
|
||||
}
|
||||
}
|
||||
|
||||
// Detect node changes during transform updates
|
||||
detectChangesInRAF()
|
||||
|
||||
// Trigger reactivity for nodesToRender
|
||||
void nodesToRender.value.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate if a specific node is visible in viewport
|
||||
* Useful for individual node visibility checks
|
||||
*/
|
||||
const isNodeVisible = (nodeData: VueNodeData): boolean => {
|
||||
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) {
|
||||
return true // Default to visible if culling not available
|
||||
}
|
||||
const updateVisibility = () => {
|
||||
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
|
||||
|
||||
const canvas = canvasStore.canvas
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return false
|
||||
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
const manager = nodeManager.value
|
||||
const ds = canvas.ds
|
||||
|
||||
// Viewport bounds
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
const canvasMarginDistance = 200
|
||||
const margin_x = canvasMarginDistance * ds.scale
|
||||
const margin_y = canvasMarginDistance * ds.scale
|
||||
const margin = 500 * ds.scale
|
||||
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
// Get all node elements at once
|
||||
const nodeElements = document.querySelectorAll('[data-node-id]')
|
||||
|
||||
return !(
|
||||
screen_x + screen_width < -margin_x ||
|
||||
screen_x > viewport_width + margin_x ||
|
||||
screen_y + screen_height < -margin_y ||
|
||||
screen_y > viewport_height + margin_y
|
||||
)
|
||||
// Update each element's visibility
|
||||
for (const element of nodeElements) {
|
||||
const nodeId = element.getAttribute('data-node-id')
|
||||
if (!nodeId) continue
|
||||
|
||||
const node = manager.getNode(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
// Calculate if node is outside viewport
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
|
||||
const isNodeOutsideViewport =
|
||||
screen_x + screen_width < -margin ||
|
||||
screen_x > viewport_width + margin ||
|
||||
screen_y + screen_height < -margin ||
|
||||
screen_y > viewport_height + margin
|
||||
|
||||
// Setting display none directly avoid potential cascade resolution
|
||||
if (element instanceof HTMLElement) {
|
||||
element.style.display = isNodeOutsideViewport ? 'none' : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RAF throttling for smooth updates during continuous panning
|
||||
let rafId: number | null = null
|
||||
|
||||
/**
|
||||
* Get viewport bounds information for debugging
|
||||
* Handle transform update - called by TransformPane event
|
||||
* Uses RAF to batch updates for smooth performance
|
||||
*/
|
||||
const getViewportInfo = () => {
|
||||
if (!canvasStore.canvas || !comfyApp.canvas) {
|
||||
return null
|
||||
const handleTransformUpdate = () => {
|
||||
if (!isVueNodesEnabled.value) return
|
||||
|
||||
// Cancel previous RAF if still pending
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
|
||||
const canvas = canvasStore.canvas
|
||||
const ds = canvas.ds
|
||||
|
||||
return {
|
||||
viewport_width: canvas.canvas.width,
|
||||
viewport_height: canvas.canvas.height,
|
||||
scale: ds.scale,
|
||||
offset: [ds.offset[0], ds.offset[1]],
|
||||
margin_distance: 200,
|
||||
margin_x: 200 * ds.scale,
|
||||
margin_y: 200 * ds.scale
|
||||
}
|
||||
// Schedule update in next animation frame
|
||||
rafId = requestAnimationFrame(() => {
|
||||
updateVisibility()
|
||||
rafId = null
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
nodesToRender,
|
||||
allNodes,
|
||||
handleTransformUpdate,
|
||||
isNodeVisible,
|
||||
getViewportInfo,
|
||||
|
||||
// Transform state
|
||||
currentTransformState: readonly(currentTransformState),
|
||||
lastScale: readonly(lastScale),
|
||||
lastOffsetX: readonly(lastOffsetX),
|
||||
lastOffsetY: readonly(lastOffsetY)
|
||||
updateVisibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,13 @@ import type {
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync'
|
||||
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Adds a handler on copy that serializes selected nodes to JSON
|
||||
|
||||
@@ -12,9 +12,13 @@ import {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
SubgraphNode,
|
||||
type Point
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { type Point } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
useCanvasStore,
|
||||
useTitleEditorStore
|
||||
} from '@/renderer/core/canvas/canvasStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -22,7 +26,6 @@ import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { type ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
interface RefreshableItem {
|
||||
|
||||
@@ -6,9 +6,20 @@
|
||||
"noWorkflowsFound": "No workflows found.",
|
||||
"comingSoon": "Coming Soon",
|
||||
"download": "Download",
|
||||
"downloadImage": "Download image",
|
||||
"editOrMaskImage": "Edit or mask image",
|
||||
"removeImage": "Remove image",
|
||||
"viewImageOfTotal": "View image {index} of {total}",
|
||||
"imagePreview": "Image preview - Use arrow keys to navigate between images",
|
||||
"galleryImage": "Gallery image",
|
||||
"galleryThumbnail": "Gallery thumbnail",
|
||||
"errorLoadingImage": "Error loading image",
|
||||
"failedToDownloadImage": "Failed to download image",
|
||||
"calculatingDimensions": "Calculating dimensions",
|
||||
"import": "Import",
|
||||
"loadAllFolders": "Load All Folders",
|
||||
"refresh": "Refresh",
|
||||
"refreshNode": "Refresh Node",
|
||||
"terminal": "Terminal",
|
||||
"logs": "Logs",
|
||||
"videoFailedToLoad": "Video failed to load",
|
||||
@@ -30,7 +41,9 @@
|
||||
"icon": "Icon",
|
||||
"color": "Color",
|
||||
"error": "Error",
|
||||
"help": "Help",
|
||||
"info": "Node Info",
|
||||
"bookmark": "Save to Library",
|
||||
"moreOptions": "More Options",
|
||||
"loading": "Loading",
|
||||
"loadingPanel": "Loading {panel} panel...",
|
||||
"preview": "PREVIEW",
|
||||
@@ -157,7 +170,8 @@
|
||||
"nodeContentError": "Node Content Error",
|
||||
"nodeHeaderError": "Node Header Error",
|
||||
"nodeSlotsError": "Node Slots Error",
|
||||
"nodeWidgetsError": "Node Widgets Error"
|
||||
"nodeWidgetsError": "Node Widgets Error",
|
||||
"frameNodes": "Frame Nodes"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
@@ -311,7 +325,37 @@
|
||||
"Save Selected as Template": "Save Selected as Template",
|
||||
"Node Templates": "Node Templates",
|
||||
"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": {
|
||||
"bookmark": "Bookmark",
|
||||
@@ -462,6 +506,14 @@
|
||||
"revertChanges": "Revert Changes",
|
||||
"restart": "Restart"
|
||||
},
|
||||
"shape": {
|
||||
"default": "Default",
|
||||
"round": "Round",
|
||||
"CARD": "Card",
|
||||
"circle": "Circle",
|
||||
"arrow": "Arrow",
|
||||
"box": "Box"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"themeToggle": "Toggle Theme",
|
||||
"helpCenter": "Help Center",
|
||||
@@ -1758,7 +1810,10 @@
|
||||
"executeButton": {
|
||||
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
|
||||
"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": {
|
||||
"cancelEdit": "Cancel",
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
|
||||
/**
|
||||
* Injection key for providing selected node IDs to Vue node components.
|
||||
* Contains a reactive Set of selected node IDs (as strings).
|
||||
*/
|
||||
export const SelectedNodeIdsKey: InjectionKey<Ref<Set<string>>> =
|
||||
Symbol('selectedNodeIds')
|
||||
|
||||
/**
|
||||
* Injection key for providing executing node IDs to Vue node components.
|
||||
* Contains a reactive Set of currently executing node IDs (as strings).
|
||||
*/
|
||||
export const ExecutingNodeIdsKey: InjectionKey<Ref<Set<string>>> =
|
||||
Symbol('executingNodeIds')
|
||||
|
||||
/**
|
||||
* Injection key for providing node progress states to Vue node components.
|
||||
* Contains a reactive Record of node IDs to their current progress state.
|
||||
*/
|
||||
export const NodeProgressStatesKey: InjectionKey<
|
||||
Ref<Record<string, NodeProgressState>>
|
||||
> = Symbol('nodeProgressStates')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import TransformPane from '../TransformPane.vue'
|
||||
import TransformPane from '../transform/TransformPane.vue'
|
||||
|
||||
// Mock the transform state composable
|
||||
const mockTransformState = {
|
||||
|
||||
@@ -36,9 +36,19 @@ import {
|
||||
type Point,
|
||||
type RerouteId,
|
||||
type RerouteLayout,
|
||||
type Size,
|
||||
type SlotLayout
|
||||
} from '@/renderer/core/layout/types'
|
||||
import {
|
||||
REROUTE_RADIUS,
|
||||
boundsIntersect,
|
||||
pointInBounds
|
||||
} from '@/renderer/core/layout/utils/layoutMath'
|
||||
import { makeLinkSegmentKey } from '@/renderer/core/layout/utils/layoutUtils'
|
||||
import {
|
||||
type NodeLayoutMap,
|
||||
layoutToYNode,
|
||||
yNodeToLayout
|
||||
} from '@/renderer/core/layout/utils/mappers'
|
||||
import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex'
|
||||
|
||||
type YEventChange = {
|
||||
@@ -48,9 +58,6 @@ type YEventChange = {
|
||||
|
||||
const logger = log.getLogger('LayoutStore')
|
||||
|
||||
// Constants
|
||||
const REROUTE_RADIUS = 8
|
||||
|
||||
// Utility functions
|
||||
function asRerouteId(id: string | number): RerouteId {
|
||||
return Number(id)
|
||||
@@ -60,15 +67,6 @@ function asLinkId(id: string | number): LinkId {
|
||||
return Number(id)
|
||||
}
|
||||
|
||||
interface NodeLayoutData {
|
||||
id: NodeId
|
||||
position: Point
|
||||
size: Size
|
||||
zIndex: number
|
||||
visible: boolean
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
interface LinkData {
|
||||
id: LinkId
|
||||
sourceNodeId: NodeId
|
||||
@@ -91,15 +89,6 @@ interface TypedYMap<T> {
|
||||
}
|
||||
|
||||
class LayoutStoreImpl implements LayoutStore {
|
||||
private static readonly NODE_DEFAULTS: NodeLayoutData = {
|
||||
id: 'unknown-node',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 50 },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 100, height: 50 }
|
||||
}
|
||||
|
||||
private static readonly REROUTE_DEFAULTS: RerouteData = {
|
||||
id: 0,
|
||||
position: { x: 0, y: 0 },
|
||||
@@ -109,7 +98,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Yjs document and shared data structures
|
||||
private ydoc = new Y.Doc()
|
||||
private ynodes: Y.Map<Y.Map<unknown>> // Maps nodeId -> Y.Map containing NodeLayout data
|
||||
private ynodes: Y.Map<NodeLayoutMap> // Maps nodeId -> NodeLayoutMap containing NodeLayout data
|
||||
private ylinks: Y.Map<Y.Map<unknown>> // Maps linkId -> Y.Map containing link data
|
||||
private yreroutes: Y.Map<Y.Map<unknown>> // Maps rerouteId -> Y.Map containing reroute data
|
||||
private yoperations: Y.Array<LayoutOperation> // Operation log
|
||||
@@ -155,7 +144,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
this.rerouteSpatialIndex = new SpatialIndexManager()
|
||||
|
||||
// Listen for Yjs changes and trigger Vue reactivity
|
||||
this.ynodes.observe((event: Y.YMapEvent<Y.Map<unknown>>) => {
|
||||
this.ynodes.observe((event: Y.YMapEvent<NodeLayoutMap>) => {
|
||||
this.version++
|
||||
|
||||
// Trigger all affected node refs
|
||||
@@ -184,16 +173,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
})
|
||||
}
|
||||
|
||||
private getNodeField<K extends keyof NodeLayoutData>(
|
||||
ynode: Y.Map<unknown>,
|
||||
field: K,
|
||||
defaultValue: NodeLayoutData[K] = LayoutStoreImpl.NODE_DEFAULTS[field]
|
||||
): NodeLayoutData[K] {
|
||||
const typedNode = ynode as TypedYMap<NodeLayoutData>
|
||||
const value = typedNode.get(field)
|
||||
return value ?? defaultValue
|
||||
}
|
||||
|
||||
private getLinkField<K extends keyof LinkData>(
|
||||
ylink: Y.Map<unknown>,
|
||||
field: K
|
||||
@@ -227,7 +206,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
get: () => {
|
||||
track()
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
const layout = ynode ? this.yNodeToLayout(ynode) : null
|
||||
const layout = ynode ? yNodeToLayout(ynode) : null
|
||||
return layout
|
||||
},
|
||||
set: (newLayout: NodeLayout | null) => {
|
||||
@@ -242,7 +221,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
timestamp: Date.now(),
|
||||
source: this.currentSource,
|
||||
actor: this.currentActor,
|
||||
previousLayout: this.yNodeToLayout(existing)
|
||||
previousLayout: yNodeToLayout(existing)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -260,7 +239,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
actor: this.currentActor
|
||||
})
|
||||
} else {
|
||||
const existingLayout = this.yNodeToLayout(existing)
|
||||
const existingLayout = yNodeToLayout(existing)
|
||||
|
||||
// Check what properties changed
|
||||
if (
|
||||
@@ -330,8 +309,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
for (const [nodeId] of this.ynodes) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode) {
|
||||
const layout = this.yNodeToLayout(ynode)
|
||||
if (layout && this.boundsIntersect(layout.bounds, bounds)) {
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout && boundsIntersect(layout.bounds, bounds)) {
|
||||
result.push(nodeId)
|
||||
}
|
||||
}
|
||||
@@ -352,7 +331,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
for (const [nodeId] of this.ynodes) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode) {
|
||||
const layout = this.yNodeToLayout(ynode)
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout) {
|
||||
result.set(nodeId, layout)
|
||||
}
|
||||
@@ -378,7 +357,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
for (const [nodeId] of this.ynodes) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode) {
|
||||
const layout = this.yNodeToLayout(ynode)
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout) {
|
||||
nodes.push([nodeId, layout])
|
||||
}
|
||||
@@ -389,7 +368,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
nodes.sort(([, a], [, b]) => b.zIndex - a.zIndex)
|
||||
|
||||
for (const [nodeId, layout] of nodes) {
|
||||
if (this.pointInBounds(point, layout.bounds)) {
|
||||
if (pointInBounds(point, layout.bounds)) {
|
||||
return nodeId
|
||||
}
|
||||
}
|
||||
@@ -561,16 +540,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return this.rerouteLayouts.get(rerouteId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create internal key for link segment
|
||||
*/
|
||||
private makeLinkSegmentKey(
|
||||
linkId: LinkId,
|
||||
rerouteId: RerouteId | null
|
||||
): string {
|
||||
return `${linkId}:${rerouteId ?? 'final'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Update link segment layout data
|
||||
*/
|
||||
@@ -579,7 +548,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
rerouteId: RerouteId | null,
|
||||
layout: Omit<LinkSegmentLayout, 'linkId' | 'rerouteId'>
|
||||
): void {
|
||||
const key = this.makeLinkSegmentKey(linkId, rerouteId)
|
||||
const key = makeLinkSegmentKey(linkId, rerouteId)
|
||||
const existing = this.linkSegmentLayouts.get(key)
|
||||
|
||||
// Short-circuit if bounds and centerPos unchanged (prevents spatial index churn)
|
||||
@@ -629,7 +598,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
* Delete link segment layout data
|
||||
*/
|
||||
deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void {
|
||||
const key = this.makeLinkSegmentKey(linkId, rerouteId)
|
||||
const key = makeLinkSegmentKey(linkId, rerouteId)
|
||||
const deleted = this.linkSegmentLayouts.delete(key)
|
||||
if (deleted) {
|
||||
// Remove from spatial index
|
||||
@@ -693,7 +662,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
rerouteId: segmentLayout.rerouteId
|
||||
}
|
||||
}
|
||||
} else if (this.pointInBounds(point, segmentLayout.bounds)) {
|
||||
} else if (pointInBounds(point, segmentLayout.bounds)) {
|
||||
// Fallback to bounding box test
|
||||
return {
|
||||
linkId: segmentLayout.linkId,
|
||||
@@ -733,7 +702,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
// Check precise bounds for candidates
|
||||
for (const key of candidateSlotKeys) {
|
||||
const slotLayout = this.slotLayouts.get(key)
|
||||
if (slotLayout && this.pointInBounds(point, slotLayout.bounds)) {
|
||||
if (slotLayout && pointInBounds(point, slotLayout.bounds)) {
|
||||
return slotLayout
|
||||
}
|
||||
}
|
||||
@@ -969,7 +938,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
}
|
||||
|
||||
this.ynodes.set(layout.id, this.layoutToYNode(layout))
|
||||
this.ynodes.set(layout.id, layoutToYNode(layout))
|
||||
|
||||
// Add to spatial index
|
||||
this.spatialIndex.insert(layout.id, layout.bounds)
|
||||
@@ -987,7 +956,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return
|
||||
}
|
||||
|
||||
const size = this.getNodeField(ynode, 'size')
|
||||
const size = yNodeToLayout(ynode).size
|
||||
const newBounds = {
|
||||
x: operation.position.x,
|
||||
y: operation.position.y,
|
||||
@@ -1016,7 +985,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
if (!ynode) return
|
||||
|
||||
const position = this.getNodeField(ynode, 'position')
|
||||
const position = yNodeToLayout(ynode).position
|
||||
const newBounds = {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
@@ -1053,7 +1022,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: CreateNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const ynode = this.layoutToYNode(operation.layout)
|
||||
const ynode = layoutToYNode(operation.layout)
|
||||
this.ynodes.set(operation.nodeId, ynode)
|
||||
|
||||
// Add to spatial index
|
||||
@@ -1187,7 +1156,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
* Update node bounds helper
|
||||
*/
|
||||
private updateNodeBounds(
|
||||
ynode: Y.Map<unknown>,
|
||||
ynode: NodeLayoutMap,
|
||||
position: Point,
|
||||
size: { width: number; height: number }
|
||||
): void {
|
||||
@@ -1335,27 +1304,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private layoutToYNode(layout: NodeLayout): Y.Map<unknown> {
|
||||
const ynode = new Y.Map<unknown>()
|
||||
ynode.set('id', layout.id)
|
||||
ynode.set('position', layout.position)
|
||||
ynode.set('size', layout.size)
|
||||
ynode.set('zIndex', layout.zIndex)
|
||||
ynode.set('visible', layout.visible)
|
||||
ynode.set('bounds', layout.bounds)
|
||||
return ynode
|
||||
}
|
||||
|
||||
private yNodeToLayout(ynode: Y.Map<unknown>): NodeLayout {
|
||||
return {
|
||||
id: this.getNodeField(ynode, 'id'),
|
||||
position: this.getNodeField(ynode, 'position'),
|
||||
size: this.getNodeField(ynode, 'size'),
|
||||
zIndex: this.getNodeField(ynode, 'zIndex'),
|
||||
visible: this.getNodeField(ynode, 'visible'),
|
||||
bounds: this.getNodeField(ynode, 'bounds')
|
||||
}
|
||||
}
|
||||
|
||||
private notifyChange(change: LayoutChange): void {
|
||||
this.changeListeners.forEach((listener) => {
|
||||
@@ -1367,24 +1315,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
})
|
||||
}
|
||||
|
||||
private pointInBounds(point: Point, bounds: Bounds): boolean {
|
||||
return (
|
||||
point.x >= bounds.x &&
|
||||
point.x <= bounds.x + bounds.width &&
|
||||
point.y >= bounds.y &&
|
||||
point.y <= bounds.y + bounds.height
|
||||
)
|
||||
}
|
||||
|
||||
private boundsIntersect(a: Bounds, b: Bounds): boolean {
|
||||
return !(
|
||||
a.x + a.width < b.x ||
|
||||
b.x + b.width < a.x ||
|
||||
a.y + a.height < b.y ||
|
||||
b.y + b.height < a.y
|
||||
)
|
||||
}
|
||||
|
||||
// CRDT-specific methods
|
||||
getOperationsSince(timestamp: number): LayoutOperation[] {
|
||||
const operations: LayoutOperation[] = []
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
|
||||
import { useTransformSettling } from '@/composables/graph/useTransformSettling'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useTransformState } from '@/renderer/core/layout/useTransformState'
|
||||
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
@@ -1,6 +1,6 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
interface CanvasTransformSyncOptions {
|
||||
/**
|
||||
21
src/renderer/core/layout/utils/layoutMath.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Bounds, Point } from '@/renderer/core/layout/types'
|
||||
|
||||
export const REROUTE_RADIUS = 8
|
||||
|
||||
export function pointInBounds(point: Point, bounds: Bounds): boolean {
|
||||
return (
|
||||
point.x >= bounds.x &&
|
||||
point.x <= bounds.x + bounds.width &&
|
||||
point.y >= bounds.y &&
|
||||
point.y <= bounds.y + bounds.height
|
||||
)
|
||||
}
|
||||
|
||||
export function boundsIntersect(a: Bounds, b: Bounds): boolean {
|
||||
return !(
|
||||
a.x + a.width < b.x ||
|
||||
b.x + b.width < a.x ||
|
||||
a.y + a.height < b.y ||
|
||||
b.y + b.height < a.y
|
||||
)
|
||||
}
|
||||
11
src/renderer/core/layout/utils/layoutUtils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { LinkId, RerouteId } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Creates a unique key for identifying link segments in spatial indexes
|
||||
*/
|
||||
export function makeLinkSegmentKey(
|
||||
linkId: LinkId,
|
||||
rerouteId: RerouteId | null
|
||||
): string {
|
||||
return `${linkId}:${rerouteId ?? 'final'}`
|
||||
}
|
||||
45
src/renderer/core/layout/utils/mappers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
export type NodeLayoutMap = Y.Map<NodeLayout[keyof NodeLayout]>
|
||||
|
||||
export const NODE_LAYOUT_DEFAULTS: NodeLayout = {
|
||||
id: 'unknown-node',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 50 },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 100, height: 50 }
|
||||
}
|
||||
|
||||
export function layoutToYNode(layout: NodeLayout): NodeLayoutMap {
|
||||
const ynode = new Y.Map<NodeLayout[keyof NodeLayout]>() as NodeLayoutMap
|
||||
ynode.set('id', layout.id)
|
||||
ynode.set('position', layout.position)
|
||||
ynode.set('size', layout.size)
|
||||
ynode.set('zIndex', layout.zIndex)
|
||||
ynode.set('visible', layout.visible)
|
||||
ynode.set('bounds', layout.bounds)
|
||||
return ynode
|
||||
}
|
||||
|
||||
function getOr<K extends keyof NodeLayout>(
|
||||
map: NodeLayoutMap,
|
||||
key: K,
|
||||
fallback: NodeLayout[K]
|
||||
): NodeLayout[K] {
|
||||
const v = map.get(key)
|
||||
return (v ?? fallback) as NodeLayout[K]
|
||||
}
|
||||
|
||||
export function yNodeToLayout(ynode: NodeLayoutMap): NodeLayout {
|
||||
return {
|
||||
id: getOr(ynode, 'id', NODE_LAYOUT_DEFAULTS.id),
|
||||
position: getOr(ynode, 'position', NODE_LAYOUT_DEFAULTS.position),
|
||||
size: getOr(ynode, 'size', NODE_LAYOUT_DEFAULTS.size),
|
||||
zIndex: getOr(ynode, 'zIndex', NODE_LAYOUT_DEFAULTS.zIndex),
|
||||
visible: getOr(ynode, 'visible', NODE_LAYOUT_DEFAULTS.visible),
|
||||
bounds: getOr(ynode, 'bounds', NODE_LAYOUT_DEFAULTS.bounds)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useRafFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
|
||||
258
src/renderer/extensions/vueNodes/components/ImagePreview.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="image-preview relative group flex flex-col items-center"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
class="relative rounded-[5px] overflow-hidden w-full max-w-[352px] bg-[#262729]"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="imageError"
|
||||
class="w-full h-[352px] flex flex-col items-center justify-center text-white text-center bg-gray-800/50"
|
||||
>
|
||||
<i-lucide:image-off class="w-12 h-12 mb-2 text-gray-400" />
|
||||
<p class="text-sm text-gray-300">{{ $t('g.imageFailedToLoad') }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ currentImageUrl }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Skeleton
|
||||
v-else-if="isLoading"
|
||||
class="w-full h-[352px]"
|
||||
border-radius="5px"
|
||||
/>
|
||||
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-else
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="w-full h-[352px] object-cover block"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover) -->
|
||||
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
v-if="!hasMultipleImages"
|
||||
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
|
||||
:title="$t('g.editOrMaskImage')"
|
||||
:aria-label="$t('g.editOrMaskImage')"
|
||||
@click="handleEditMask"
|
||||
>
|
||||
<i-lucide:venetian-mask class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
|
||||
:title="$t('g.downloadImage')"
|
||||
:aria-label="$t('g.downloadImage')"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i-lucide:download class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
|
||||
:title="$t('g.removeImage')"
|
||||
:aria-label="$t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<i-lucide:x class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Images Navigation -->
|
||||
<div
|
||||
v-if="hasMultipleImages"
|
||||
class="absolute bottom-2 left-2 right-2 flex justify-center gap-1"
|
||||
>
|
||||
<button
|
||||
v-for="(_, index) in imageUrls"
|
||||
:key="index"
|
||||
:class="getNavigationDotClass(index)"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: imageUrls.length
|
||||
})
|
||||
"
|
||||
@click="setCurrentIndex(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<div class="text-white text-xs text-center mt-2">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-gray-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
readonly imageUrls: readonly string[]
|
||||
/** Optional node ID for context-aware actions */
|
||||
readonly nodeId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<ImagePreviewProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
// Component state
|
||||
const currentIndex = ref(0)
|
||||
const isHovered = ref(false)
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Computed values
|
||||
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
|
||||
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
|
||||
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
|
||||
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
(newUrls) => {
|
||||
// Reset current index if it's out of bounds
|
||||
if (currentIndex.value >= newUrls.length) {
|
||||
currentIndex.value = 0
|
||||
}
|
||||
|
||||
// Reset loading and error states when URLs change
|
||||
actualDimensions.value = null
|
||||
imageError.value = false
|
||||
isLoading.value = false
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
const handleImageLoad = (event: Event) => {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
isLoading.value = false
|
||||
imageError.value = false
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
isLoading.value = false
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
|
||||
const handleEditMask = () => {
|
||||
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
downloadFile(currentImageUrl.value)
|
||||
} catch (error) {
|
||||
useToast().add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
life: 3000,
|
||||
group: 'image-preview'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
if (!props.nodeId) return
|
||||
nodeOutputStore.removeNodeOutputs(props.nodeId)
|
||||
}
|
||||
|
||||
const setCurrentIndex = (index: number) => {
|
||||
if (index >= 0 && index < props.imageUrls.length) {
|
||||
currentIndex.value = index
|
||||
actualDimensions.value = null
|
||||
isLoading.value = true
|
||||
imageError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
}
|
||||
|
||||
const getNavigationDotClass = (index: number) => {
|
||||
return [
|
||||
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',
|
||||
index === currentIndex.value ? 'bg-white' : 'bg-white/50 hover:bg-white/80'
|
||||
]
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (props.imageUrls.length <= 1) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(
|
||||
currentIndex.value > 0
|
||||
? currentIndex.value - 1
|
||||
: props.imageUrls.length - 1
|
||||
)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(
|
||||
currentIndex.value < props.imageUrls.length - 1
|
||||
? currentIndex.value + 1
|
||||
: 0
|
||||
)
|
||||
break
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(0)
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(props.imageUrls.length - 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { type ComponentMountingOptions, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { useDomSlotRegistration } from '@/renderer/core/layout/slots/useDomSlotRegistration'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
import OutputSlot from './OutputSlot.vue'
|
||||
|
||||
// Mock composable used by InputSlot/OutputSlot so we can assert call params
|
||||
vi.mock('@/renderer/core/layout/slots/useDomSlotRegistration', () => ({
|
||||
useDomSlotRegistration: vi.fn(() => ({ remeasure: vi.fn() }))
|
||||
}))
|
||||
|
||||
type InputSlotProps = ComponentMountingOptions<typeof InputSlot>['props']
|
||||
type OutputSlotProps = ComponentMountingOptions<typeof OutputSlot>['props']
|
||||
|
||||
const mountInputSlot = (props: InputSlotProps) =>
|
||||
mount(InputSlot, {
|
||||
global: {
|
||||
plugins: [
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
}),
|
||||
createPinia()
|
||||
]
|
||||
},
|
||||
props
|
||||
})
|
||||
|
||||
const mountOutputSlot = (props: OutputSlotProps) =>
|
||||
mount(OutputSlot, {
|
||||
global: {
|
||||
plugins: [
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
}),
|
||||
createPinia()
|
||||
]
|
||||
},
|
||||
props
|
||||
})
|
||||
|
||||
describe('InputSlot/OutputSlot', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useDomSlotRegistration).mockClear()
|
||||
})
|
||||
|
||||
it('InputSlot registers with correct options', () => {
|
||||
mountInputSlot({
|
||||
nodeId: 'node-1',
|
||||
index: 3,
|
||||
slotData: { name: 'A', type: 'any', boundingRect: [0, 0, 0, 0] }
|
||||
})
|
||||
|
||||
expect(useDomSlotRegistration).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
nodeId: 'node-1',
|
||||
slotIndex: 3,
|
||||
isInput: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('OutputSlot registers with correct options', () => {
|
||||
mountOutputSlot({
|
||||
nodeId: 'node-2',
|
||||
index: 1,
|
||||
slotData: { name: 'B', type: 'any', boundingRect: [0, 0, 0, 0] }
|
||||
})
|
||||
|
||||
expect(useDomSlotRegistration).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
nodeId: 'node-2',
|
||||
slotIndex: 1,
|
||||
isInput: false
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-[#9FA2BD] text-[#888682]"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
|
||||
@@ -8,20 +8,19 @@
|
||||
:class="
|
||||
cn(
|
||||
'bg-white dark-theme:bg-charcoal-100',
|
||||
'min-w-[445px]',
|
||||
'lg-node absolute rounded-2xl',
|
||||
// border
|
||||
'border border-solid border-sand-100 dark-theme:border-charcoal-300',
|
||||
!!executing && 'border-blue-500 dark-theme:border-blue-500',
|
||||
!!error && 'border-red-700 dark-theme:border-red-300',
|
||||
!!executing && 'border-blue-100 dark-theme:border-blue-100',
|
||||
hasAnyError && 'border-error',
|
||||
// hover
|
||||
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
|
||||
// Selected
|
||||
'outline-transparent -outline-offset-2 outline-2',
|
||||
!!isSelected && 'outline-black dark-theme:outline-white',
|
||||
!!(isSelected && executing) &&
|
||||
'outline-blue-500 dark-theme:outline-blue-500',
|
||||
!!(isSelected && error) && 'outline-red-500 dark-theme:outline-red-500',
|
||||
'outline-blue-100 dark-theme:outline-blue-100',
|
||||
isSelected && hasAnyError && 'outline-error',
|
||||
{
|
||||
'animate-pulse': executing,
|
||||
'opacity-50': nodeData.mode === 4,
|
||||
@@ -119,6 +118,7 @@
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
:image-urls="nodeImageUrls"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -126,14 +126,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onErrorCaptured, ref, toRef, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onErrorCaptured,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
toRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
|
||||
@@ -149,13 +164,18 @@ interface LGraphNodeProps {
|
||||
position?: { x: number; y: number }
|
||||
size?: { width: number; height: number }
|
||||
readonly?: boolean
|
||||
executing?: boolean
|
||||
progress?: number
|
||||
error?: string | null
|
||||
zoomLevel?: number
|
||||
}
|
||||
|
||||
const props = defineProps<LGraphNodeProps>()
|
||||
const {
|
||||
nodeData,
|
||||
position,
|
||||
size,
|
||||
error = null,
|
||||
readonly = false,
|
||||
zoomLevel = 1
|
||||
} = defineProps<LGraphNodeProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'node-click': [
|
||||
@@ -174,7 +194,7 @@ const emit = defineEmits<{
|
||||
'update:title': [nodeId: string, newTitle: string]
|
||||
}>()
|
||||
|
||||
useVueElementTracking(props.nodeData.id, 'node')
|
||||
useVueElementTracking(nodeData.id, 'node')
|
||||
|
||||
// Inject selection state from parent
|
||||
const selectedNodeIds = inject(SelectedNodeIdsKey)
|
||||
@@ -184,13 +204,42 @@ if (!selectedNodeIds) {
|
||||
)
|
||||
}
|
||||
|
||||
// Inject transform state for coordinate conversion
|
||||
const transformState = inject('transformState') as
|
||||
| {
|
||||
camera: { z: number }
|
||||
canvasToScreen: (point: { x: number; y: number }) => {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
screenToCanvas: (point: { x: number; y: number }) => {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
|
||||
// Computed selection state - only this node re-evaluates when its selection changes
|
||||
const isSelected = computed(() => {
|
||||
return selectedNodeIds.value.has(props.nodeData.id)
|
||||
return selectedNodeIds.value.has(nodeData.id)
|
||||
})
|
||||
|
||||
// Use execution state composable
|
||||
const { executing, progress } = useNodeExecutionState(nodeData.id)
|
||||
|
||||
// Direct access to execution store for error state
|
||||
const executionStore = useExecutionStore()
|
||||
const hasExecutionError = computed(
|
||||
() => executionStore.lastExecutionErrorNodeId === nodeData.id
|
||||
)
|
||||
|
||||
// Computed error states for styling
|
||||
const hasAnyError = computed(
|
||||
(): boolean => !!(hasExecutionError.value || nodeData.hasErrors || error)
|
||||
)
|
||||
|
||||
// LOD (Level of Detail) system based on zoom level
|
||||
const zoomRef = toRef(() => props.zoomLevel ?? 1)
|
||||
const zoomRef = toRef(() => zoomLevel)
|
||||
const {
|
||||
lodLevel,
|
||||
shouldRenderWidgets,
|
||||
@@ -218,8 +267,20 @@ const {
|
||||
zIndex,
|
||||
startDrag,
|
||||
handleDrag: handleLayoutDrag,
|
||||
endDrag
|
||||
} = useNodeLayout(props.nodeData.id)
|
||||
endDrag,
|
||||
resize
|
||||
} = useNodeLayout(nodeData.id)
|
||||
|
||||
onMounted(() => {
|
||||
if (size && transformState) {
|
||||
const scale = transformState.camera.z
|
||||
const screenSize = {
|
||||
width: size.width * scale,
|
||||
height: size.height * scale
|
||||
}
|
||||
resize(screenSize)
|
||||
}
|
||||
})
|
||||
|
||||
// Drag state for styling
|
||||
const isDragging = ref(false)
|
||||
@@ -232,11 +293,11 @@ const lastX = ref(0)
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
|
||||
// Track collapsed state
|
||||
const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false)
|
||||
const isCollapsed = ref(nodeData.flags?.collapsed ?? false)
|
||||
|
||||
// Watch for external changes to the collapsed state
|
||||
watch(
|
||||
() => props.nodeData.flags?.collapsed,
|
||||
() => nodeData.flags?.collapsed,
|
||||
(newCollapsed: boolean | undefined) => {
|
||||
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
|
||||
isCollapsed.value = newCollapsed
|
||||
@@ -244,11 +305,10 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Check if node has custom content
|
||||
// Check if node has custom content (like image outputs)
|
||||
const hasCustomContent = computed(() => {
|
||||
// Currently all content is handled through widgets
|
||||
// This remains false but provides extensibility point
|
||||
return false
|
||||
// Show custom content if node has image outputs
|
||||
return nodeImageUrls.value.length > 0
|
||||
})
|
||||
|
||||
// Computed classes and conditions for better reusability
|
||||
@@ -258,7 +318,7 @@ const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
|
||||
|
||||
// Common condition computations to avoid repetition
|
||||
const shouldShowWidgets = computed(
|
||||
() => shouldRenderWidgets.value && props.nodeData.widgets?.length
|
||||
() => shouldRenderWidgets.value && nodeData.widgets?.length
|
||||
)
|
||||
|
||||
const shouldShowContent = computed(
|
||||
@@ -267,7 +327,7 @@ const shouldShowContent = computed(
|
||||
|
||||
// Event handlers
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!props.nodeData) {
|
||||
if (!nodeData) {
|
||||
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
|
||||
return
|
||||
}
|
||||
@@ -294,13 +354,13 @@ const handlePointerUp = (event: PointerEvent) => {
|
||||
const dx = event.clientX - lastX.value
|
||||
const dy = event.clientY - lastY.value
|
||||
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
|
||||
emit('node-click', event, props.nodeData, wasDragging)
|
||||
emit('node-click', event, nodeData, wasDragging)
|
||||
}
|
||||
|
||||
const handleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
// Emit event so parent can sync with LiteGraph if needed
|
||||
emit('update:collapsed', props.nodeData.id, isCollapsed.value)
|
||||
emit('update:collapsed', nodeData.id, isCollapsed.value)
|
||||
}
|
||||
|
||||
const handleSlotClick = (
|
||||
@@ -308,14 +368,58 @@ const handleSlotClick = (
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) => {
|
||||
if (!props.nodeData) {
|
||||
if (!nodeData) {
|
||||
console.warn('LGraphNode: nodeData is null/undefined in handleSlotClick')
|
||||
return
|
||||
}
|
||||
emit('slot-click', event, props.nodeData, slotIndex, isInput)
|
||||
emit('slot-click', event, nodeData, slotIndex, isInput)
|
||||
}
|
||||
|
||||
const handleTitleUpdate = (newTitle: string) => {
|
||||
emit('update:title', props.nodeData.id, newTitle)
|
||||
emit('update:title', nodeData.id, newTitle)
|
||||
}
|
||||
|
||||
const nodeOutputs = useNodeOutputStore()
|
||||
|
||||
const nodeImageUrls = ref<string[]>([])
|
||||
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
|
||||
// Construct proper locator ID using subgraph ID from VueNodeData
|
||||
const locatorId = nodeData.subgraphId
|
||||
? `${nodeData.subgraphId}:${nodeData.id}`
|
||||
: nodeData.id
|
||||
|
||||
// Use root graph for getNodeByLocatorId since it needs to traverse from root
|
||||
const rootGraph = app.graph?.rootGraph || app.graph
|
||||
if (!rootGraph) {
|
||||
nodeImageUrls.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const node = getNodeByLocatorId(rootGraph, locatorId)
|
||||
|
||||
if (node && newOutputs?.images?.length) {
|
||||
const urls = nodeOutputs.getNodeImageUrls(node)
|
||||
if (urls) {
|
||||
nodeImageUrls.value = urls
|
||||
}
|
||||
} else {
|
||||
// Clear URLs if no outputs or no images
|
||||
nodeImageUrls.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const nodeOutputLocatorId = computed(() =>
|
||||
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
|
||||
)
|
||||
|
||||
watch(
|
||||
() => nodeOutputs.nodeOutputs[nodeOutputLocatorId.value],
|
||||
(newOutputs) => {
|
||||
onNodeOutputsUpdate(newOutputs)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Provide nodeImageUrls to child components
|
||||
provide('nodeImageUrls', nodeImageUrls)
|
||||
</script>
|
||||
|
||||
@@ -5,28 +5,42 @@
|
||||
<div v-else class="lg-node-content">
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<!-- This component serves as a placeholder for future extensibility -->
|
||||
<!-- Currently all node content is rendered through the widget system -->
|
||||
<ImagePreview
|
||||
v-if="hasImages"
|
||||
:image-urls="props.imageUrls || []"
|
||||
:node-id="nodeId"
|
||||
class="mt-2"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onErrorCaptured, ref } from 'vue'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
interface NodeContentProps {
|
||||
node?: LGraphNode // For backwards compatibility
|
||||
nodeData?: VueNodeData // New clean data structure
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
imageUrls?: string[]
|
||||
}
|
||||
|
||||
defineProps<NodeContentProps>()
|
||||
const props = defineProps<NodeContentProps>()
|
||||
|
||||
const hasImages = computed(() => props.imageUrls && props.imageUrls.length > 0)
|
||||
|
||||
// Get node ID from nodeData or node prop
|
||||
const nodeId = computed(() => {
|
||||
return props.nodeData?.id?.toString() || props.node?.id?.toString()
|
||||
})
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
>
|
||||
<i
|
||||
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
|
||||
class="text-xs leading-none relative top-[1px] text-[#888682] dark-theme:text-[#5B5E7D]"
|
||||
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
@@ -74,15 +74,23 @@ const isEditing = ref(false)
|
||||
|
||||
const nodeInfo = computed(() => props.nodeData || props.node)
|
||||
|
||||
// Local state for title to provide immediate feedback
|
||||
const displayTitle = ref(nodeInfo.value?.title || 'Untitled')
|
||||
const resolveTitle = (info: LGraphNode | VueNodeData | undefined) => {
|
||||
const title = (info?.title ?? '').trim()
|
||||
if (title.length > 0) return title
|
||||
const type = (info?.type ?? '').trim()
|
||||
return type.length > 0 ? type : 'Untitled'
|
||||
}
|
||||
|
||||
// Watch for external changes to the node title
|
||||
// Local state for title to provide immediate feedback
|
||||
const displayTitle = ref(resolveTitle(nodeInfo.value))
|
||||
|
||||
// Watch for external changes to the node title or type
|
||||
watch(
|
||||
() => nodeInfo.value?.title,
|
||||
(newTitle) => {
|
||||
if (newTitle && newTitle !== displayTitle.value) {
|
||||
displayTitle.value = newTitle
|
||||
() => [nodeInfo.value?.title, nodeInfo.value?.type] as const,
|
||||
() => {
|
||||
const next = resolveTitle(nodeInfo.value)
|
||||
if (next !== displayTitle.value) {
|
||||
displayTitle.value = next
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -109,7 +117,5 @@ const handleTitleEdit = (newTitle: string) => {
|
||||
|
||||
const handleTitleCancel = () => {
|
||||
isEditing.value = false
|
||||
// Reset displayTitle to the current node title
|
||||
displayTitle.value = nodeInfo.value?.title || 'Untitled'
|
||||
}
|
||||
</script>
|
||||
|
||||